Фронтенд-DI, незалежний від фреймворку, з InversifyJS та коренем композиції
Як вибрати контейнер DI для TypeScript, запакувати кожну доменну частину як ContainerModule, з’єднати все в одному Composition Root та створити місток до React, Vue та Angular.
Великі кодові бази фронтенду рідко зазнають невдач через один несправний компонент; вони зазнають невдач тому, що бізнес-правила поступово переплітаються з React hooks, HTTP-клієнтами та API для зберігання даних, поки неможливо протестувати чи повторно використовувати щось окремо. Введення залежностей — це механізм низького рівня, який дозволяє тримати ці правила окремо, але лише за умови свідомого проектування структури залежностей. У цьому посібнику розглядається вибір контейнера для монорепозиторію TypeScript з кількома доменами, визначення типобезпечних токенів, ізоляція кожного домену за допомогою окремого модуля, формування структури в Composition Root та її використання у React, Vue та Angular через мости з десятком рядків кожен.
Чому ізоляцію потрібно визначити ще до моделювання домену
Класичні рекомендації щодо дизайну, заснованого на доменах, радять відкласти деталі реалізації: спочатку створюйте ентитети моделі та сценарії використання, а інструменти обирайте пізніше. На практиці команда, яка розпочинає масштабну архітектурну зміну, отримує перевагу від того, що заздалегідь вирішує одне технічне питання — а саме, як буде забезпечуватися ізоляція модулів. Без узгодженого механізму перші кілька доменів формуються спонтанно, і стандарти так і не встановлюються.
Основним принципом є принцип інверсії залежностей: політика високого рівня має залежати від абстракцій, а конкретні деталі — від цих самих абстракцій. вставка залежностей — це практична техніка, яка його реалізує. Класи запитують абстрактні токени замість конкретних реалізацій, і щось ззовні вирішує, на що саме вони посилаються. У підходах DDD та Clean Architecture це не просто додаткова оптимізація; область моделей не повинна знати, з якою фреймворком користувацького інтерфейсу, HTTP-клієнтом чи шаром зберігання вона працює.
Більшість команд фронтенду спочатку звертаються до React Context (або provide/inject у Vue). Це здається достатнім: оголошується значення біля кореня, і його можна читати будь-де нижче. Однак обмеження проявляються швидко. Немає керування життєвим циклом, немає поняття фабрик чи областей видимості, і кожен споживач значення тепер прив’язаний до середовища виконання React, тож логіка не може працювати чи перевірятися без нього. Шлях від цієї вихідної точки до належного Composition Root — саме про це йдеться у решті цього посібника. Створення власного контейнера рідко є доцільним, тому першим кроком є чесна оцінка існуючих варіантів IoC.
Критерії для контейнера DI у фронтенді
Фреймворки бекенду, такі як NestJS, Spring та .NET, постачаються з механізмом DI як готовим, стандартним рішенням. Код фронтенду має додаткові обмеження: витрати під час виконання, обмеження щодо розміру пакетів та необхідність роботи з кількома фреймворками користувацького інтерфейсу. П’ять критеріїв допомагають зберегти чіткість цілей оцінки:
- Агностичність до фреймворку. Контейнер має працювати зі звичайним TypeScript без жодних залежностей від React, Vue чи Angular, а також ефективно функціонувати в браузері, у Node.js для SSR та в React Native.
- Модульність. Кожна сфера має можливість надавати заздалегідь налаштований модуль із закритими зв’язками всередині, щоб додаток завантажував лише його. Саме це запобігає створенню 500-рядкового файлу
main.ts, у якому кожна служба реєструється вручну. - Керування життєвим циклом. Механізми роботи з синглтонами, тимчасовими екземплярами та обмеженими контекстами мають бути на першому місці.
Кандидати
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та властивості$. - Кожна бібліотека домену експортує лише свій модуль контейнера, а не свої конкретні класи.
- Єдиний корень композиції формує граф застосунку.
- Простий міст для кожної платформи UI забезпечує доступ до контейнера для компонентів.
Токени з типовою безпекою за допомогою 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>. Введення $ як Inversify's ServiceIdentifier<T> пов’язує токен із його інтерфейсом на етапі компіляції, тож місця резолюції можуть визначити правильний тип без необхідності використання явних генериків.Наступний уривок демонструє обидві сторони: клас, який отримує фасад через вставку конструктора, та прямий виклик 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, а не чимось, що потрібно пам’ятати рецензентам.
Korень композиції
Корень композиції — це єдине місце, де створюється граф залежностей, зазвичай 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 завантажує обидва модулі в один і той самий контейнер. Практична наслідок: якщо додаток завантажує функції оплати без автентифікації, процес розрішення вимог зазнає невдачі під час виконання, тому варто проводити тест на кожній меті додатку, щоб переконатися, що кожен публічний токен розрішується один раз.
З’єднання контейнера з фреймворками UI
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 під час збирання проекту. Для корпоративного монорепозиторію з десятьма чи більше доменами ці витрати швидко окупуються. Для додатку з двома-трьома екранами така ізоляція є зайвим навантаженням; звичайна ін’єкція через конструктор чи навіть синглтони на рівні модуля підійдуть краще.
Основні висновки
- Тримайте механізми DI фреймворку (Context, provide/inject, Angular providers) на рівні кордону UI; ядро домену має залежати лише від контрактних токенів.
ContainerModule, допомагає зберегти невеликий вхідний точку.Symbol.for, оголошені як ServiceIdentifier<T>, щоб одночасно уникнути дублювання пакетів та зберегти функціонал інференції типів.Пов’язана література
- Усунення проблеми перевантаження параметрами у React за допомогою композиції та слотів — Дізнайтеся, чому параметри React із великою кількістю налаштувань створюють проблеми під час технічного обслуговування, та як інверсія керування, композиція та слоти дозволяють створювати справді повторно використовувані компоненти.
- Інтеграція інструментів MCP у інтерфейс чату у React із вбудованою людською затвердженням — Дізнайтеся, як протокол Model Context Protocol підходить для React-додатків: чому бекенд має хостувати MCP, як працює сервер інструментів та як стрімувати та затверджувати виклики інструментів у інтерфейсі.