Внедрение зависимостей в монорепозиторий на TypeScript: InversifyJS для React и Angular
Внедрение зависимостей в монорепозиторий на TypeScript: выбор контейнера, определение типобезопасных токенов, формирование корня композиции и связь с React, Vue и Angular.
Источники: Frontend Dependency Injection: From Context API to Composition Root
Большие кодовые базы фронтенда редко терпят неудачу из-за одного неисправного компонента; они терпят неудачу потому, что бизнес-правила постепенно переплетаются с хуками React, клиентами HTTP и API хранилищ, до тех пор пока невозможно протестировать или повторно использовать что-либо отдельно. Внедрение зависимостей — это механизм низкого уровня, который позволяет сохранять эти правила раздельными, но только при условии осознанного проектирования структуры связей. В этом руководстве рассматривается выбор контейнера для монорепозитория TypeScript с несколькими доменами, определение типобезопасных токенов, изоляция каждого домена за счёт отдельного модуля, сборка структуры в Composition Root, а также её предоставление React, Vue и Angular с помощью мостов, состоящих по десять строк каждый.
Почему необходимо определить принципы изоляции до моделирования доменов
Классические рекомендации по доменно-ориентированному проектированию гласят, что детали реализации следует отложить: сначала создавайте модели сущностей и сценарии использования, а инструменты выбирайте позже. На практике команда, начинающая крупные архитектурные изменения, выигрывает, если заранее решит один технический вопрос — а именно, как будет обеспечиваться изоляция модулей. Без согласованного механизма первые несколько доменов формируются спонтанно, и установленные правила так и не восстанавливаются.
Основополагающим принципом здесь является принцип обратной зависимости: высокоуровневая логика должна зависеть от абстракций, а конкретные детали — от тех же самых абстракций. Внедрение зависимостей — это практический метод для реализации этого принципа. Классы запрашивают абстрактные токены вместо конкретных реализаций, и что-то внешнее определяет, на что они ссылаются. В подходах DDD и Clean Architecture это не просто дополнительная оптимизация; доменная часть не должна знать, с какой фреймворком интерфейса, клиентом HTTP или слоем хранения данных она работает.
Большинство команд фронтенда сначала обращаются к React Context (или к механизму provide/inject в Vue). Этого кажется достаточно: объявляется значение рядом с корнем компонента, и его можно читать в любом подчинённом компоненте. Однако ограничения проявляются быстро. Нет управления жизненным циклом, нет понятий фабрик или областей видимости, и каждый потребитель значения теперь связан с средой выполнения React, поэтому логика не может работать или тестироваться без неё. Путь от этой отправной точки к настоящему корню композиции рассматривается далее в этом руководстве. Писать собственный контейнер редко имеет смысл, поэтому первым шагом является честная оценка существующих вариантов IoC.
Критерии выбора контейнера для внедрения зависимостей в фронтенде
Фреймворки бэкенда, такие как NestJS, Spring и .NET, поставляются с реализацией принципа инжекции зависимостей как готового стандартного решения. Код фронтенда сталкивается с дополнительными ограничениями: затраты на выполнение, лимиты по размеру пакетов и необходимость совместимости с различными фреймворками интерфейса. Для объективной оценки используются пять критериев:
- Агностичность к фреймворку. Контейнер должен работать с обычным 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 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 больше не требует отдельного полифилла 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>. Указание$как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не может импортировать проект с тем же тегом из другой области. Подробности о том, как объявляются теги и ограничения, смотрите в документации 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));
}
}
Несколько приложений из одного ядра домена
Поскольку инфраструктура выбирается в корне композиции, разные приложения могут повторно использовать одни и те же пакеты домена с разными адаптерами и разным набором модулей. Приложение на 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.$; он лишь использует его. Связь устанавливается тогда, когда корень композиции загружает оба модуля в один и тот же контейнер. Практический результат: если приложение загружает модуль оплаты без аутентификации, процесс разрешения ошибок происходит во время выполнения, поэтому целесообразно проводить тест на каждой целевой платформе, чтобы убедиться, что каждый публичный токен разрешается один раз.
Связывание контейнера с 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. Для корпоративного монорепозитория с десятью и более доменами эти затраты быстро окупаются. Для приложения с двумя-тремя экранами такая изоляция представляет собой ненужную нагрузку; в таких случаях подойдет простая инъекция через конструктор или даже синглтоны на уровне модуля.
Правила внедрения зависимостей на фронтенде
- Держите механизм DI фреймворка (Context, provide/inject, Angular providers) на периферии интерфейса; ядро домена должно зависеть только от токенов контрактов.
- Сначала выберите контейнер для модуля: домен, экспортирующий один запечатанный объект
ContainerModule, позволяет сохранить небольшой входной пункт. - Используйте токены
Symbol.forс типомServiceIdentifier<T>, чтобы дублирующиеся пакеты и вывод типов продолжали работать корректно. - Устанавливайте границы с помощью экспортов и правил проверки кода, а не только соглашений.
- Пусть ровно один файл — корень композиции — взаимодействует с конкретными реализациями; тогда многоплатформенная сборка становится просто заменой связей.
- Помните, что инъекция — это лишь инструмент. Цель — инверсия: если вы можете заменить React на Vue, SQLite на IndexedDB или Axios на Fetch без изменения уровня домена, значит стратегия сработала.
Связанные материалы
- Устранение проблемы перегрузки свойств в React с помощью композиции и слотов: Узнайте, почему свойства React с большим количеством настроек приводят к увеличению нагрузки на обслуживание кода, и как инверсия контроля, композиция и слоты позволяют создавать по-настоящему повторно используемые компоненты.
- Интеграция инструментов MCP в интерфейс чата на React с встроенной проверкой человеком: Узнайте, как протокол Model Context Protocol встраивается в приложение на React: почему бэкенд должен хостить MCP, как работает сервер инструментов и как транслировать и проверять вызовы инструментов в интерфейсе.