Введення залежностей у TypeScript monorepo: InversifyJS для React та Angular
Введення залежностей у TypeScript monorepo: виберіть контейнер, визначте типобезпечні токени, сформуйте Composition Root та з’єднайте його з React, Vue та Angular.
Джерела: Frontend Dependency Injection: From Context API to Composition Root
Великі кодові бази фронтенду рідко зазнають невдач через один несправний компонент; вони зазнають невдач тому, що бізнес-правила поступово переплітаються з React hooks, HTTP-клієнтами та API для зберігання даних, поки неможливо протестувати чи повторно використовувати щось окремо. Введення залежностей — це механізм низького рівня, який дозволяє тримати ці правила окремими, але лише за умови свідомого проектування структури зв’язків. У цьому посібнику розглядається вибір контейнера для монорепозиторію TypeScript з кількома доменами, визначення типобезпечних токенів, ізоляція кожного домену за допомогою окремого модуля, формування структури в Composition Root та її використання в React, Vue та Angular через мости, складені з десятка рядків кожен.
Чому ізоляцію потрібно визначити ще до моделювання домену
Класичні рекомендації щодо дизайну, заснованого на доменах, радять відкласти деталі реалізації: спочатку створюйте ентитети та сценарії використання, а інструменти обирайте пізніше. На практиці команда, яка розпочинає масштабні зміни в архітектурі, отримує перевагу, вирішуючи заздалегідь одне технічне питання — а саме, як саме буде забезпечуватися ізоляція модулів. Без узгодженого механізму перші кілька доменів формуються спонтанно, і стандарти так і не встановлюються.
Основним принципом є принцип інверсії залежностей: політика високого рівня має залежати від абстракцій, а конкретні деталі — від цих самих абстракцій. Ін’єкція залежностей — це практична техніка, яка його реалізує. Класи запитують абстрактні токени замість конкретних реалізацій, і щось ззовні вирішує, на що саме вони посилаються. У підходах DDD та Clean Architecture це не просто додаткова оптимізація; домен не повинен знати, з якою фреймворком користувацького інтерфейсу, HTTP-клієнтом чи шаром зберігання він працює.
Більшість команд фронтенду спочатку звертаються до React Context (або provide/inject у Vue). Це здається достатнім: оголошується значення біля кореня, і його можна читати будь-де нижче. Однак обмеження проявляються швидко. Немає керування життєвим циклом, немає поняття фабрик чи областей видимості, і кожен споживач значення тепер прив’язаний до середовища виконання React, тож логіка не може працювати чи тестуватися без нього. Шлях від цієї вихідної точки до належного Composition Root — саме про це йдеться у решті цього посібника. Створення власного контейнера рідко є доцільним, тому першим кроком є чесна оцінка існуючих варіантів IoC.
Критерії для контейнера впровадження залежностей у фронтенді
Фреймворки бекенду, такі як NestJS, Spring та .NET, постачаються з механізмом DI як готовим, стандартним рішенням. Код фронтенду має додаткові обмеження: витрати під час виконання, обмеження щодо розміру пакетів та необхідність роботи з кількома фреймворками користувацького інтерфейсу. П’ять критеріїв допомагають зберегти чіткість цілей оцінки:
- Агностичність до фреймворку. Контейнер має працювати зі звичайним TypeScript без жодних залежностей від React, Vue чи Angular, а також ефективно функціонувати в браузері, у Node.js для SSR та у React Native.
- Модульність. Кожна сфера має можливість надавати заздалегідь налаштований модуль із закритими зв’язками всередині, щоб додаток завантажував лише його. Це допомагає уникнути ситуації, коли файл
main.tsмістить 500 рядків коду, у яких кожна служба реєструється вручну. - Керування життєвим циклом. Механізми створення синглтонів, тимчасових екземплярів та їх обмеженої реалізації мають бути на першому місці.
Кандидати
InversifyJS
InversifyJS вже давно є стандартним вибором для великих проектів на TypeScript та є найбільш повним контейнером IoC у цій екосистемі. Він не залежить від конкретної фреймворк-системи та оголошує залежності за допомогою декораторів. Його ContainerModule групує елементи зв’язку в логічні одиниці, а також підтримує режими inSingletonScope, inTransientScope та inRequestScope, дочірні контейнери, завантаження та зняття модулів під час виконання та хуки активації. Недоліком є великий розмір: це був найважчий за розміром варіант, тож називати поточні версії легкими було б оманливо.
TSyringe
TSyringe, який підтримується Microsoft, жертвує широтою функціоналу заради зручності. Він автоматично вирішує проблему залежностей конструкторів на основі метаданих TypeScript, пропонує розширений набір декораторів та підтримує звичайні моделі життєвого циклу (Singleton, Transient, ResolutionScoped, ContainerScoped). Для середнього за розміром додатку це привабливий варіант із мінімальними вимогами до налаштувань. Однак у архітектурі з кількома доменами є два недоліки: відсутній концепт модулів першого класу, тому композицію доводиться створювати вручну за допомогою функцій реєстрації або createChildContainer(), а також він залежить від polyfillу reflect-metadata, що значно збільшує об’єм коду.
Awilix
Awilix повністю уникає декораторів. Він використовує API ES6 Proxy для прив’язки залежностей до імен параметрів конструктора або до ключів об’єкта аргументів фабрики. Це робить його ідеальним вибором для команд, які не хочуть використовувати декоратори, і це був найменший самостійний контейнер, який було протестовано. Він підтримує режими життєвого циклу SINGLETON, SCOPED та TRANSIENT; модулі організовані за допомогою користувацьких функцій реєстрації.
Одна проблема, специфічна для фронтенду, заслуговує уваги. У режимі ін’єкції CLASSIC Awilix читає імена параметрів конструктора як рядки, а мініфайери змінюють їхні назви, тому процес розрішення зламується у продакшені. Лише стандартний режим PROXY витримує агресивну обробку коду.
Вбудований інжектор Angular
Angular має одну з найсильніших ієрархічних систем DI у розробці фронтенду, яка включає в себе вбудовану ін’єкцію на основі токенів, провайдери з обмеженим діапазоном дії та лежачу завантаження. Якби її можна було використовувати без рунтайму Angular, вона була б серйозним конкурентом для реалізації доменного шару. Однак це неможливо, і саме в цьому проблема.
React Context
Context насправді не є контейнером DI; це спосіб уникнення проблеми передачі значень через безліч параметрів. Провайдер розташовується близько до кореня, а компоненти отримують до нього доступ за допомогою useContext(). Будь-яка зміна значення, яке надається, змушує переробити всі компоненти-споживачі, причому немає жодного життєвого циклу, жодного прив’язування через фабрики та жодної ізоляції діапазону дії. Її єдиною справжньою перевагою є те, що вона не додає жодних додаткових об’ємів до пакету.
Vue provide та inject
Механізм Vue передає значення вниз по дереву компонентів та дотримується архітектурних обмежень Context: він не є практичною основою для графа доменних сервісів. Проте у нього є одна ергономічна перевага — залежності можна реєструвати глобально через плагін, замість того щоб обгортати дерево в вкладені провайдери. Сучасна функція inject() у Angular ґрунтується на схожій ідеї активного контексту ін’єкції.
Проста ін’єкція через конструктор
Підхід без додаткових бібліотек передає кожну залежність через конструктори явно. Він є повністю безпечним з точки зору типів та не має додаткових витрат під час виконання. Недоліком є масштабованість: коли у додатку є приблизно 10–15 доменних сервісів, ручне підключення елементів графа в точці входу перетворюється на великий, крихкий файл, до якого всі бояться торкатися.
Що показує порівняння
Розміри бундлів порівнювалися шляхом об’єднання вхідного файлу, який імпортує публічний API кожного пакету, за допомогою команди esbuild --bundle --minify --platform=browser, та стиснення отриманого результату за допомогою gzip -9; це дозволяє відокремити власні витрати контейнера від коду додатку. Було зроблено чотири висновки:
- Механізм інжекції з фреймворку належить до частини користувацького інтерфейсу, а не до сфери бізнес-логіки. Використання функції
useContext()або атрибута@Injectable()у бібліотеках сфери бізнес-логіки призводить до прив’язки бізнес-правил до одного фреймворку, що порушує перше критерій. - Ручна інжекція підходить лише для невеликих проектів. У великому корпоративному монорепозиторії механізм Composition Root перетворюється на тисячі рядків вручну написаних зв’язків між компонентами.
ContainerModule) є першокласною одиницею, яку бібліотека може створити, інкапсулювати та експортувати як єдиний цілий.tsconfig.json значень experimentalDecorators: true, emitDecoratorMetadata: true та target: ES2022. InversifyJS v8 більше не потребує окремого polyfill для reflect-metadata. За допомогою інструментів на основі esbuild або SWC (Vite, Next.js, Bun) необхідно переконатися, що етап трансформації підтримує старі метадані декораторів, зазвичай за допомогою плагіну, оскільки esbuild сам по собі не генерує метадані декораторів.Чотири правила, які запобігають перетворенню контейнера на пошуковик сервісів
Сам по собі контейнер нічого не ізолює; якщо ним недбало користуватися, він перетворюється на глобальну сумку сервісів, до якої може отримати доступ будь-який файл. Тому архітектура ґрунтується на чотирьох правилах:
- Абстрактні токени DI, створені за допомогою
Symbol.forта властивості$. - Кожна бібліотека домену експортує лише свій модуль контейнера, а не свої конкретні класи.
- Єдиний корень композиції складає граф застосунку.
- Простий міст для кожної платформи користувацького інтерфейсу забезпечує доступ до контейнера для компонентів.
Безпека типів за допомогою токенів від Symbol.for та $
Контейнеру потрібні ідентифікатори під час виконання, щоб відповідати абстракціям конкретним реалізаціям. Інтерфейси TypeScript зникають під час компіляції, тому вони не можуть безпосередньо виконувати цю функцію. Тут використовується паттерн, який поєднує кожен інтерфейс у пакеті @my-app/*-contracts із константою тієї самої назви, чия властивість $ містить об’єкт типу Symbol.
// libs/auth/contracts/src/lib/interfaces/auth.facade.ts
import type { ServiceIdentifier } from 'inversify';
export interface AuthFacade {
getAccessToken(): Promise<string | null>;
login(): Promise<void>;
logout(): Promise<void>;
}
export const AuthFacade = {
$: Symbol.for('AuthFacade') as ServiceIdentifier<AuthFacade>,
};
// libs/auth/contracts/src/lib/interfaces/pin.facade.ts
export interface PinFacade {
setupPin(pin: string): Promise<void>;
verifyPin(sub: string, pin: string): Promise<PinVerifyResult>;
changePin(sub: string, currentPin: string, newPin: string): Promise<PinVerifyResult>;
}
export const PinFacade = {
$: Symbol.for('PinFacade') as ServiceIdentifier<PinFacade>,
};
У цьому уривку містяться три рішення:
- Одна назва для типу та значення. TypeScript зберігає типи та значення у окремих просторах імен, тож
AuthFacadeможе бути водночас інтерфейсом та контейнером токенів. Компілятор вибирає правильне значення з контексту, і нікому не потрібно вигадувати назви на кшталтAuthFacadeToken. Symbol.forзамістьSymbol(). Кожен викликSymbol()повертає абсолютно нове значення, тоді якSymbol.for()шукає ключ у глобальному реєстрі символів часу виконання. Якщо пакет contracts опиняється у двох бундлах, що може трапитися в монорепо або з Module Federation, обидві копії все одно створюють однаковий токен, і пошуки продовжують працювати, замість того щоб таємниче зламатися. Недолік полягає у тому, що ключі реєстру є глобальними рядками, тому вони мають бути унікальними в усьому додатку.- Перетворення
ServiceIdentifier<AuthFacade>. Введення$якServiceIdentifier<T>від Inversify пов’язує токен із його інтерфейсом на етапі компіляції, тож місця резолюції можуть визначити правильний тип без необхідності використання явних генериків.
Наступний уривок демонструє обидві ситуації: клас, який отримує фасад через вставку конструктора, та прямий виклик container.get, тип значення якого визначається на основі токена.
import { inject, injectable } from 'inversify';
import { AuthFacade } from '@my-app/auth-contracts';
@injectable()
export class LoginPageComponent {
// Strongly-typed injection via token
constructor(@inject(AuthFacade.$) private readonly auth: AuthFacade) {}
}
// Resolution site automatically infers return type as AuthFacade
const facade = container.get(AuthFacade.$); // AuthFacade
Користувач посилається лише на контракт. Він не має жодного уявлення про те, що знаходиться всередині @my-app/auth-core, і саме в цьому суть.
Один домен, один ContainerModule
Основна бібліотека кожного домену надає лише одну річ — свій модуль DI. Сценарії використання, ентитети, порти та репозиторії залишаються приватними. Усередині модуля внутрішні сценарії використання прив’язані до самих себе, тоді як публічний фасад — до свого токена контракту.
// libs/auth/core/src/lib/auth-container.module.ts
import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';
import { AuthFacade } from '@my-app/auth-contracts';
import { CoreAuthFacade } from './facades/core-auth.facade';
import { LoginUseCase } from './use-cases/login.use-case';
import { LogoutUseCase } from './use-cases/logout.use-case';
import { GetAccessTokenUseCase } from './use-cases/get-access-token.use-case';
export const authContainerModule = new ContainerModule((options: ContainerModuleLoadOptions) => {
// Private use cases (registered to self within domain)
options.bind(LoginUseCase).toSelf().inSingletonScope();
options.bind(GetAccessTokenUseCase).toSelf().inSingletonScope();
options.bind(LogoutUseCase).toSelf().inSingletonScope();
// Public contract implementation bound to token
options.bind(AuthFacade.$).to(CoreAuthFacade).inSingletonScope();
});
// libs/auth/core/src/index.ts - ONLY the ContainerModule is re-exported!
export * from './lib/auth-container.module';
Інкапсуляція забезпечується двома способами:
- Публічний API. Файл
index.tsбібліотеки знову експортує лишеauthContainerModule. Класи на кшталтLoginUseCaseчиCoreAuthFacadeпросто недоступні з інших пакетів. - Правила лінтингу. Правило ESLint
@nx/enforce-module-boundariesвід Nx перевіряє теги проекту, тож, наприклад, проект із тегомtype:coreне може імпортувати проект із тегомtype:coreз іншої доменної структури. Деталі щодо оголошення тегів та обмежень можна знайти у документації Nx щодо меж модулів.
Межі експорту запобігають випадковим імпортам, а правила лінтингу — навмисному використанню складних шляхів. Разом вони роблять архітектуру чимось, що перевіряється системою CI, а не чимось, що потрібно пам’ятати рецензентам.
Корень композиції
Корень композиції — це єдине місце, де створюється граф залежностей, зазвичай apps/my-app/src/composition-root.ts або функція, яка викликається з main.ts. Його керує одне правило:
Корень композиції — це єдине місце в кодовій базі, де дозволено імпортувати конкретні реалізації, адаптери інфраструктури та основні модулі домену.
Наведена нижче функція спочатку під’єднує глобальну інфраструктуру (HTTP-клієнт на основі Axios та логгер, створений за допомогою фабрики, щоб він міг приймати налаштовані способи передачі даних), а потім завантажує модулі домену.
// apps/my-app/src/composition-root.ts
import { Container } from 'inversify';
import { HttpClientPort, LoggerPort } from '@my-app/shared-contracts';
import { AxiosHttpClient, BrowserLogger, ConsoleLogTransport, FileLogTransport } from '@my-app/shared-infrastructure';
import { authContainerModule } from '@my-app/auth-core';
import { paymentsContainerModule } from '@my-app/payments-core';
export const initAppContainer = (): Container => {
const container = new Container();
// 1. Bind global infrastructure adapters
container.bind(HttpClientPort.$).to(AxiosHttpClient).inSingletonScope();
container.bind(LoggerPort.$).toDynamicValue(() => new BrowserLogger({
transports: [
new ConsoleLogTransport(),
new FileLogTransport(),
],
})).inSingletonScope();
// 2. Load domain modules
container.load(authContainerModule);
container.load(paymentsContainerModule);
return container;
};
Для повноти картини, адаптер BrowserLogger — це звичайний клас, який реалізує LoggerPort та передає кожне повідомлення до відповідних каналів передачі даних. Зауважте, що він не містить жодних декораторів; оскільки він створюється всередині функції toDynamicValue, контейнеру ніколи не потрібно перевіряти його конструктор.
// libs/shared/infrastructure/src/lib/logging/browser-logger.ts
import type { LoggerPort } from '@my-app/shared-contracts';
import type { LogTransport } from './interfaces/log-transport.interface';
export interface BrowserLoggerOptions {
transports: LogTransport[];
}
export class BrowserLogger implements LoggerPort {
constructor(private readonly options: BrowserLoggerOptions) {}
info(message: string, ...args: unknown[]): void {
this.options.transports.forEach(t => t.write('INFO', message, args));
}
}
Кілька додатків з одного ядра домену
Оскільки інфраструктура обирається у Composition Root, різні додатки можуть повторно використовувати ті самі пакети домену з різними адаптерами та різним вибором модулів. Додаток React Native може використовувати сховище, підтримуване AsyncStorage, тоді як веб-додаток для адміністрування використовує IndexedDB та завантажує домен аудиту замість домену оплат.
// apps/mobile/src/composition-root.ts — React Native target
container.bind(StoragePort.$).to(AsyncStorageAdapter).inSingletonScope();
container.bind(LoggerPort.$).to(RnLogger).inSingletonScope();
container.load(authContainerModule);
container.load(paymentsContainerModule);
// apps/admin/src/composition-root.ts - Web Admin target
container.bind(StoragePort.$).to(IndexedDbAdapter).inSingletonScope();
container.bind(LoggerPort.$).to(BrowserLogger).inSingletonScope();
container.load(authContainerModule);
container.load(auditContainerModule); // Payments domain excluded entirely
@my-app/auth-core залишається абсолютно ідентичним байт за байтом у версіях для мобільних пристроїв, адміністраторів та десктопу. Він ніколи не дізнається, чи є сховище типу AsyncStorage чи IndexedDB.
Дозвіл одній доменній системі використовувати іншу
Припустимо, що payments-core потребує токена доступу, яким керує auth-core. Імпорт @my-app/auth-core заборонений правилами меж. Натомість сценарій оплати використовує токен контракту домену автентифікації, який знаходиться у пакеті contracts та дозволений до використання.
// libs/payments/core/src/lib/use-cases/create-payment.use-case.ts
import { inject, injectable } from 'inversify';
// Import from contracts, NOT core — permitted by boundary rules
import { AuthFacade } from '@my-app/auth-contracts';
import { PaymentsGatewayPort } from '../ports/payments-gateway.port';
@injectable()
export class CreatePaymentUseCase {
constructor(
@inject(AuthFacade.$) private readonly auth: AuthFacade,
@inject(PaymentsGatewayPort.$) private readonly gateway: PaymentsGatewayPort,
) {}
async execute(amount: number): Promise<void> {
const token = await this.auth.getAccessToken();
return this.gateway.processPayment(amount, token);
}
}
paymentsContainerModule не прив’язує AuthFacade.$; він лише його використовує. Прив’язка відбувається тоді, коли Composition Root завантажує обидва модулі в один і той самий контейнер. Практичні наслідки: якщо додаток завантажує функції оплати без автентифікації, процес розрішення відбувається з помилкою під час виконання, тому варто проводити тест на кожній меті додатку, щоб переконатися, що кожен публічний токен розрішується один раз.
З’єднання контейнера з React, Vue та Angular
Оскільки контейнер нічого не знає про будь-які бібліотеки користувацького інтерфейсу, кожна фреймворк-система отримує невеликий адаптер довжиною приблизно 10–15 рядків.
React
Контейнер створюється один раз під час запуску та більше не замінюється, тому значення контексту ніколи не змінюється. Це уникає ланцюгової переробки, яка зазвичай пов’язана з Context: користувачі отримують стабільний посилання. useInjection зберігає результат пошуку для кожного контейнера та токена та кидає зрозумілу помилку, якщо постачальник відсутній.
// libs/shared/react-di/src/lib/di-context.tsx
import React, { createContext, useContext, useMemo, type ReactNode } from 'react';
import type { Container, ServiceIdentifier } from 'inversify';
export interface DIProviderProps {
container: Container;
children: ReactNode;
}
const DIContext = createContext<Container | null>(null);
export const DIProvider = ({ container, children }: DIProviderProps) => (
<DIContext.Provider value={container}>
{children}
</DIContext.Provider>
);
export const useInjection = <T,>(token: ServiceIdentifier<T>): T => {
const container = useContext(DIContext);
return useMemo(() => {
if (!container) {
throw new Error('useInjection must be used within a DIProvider');
}
return container.get(token);
}, [container, token]);
};
Vue
Система плагінів Vue та типізований InjectionKey роблять адаптер ще коротшим. Плагін забезпечує контейнер на рівні додатку, а композабл розпізнає з нього токени.
// libs/shared/vue-di/src/lib/di-plugin.ts
import type { App, InjectionKey } from 'vue';
import { inject } from 'vue';
import type { Container, ServiceIdentifier } from 'inversify';
export const DI_CONTAINER: InjectionKey<Container> = Symbol('DI_CONTAINER');
export const diPlugin = {
install(app: App, { container }: { container: Container }) {
app.provide(DI_CONTAINER, container);
},
};
export const useInjection = <T>(token: ServiceIdentifier<T>): T => {
const container = inject(DI_CONTAINER);
if (!container) {
throw new Error('diPlugin is not installed');
}
return container.get(token);
};
Angular
Власний ієрархічний інжектор Angular може делегувати функції до контейнера домену через постачальників-фабрик. Передача контейнера за допомогою InjectionToken та створення нового контейнера під час кожного запуску запобігають тому, щоб одночасні запити, оброблені на сервері, ділилися станом.
// apps/angular-app/src/app/domain.providers.ts
import { InjectionToken, type Provider } from '@angular/core';
import type { Container } from 'inversify';
import { AuthFacade } from '@my-app/auth-contracts';
import { initAppContainer } from './composition-root';
export const DI_CONTAINER = new InjectionToken<Container>('DI_CONTAINER');
export const AUTH_FACADE = new InjectionToken<AuthFacade>('AUTH_FACADE');
export const provideDomainContainer = (container: Container): Provider[] => [
{
provide: DI_CONTAINER,
useValue: container,
},
{
provide: AUTH_FACADE,
useFactory: (c: Container) => c.get(AuthFacade.$),
deps: [DI_CONTAINER],
},
];
// main.ts - fresh container instance created per bootstrap
bootstrapApplication(AppComponent, {
providers: [...provideDomainContainer(initAppContainer())],
});
Кожен додатковий фасад компонентів Angular потребує власного InjectionToken та постачальника фабрики, тому список зростає разом із публічним API; його генерація з невеликого мапу є варіантом, коли список стає довгим.
Витрати та коли можна це пропустити
Налаштування додає близько 21 КБ у стиснутому вигляді та вимагає використання emitDecoratorMetadata під час збірки. Для корпоративного монорепозиторію з десятьма чи більше доменами ці витрати швидко окупуються. Для додатку з двома-трьома екранами така ізоляція є зайвим навантаженням; звичайна ін’єкція через конструктор чи навіть синглтони на рівні модуля підійдуть краще.
Правила ін’єкції залежностей у фронтенді
- Тримайте механізми ін’єкції залежностей фреймворку (Context, provide/inject, Angular providers) на периферії UI; ядро домену має залежати лише від токенів контракту.
ContainerModule, допомагає зберегти невеликий вхідний точку.Symbol.for типу ServiceIdentifier<T>, щоб одночасно уникнути дублювання пакетів та зберегти функціонал інференції типів.Пов’язана література
- Усунення проблеми перевантаження пропсів у React за допомогою композиції та слотів: Дізнайтеся, чому пропси у React із великою кількістю налаштувань створюють проблеми під час технічного обслуговування, та як інверсія керування, композиція та слоти дозволяють створювати справді повторно використовувані компоненти.
- Інтеграція інструментів MCP у інтерфейс чату у React із вбудованою людською затвердженням: Дізнайтеся, як протокол Model Context Protocol підходить для React-додатків: чому бекенд має хостувати MCP, як працює сервер інструментів, та як стрімувати та затверджувати виклики інструментів у інтерфейсі.