Фронтенд-DI, независимый от фреймворка, с InversifyJS и корнем композиции
Как выбрать контейнер DI для TypeScript, упаковать каждую область как ContainerModule, связать всё в одном Composition Root и объединить его с React, Vue и Angular.
Большие кодовые базы фронтенда редко терпят неудачу из-за одного поврежденного компонента; они терпят неудачу потому, что бизнес-правила постепенно переплетаются с хуками React, клиентами HTTP и API хранилищ до тех пор, пока невозможно стать тестировать или повторно использовать что-либо отдельно. Внедрение зависимостей — это механизм низкого уровня, который позволяет сохранять эти правила раздельными, но только при условии осознанной настройки связей между компонентами. В этом руководстве рассматривается процесс выбора контейнера для монорепозитория на TypeScript с несколькими доменами, определение типобезопасных токенов, изоляция каждого домена за счет отдельного модуля, формирование структуры в Composition Root и ее предоставление React, Vue и Angular с помощью мостов, состоящих по десять строк каждый.
Почему необходимо определить принципы изоляции до моделирования доменов
Классические рекомендации по доменно-ориентированному проектированию гласят, что детали реализации следует отложить: сначала создавайте модели сущностей и случаи использования, а инструменты выбирайте позже. На практике команда, начинающая крупные архитектурные изменения, выигрывает, если заранее решит один технический вопрос — а именно, как будет обеспечиваться изоляция модулей. Без согласованного механизма первые несколько доменов формируются спонтанно, и установленные правила так и не восстанавливаются.
Основополагающим принципом здесь является принцип инверсии зависимостей: высокоуровневые компоненты должны зависеть от абстракций, а конкретные детали — от тех же самых абстракций. внедрение зависимостей — это практический метод реализации этого принципа. Классы запрашивают абстрактные объекты вместо конкретных реализаций, и что-то внешнее определяет, на что они будут переопределены. В подходах DDD и Clean Architecture это не просто дополнительная оптимизация; доменный слой не должен знать, с какой фреймворком интерфейса, клиентом HTTP или слоем хранения данных он работает.
Большинство команд фронтенда сначала обращаются к React Context (или к механизму provide/inject в Vue). Этого кажется достаточно: объявляется значение рядом с корнем компонента, и его можно читать в любом подчинённом элементе. Однако ограничения проявляются быстро. Нет управления жизненным циклом, нет понятий фабрик или областей видимости, и каждый потребитель значения теперь связан с движком React, поэтому логика не может выполняться или тестироваться без него. Путь от этой отправной точки к настоящему корню композиции рассматривается далее в этом руководстве. Писать собственный контейнер редко имеет смысл, поэтому первой задачей является честная оценка существующих вариантов 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(), а также он зависит от полифилла reflect-metadata, что увеличивает размер библиотеки.
Awilix
Awilix полностью исключает использование декораторов. Он использует API Proxy ES6 для соответствия зависимостям именам параметров конструктора или ключам объекта аргументов фабрики. Это делает его идеальным решением для команд, которые не хотят использовать декораторы, и он оказался самым маленьким автономным контейнером среди всех проанализированных. Он поддерживает режимы жизненного цикла 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 больше не требует отдельного полифилла 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, а не чем-то, что нужно запоминать рецензентам.
Корень композиции
Корень композиции — это единственное место, где создается граф зависимостей; обычно это файл 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.$; он лишь использует его. Связывание происходит тогда, когда корень композиции загружает оба модуля в один и тот же контейнер. Практический результат: если приложение загружает модуль оплаты без аутентификации, решение проблемы не удается во время выполнения, поэтому целесообразно проводить тест на каждой целевой платформе приложения, чтобы убедиться, что каждый публичный токен обрабатывается один раз.
Связь контейнера с фреймворками UI
Поскольку контейнер ничего не знает о любых библиотеках UI, для каждого фреймворка создается небольшой адаптер длиной примерно 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 в процессе сборки. Для корпоративного монорепозитория с десятью и более доменами эти затраты быстро окупаются. Для приложения с двумя-тремя экранами такая изоляция представляет собой ненужную нагрузку; обычная инъекция через конструктор или даже синглтоны на уровне модуля окажутся более эффективными.
Основные выводы
- Держите механизм DI фреймворка (Context, provide/inject, Angular providers) на периферии UI; ядро домена должно зависеть только от токенов контрактов.
ContainerModule, позволяет сохранить небольшой входной точкой.Symbol.for с типом ServiceIdentifier<T>, чтобы дублирующиеся пакеты и вывод типов продолжали работать корректно.Связанные материалы
- Устранение проблемы перегрузки свойств в React с помощью композиции и слотов — Узнайте, почему свойства React с большим количеством настроек приводят к увеличению объема работ по техническому обслуживанию, и как инверсия контроля, композиция и слоты позволяют создавать по-настоящему переиспользуемые компоненты.
- Интеграция инструментов MCP в интерфейс чата на React с встроенной проверкой человеком — Узнайте, как протокол Model Context Protocol встраивается в приложение на React: почему бэкенд должен хостить MCP, как работает сервер инструментов и как транслировать и проверять вызовы инструментов в интерфейсе.