javascript

DI во фронтенде: от Context API к Composition Root

  • суббота, 22 августа 2026 г. в 00:00:10
https://habr.com/ru/articles/1072780/

Цикл статей «Tactical DDD во фронтенде»:

  1. «Почему мы снова говорим про архитектуру?»

  2. DI во фронтенде: от Context API к Composition Root (Вы здесь)

  3. UI как плагин: паттерн Фасад и реактивные потоки данных (Скоро)

В прошлой статье мы определились с глобальными подходами к изоляции бизнес-логики в веб-приложениях. Но как реализовать все озвученные концепции на практике? С чего вообще начать и как подступиться к столь масштабной задаче?

Прежде чем нырять с головой в проектирование самих доменов — их сущностей, юзкейсов, междоменного общения и взаимодействия с UI, — я решил сначала закрыть фундаментальный вопрос: как технически на низком уровне будет обеспечиваться изоляция модулей приложения?

Так я сосредоточился на концепции Инверсии зависимостей (Dependency Inversion Principle, DIP). В этом есть определенная ирония: выбор инструмента сборки зависимостей до написания самого домена формально нарушает канонический подход, согласно которому детали реализации не должны волновать нас на старте. Но мы все живем в реальном мире, где строгое следование академическим методологиям не всегда гарантирует успех. Тем более хотелось привлечь к этой теме как можно больше аудитории, а для этого нужно было сразу заложить надежный технический фундамент и понятные правила игры.

Я давно знаком с паттерном Внедрения зависимостей (Dependency Injection, DI) и пихаю его и куда нужно, и туда, где без него можно было бы обойтись. Мне искренне нравится, когда класс зависит от абстрактных DI-токенов, а не от конкретных реализаций — ничего не могу с собой поделать. Тем более в парадигмах DDD и Clean Architecture это принципиальный момент: бизнес-правила ничего не знают о деталях реализации (UI-фреймворках, сетевых клиентах, библиотеках кэширования).

Практически любой фронтендер приходит к этой задаче с одной и той же отправной точки: React Context (или provide/inject во Vue). Сначала это выглядит хорошим решением — зависимость объявлена в одном месте, а получить ее можно в любом другом. Потом выясняется, что скоупов нет, фабрик нет, а доменная логика теперь не собирается без React. Путь от этой точки до полноценного Composition Root — и есть тема статьи.

Естественно, писать очередную реализацию с нуля мне не хотелось. Стояла задача выбрать готовый IoC-контейнер, максимально закрывающий потребности архитектуры.


Обзор DI-решений для фронтенда

В бэкенд-мире (NestJS, Spring, .NET) вопрос DI давно стандартизирован. Но во фронтенде к вопросу о самой реализации добавляются еще и ограничения в виде браузерного рантайма, размера итогового бандла и разнообразных UI-фреймворков.

Чтобы выбор не превращался исключительно в субъективное мнение — сформулируем четкие критерии:

  1. Фреймворк-агностичность: Способность работать в чистом TypeScript без зависимостей от React, Vue или Angular, а также исполняться в браузере, Node.js (SSR) и React Native.

  2. Модульность: Поддержка компоновки зависимостей по доменным модулям. В идеале домен с бизнес-логикой экспортирует наружу контейнер с инкапсулированными зависимостями, а дальше приложение использует эти зависимости. Так мы избавляемся от файла на 500 строк, в который импортируются вообще все зависимости в приложении.

  3. Управление жизненным циклом: Поддержка как долгоживущих глобальных синглтонов, так и создания нового экземпляра сервиса под каждый запрос зависимости.

  4. Простота тестирования: Возможность в одну строчку переопределить зависимость на mock/fake в unit-тестах без сложного пересоздания контейнера.

  5. Влияние на размер бандла: Минимальный оверхед по размеру в продакшен-сборке.

Держа в голове эти требования, давайте разберем основные решения, представленные сегодня на рынке.

Специализированные DI-библиотеки

InversifyJS

InversifyJS стал чем-то вроде стандарта для крупных TypeScript-проектов. Это самый функциональный IoC-контейнер в экосистеме, написанный на чистом TypeScript.

Библиотека полностью не зависит от фреймворков и использует декораторы для описания зависимостей. Для удобной организации модулей InversifyJS предоставляет ContainerModule, что позволяет группировать биндинги логически. Из скоупов доступны inSingletonScope, inTransientScope и inRequestScope, есть дочерние контейнеры, ленивая загрузка/выгрузка модулей и хуки активации.

Ценник за эту функциональность — вес. Это самое тяжелое решение из рассмотренных, и именно оно, а не отсутствие фич, будет главным аргументом против (цифры — в таблице ниже). Называть InversifyJS «легковесным», как это часто делают, уже некорректно.

TSyringe

TSyringe — это альтернатива от Microsoft, не такая навороченная, как предыдущее решение. Главная особенность библиотеки в том, что она требует меньше ручной конфигурации и поддерживает автоматическую регистрацию зависимостей. Есть очень большая коллекция декораторов на все случаи жизни, а скоупы покрывают все типовые сценарии (Singleton, Transient, ResolutionScoped, ContainerScoped).

Меньшее количество кода для инициализации делает ее отличным выбором для проектов среднего размера, где нужен DI без лишних усложнений. Главный минус для нашей задачи — нет отдельной сущности «модуль»: композиция делается обычными функциями-регистраторами поверх глобального контейнера или через createChildContainer(). Плюс библиотека обязательно требует полифил reflect-metadata, который в бандле весит примерно столько же, сколько сама TSyringe.

Awilix

Awilix — DI-контейнер для JavaScript и TypeScript, который не требует использования декораторов. Библиотека работает с использованием Proxy API и внедряет зависимости напрямую в параметры конструктора или фабричные функции по именам.

Это идеальный вариант для команд, которые по каким-либо причинам избегают использования декораторов, и самое легкое решение по весу. Скоупы есть (SINGLETON, SCOPED, TRANSIENT), модульность — через функции-регистраторы.

Отдельно стоит держать в голове режим внедрения: в CLASSIC Awilix резолвит зависимости по именам параметров конструктора, а минификатор эти имена переименовывает. Для фронтенда безопасен только режим PROXY (он же дефолтный), где имена живут как обращения к свойствам объекта.

Встроенные решения UI-фреймворков

Angular DI

Angular обладает одной из самых мощных встроенных систем внедрения зависимостей. Поддерживает внедрение по токенам, а не только по строкам. Также из коробки поддерживаются модульность и ленивая загрузка.

Если бы этот контейнер был отвязан от самого фреймворка, то я бы не задумываясь выбрал его для своего проекта.

React Context API

Хотя React Context API не является полноценным DI-контейнером, он часто используется для решения проблемы с прокидыванием пропсов сквозь дерево компонентов.

Провайдеры создаются на верхнем уровне, а внедряются через хук useContext(). Одна из самых неприятных особенностей такого подхода: любое изменение значения в контексте вызывает ререндер всех подписанных компонентов. Ну и всего остального тоже нет. Зато ничего не стоит, так как поставляется вместе с React.

Vue Provide / Inject

Встроенный механизм Vue для передачи данных сквозь дерево компонентов, подобно React Context. Минусы примерно такие же, как и у React Context — сложного DI-контейнера на основе него не построишь. Но хотя бы не нужно для каждого контекста заводить компонент: зависимости провайдятся через плагины.

К слову, в последних версиях Angular функция inject() работает по схожему принципу (в контексте создания компонента).

Самописные решения

Ручное внедрение через конструктор

Это кристаллизация логики работы любого DI-контейнера, без всяких обвязок и удобств. Самый простой и очевидный подход: передача зависимостей через аргументы конструктора при инстанцировании объектов.

Это обеспечивает полную прозрачность и идеальную поддержку TypeScript без магии IoC, а весит ровно ноль байт. Главный недостаток — дерево инициализации становится слишком громоздким на верхнем уровне приложения, когда число зависимостей переваливает за 10–15. Скоупы и подмену зависимостей в тестах тоже придется писать руками.


Сводная таблица

Наконец, сведем все к критериям. Размер измерен так: точка входа с импортом публичного API библиотеки, esbuild --bundle --minify --platform=browser, затем gzip -9. Это оверхед самого контейнера, без вашего кода.

Решение

Агностичность

Модульность

Жизненный цикл

Тестирование

Вес (min+gzip)

InversifyJS

Чистый TS, работает везде

ContainerModule + дочерние контейнеры

singleton / transient / request

rebind() или дочерний контейнер

21,4 КБ

TSyringe

Чистый TS, работает везде

Нет модулей, только функции-регистраторы

singleton / transient / resolution / container

registerInstance(), createChildContainer()

4,8 КБ + 4,6 КБ reflect-metadata

Awilix

Чистый TS, работает везде

Нет модулей, только функции-регистраторы

singleton / scoped / transient

Повторный register(), createScope()

3,5 КБ

Angular DI

Только Angular

Да

Да

Да, TestBed

Часть фреймворка

React Context

Только React

Нет

Нет

Обертки-провайдеры в тестах

Часть фреймворка

Ручное внедрение

Да

Руками

Руками

Руками

0

Выводы из таблицы:

  • Встроенный DI фреймворков (Angular, React, Vue) хорош для UI-компонентов, но не подходит для бизнес-ядра. Единожды использовав useContext() или @Injectable() внутри домена, можно ставить крест на идее независимого ядра приложения. Критерий №1 отсекает их сразу.

  • Ручное внедрение зависимостей — простое решение с нулевым весом, но на масштабах большого монорепозитория превращает Composition Root в неуправляемый файл на тысячи строк.

  • InversifyJS стал моим выбором: единственное решение, где модуль зависимостей — это самостоятельная сущность, которую домен экспортирует наружу как единицу композиции. Для монорепы с десятком доменов это решающий фактор.

  • Размер выбранного решения: 21,4 КБ — это много. Если у вас 2–3 домена, а не 15, разница в модульности не окупает разницу в весе, и я бы взял Awilix. Мой выбор — следствие разработки подхода к управлению сложностью в больших корпоративных приложениях.

  • Важный нюанс по декораторам: InversifyJS требует experimentalDecorators: true, emitDecoratorMetadata: true и target: ES2022 в tsconfig.json. Хорошая новость: в v8 отдельный полифил reflect-metadata больше не нужен — его нет ни в зависимостях пакета, ни в официальных требованиях, и примеры ниже работают без него. TSyringe его по-прежнему требует. При использовании сборщиков на базе esbuild или SWC понадобятся плагины для поддержки legacy-декораторов.

Архитектура независимого домена

Хорошо, мы определились с DI-контейнером, но только этого недостаточно для изоляции доменной логики. Можно и с контейнером так выстроить зависимости между модулями, что никакой разницы по сравнению с наивным подходом или делением на фичи (папка /features) не будет.

Именно поэтому в проекте применяется ряд соглашений для изоляции бизнес-логики. Правила следующие: использование абстрактных DI-токенов, домен экспортирует только DI-модуль (никаких реализаций), Composition Root и тонкий мост для подключения этого контейнера к любому UI-фреймворку. Давайте детально разберем, как устроена эта архитектура, шаг за шагом.

DI-токены: паттерн Symbol.for + $

Для того чтобы DI-контейнер мог связывать интерфейсы с их реализациями, ему нужны уникальные идентификаторы — DI-токены. В TypeScript интерфейсов на этапе выполнения не существует, поэтому мы не можем использовать их как значения, а очень хочется.

В проекте используется паттерн, который решает эту проблему: каждый интерфейс, который биндится к DI-контейнеру, сопровождается одноименным объектом с полем $, содержащим 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 — фасад PIN-кода  
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() кладет его в глобальный реестр рантайма и при повторном вызове с тем же ключом возвращает тот же самый символ. В монорепе это спасает от бага: если пакет контрактов по какой-то причине попал в бандл дважды (разные версии в зависимостях, дублирование между хостом и микрофронтом в Module Federation), то с Symbol() вы получите два разных токена и ошибку, с которой очень сложно разобраться.

as ServiceIdentifier<AuthFacade>. Без этого приведения $ имеет тип symbol, и связь между токеном и интерфейсом существует только в голове разработчика: container.get<PinFacade>(AuthFacade.$) спокойно скомпилируется. С ServiceIdentifier<T> тип выводится автоматически, и дженерики в местах использования становятся не нужны:

import { inject, injectable } from 'inversify';  
  
import { AuthFacade } from '@my-app/auth-contracts';  
  
@injectable()  
export class LoginPageComponent {  
    constructor(@inject(AuthFacade.$) private readonly auth: AuthFacade) {}
}  
  
// а на стороне резолва тип выводится сам, без явного дженерика  
const facade = container.get(AuthFacade.$); // AuthFacade  

Обратите внимание: компонент зависит только от интерфейса и ничего не знает о реализации, которая лежит в @my-app/auth-core. А реализация внедряется исключительно через DI-контейнер в Composition Root приложения.

Один домен — один модуль

Ядро каждого домена экспортирует наружу только модуль конфигурации DI. Внутренние компоненты — юзкейсы, сущности, порты и их реализации — не покидают границ библиотеки.

Для этого каждый домен создает свой локальный контейнер, который описывает внутренние биндинги домена:

// libs/auth/core/src/lib/auth-container.module.ts  
import { ContainerModule, type ContainerModuleLoadOptions } from 'inversify';  
  
import { AuthFacade } from '@my-app/auth-contracts';  
  
export const authContainerModule = new ContainerModule((options: ContainerModuleLoadOptions) => {  
    // ...

    // Юзкейсы
    options.bind(LoginUseCase).toSelf().inSingletonScope();
    options.bind(GetAccessTokenUseCase).toSelf().inSingletonScope();
    options.bind(LogoutUseCase).toSelf().inSingletonScope();
    // Реализация фасада биндится по токену
    options.bind(AuthFacade.$).to(CoreAuthFacade).inSingletonScope();

    // ...
});  
  
// libs/auth/core/src/index.ts — наружу реэкспортируется ТОЛЬКО модуль  
export * from './lib/auth-container.module';  

Граница домена держится на двух вещах, которые работают на этапе сборки:

  1. Экспорт домена. libs/auth/core/src/index.ts реэкспортирует только authContainerModule. Класс LoginUseCase физически невозможно импортировать из @my-app/auth-core — его нет в публичном API.

  2. Линтер границ. Правило @nx/enforce-module-boundaries поверх тегов проектов запрещает импорты в обход индексного файла и импорты между доменами. Тег type:core одного домена не может импортировать type:core другого.

Composition Root

Composition Root (Корень композиции) — это место, где все приложение собирается воедино: единая точка, где создается главный DI-контейнер и в него загружаются модули всех доменов. Это может быть корневой файл приложения (например, main.ts) или отдельный файл, который импортируется в корневой. Ключевое свойство: только здесь разрешено импортировать конкретные реализации, библиотеки и инфраструктуру. Весь остальной код работает с абстрактными токенами.

// 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();

    // Биндинг глобальных зависимостей
    container.bind(HttpClientPort.$).to(AxiosHttpClient).inSingletonScope();
    container.bind(LoggerPort.$).toDynamicValue(() => new BrowserLogger({
        transports: [new ConsoleLogTransport(), new FileLogTransport()]
    })).inSingletonScope();

    // ...

    // Загрузка доменных модулей
    container.load(authContainerModule);
    container.load(paymentsContainerModule);
  
    // ...  

    return container;
};  

Вот как реализован адаптер логера BrowserLogger:

// 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(options: BrowserLoggerOptions) {
        // ...
    }
}  

Одно ядро, несколько сборок

Composition Root — это то место, где окупается вся конструкция. Меняя модули и набор инфраструктурных биндингов, мы получаем разные сборки приложения из одного и того же доменного кода:

// apps/mobile/src/composition-root.ts — React Native  
container.bind(StoragePort.$).to(AsyncStorageAdapter).inSingletonScope();  
container.bind(LoggerPort.$).to(RnLogger).inSingletonScope();  
container.load(authContainerModule);  
container.load(paymentsContainerModule);  
  
// apps/admin/src/composition-root.ts — веб-админка  
container.bind(StoragePort.$).to(IndexedDbAdapter).inSingletonScope();  
container.bind(LoggerPort.$).to(BrowserLogger).inSingletonScope();  
container.load(authContainerModule);  
container.load(auditContainerModule); // платежей здесь нет вообще  

Домен auth в обоих случаях — один и тот же неизмененный пакет. Он не знает ни про AsyncStorage, ни про IndexedDB.

Междоменное общение

Возникает резонный вопрос: а что делать, если домену payments нужен access-token из auth? Импортировать @my-app/auth-core нельзя — это запрещено правилом на уровне линтера. Решается это тем, что payments зависит от токена из контрактов соседнего домена, а не от его реализации.

// libs/payments/core/src/lib/use-cases/create-payment.use-case.ts  
import { inject, injectable } from 'inversify';  
  
// contracts, НЕ core — этот импорт разрешен правилами границ  
import { AuthFacade } from '@my-app/auth-contracts';  
  
@injectable()  
export class CreatePaymentUseCase {  
    constructor(
        @inject(AuthFacade.$) private readonly auth: AuthFacade,
        @inject(PaymentsGatewayPort.$) private readonly gateway: PaymentsGatewayPort
    ) {}
}  

Модуль paymentsContainerModule при этом не биндит AuthFacade.$ — он только его потребляет. Реализацию провайдит Composition Root, который загружает оба модуля. Такой обмен через контракты хорошо работает для синхронных запросов на месте. Сценарии, когда в одном домене нужно реагировать на событие из другого, решаются через шину событий — но это отдельная тема для обсуждения.

Мост к фреймворкам

Поскольку наш DI-контейнер ничего не знает о UI, нам нужен тонкий слой абстракции, чтобы компоненты фреймворка могли получать зависимости.

React

Так как контейнер в приложении у нас будет один глобальный, то значение контекста создается один раз и никогда не меняется, поэтому проблема ререндеров, за которую Context ругают, здесь не возникает в принципе.

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 вызван вне DIProvider');
        }

        return container.get(token);
    }, [container, token]);
};  

Для InversifyJS уже есть готовая библиотека. Для остальных IoC-контейнеров, возможно, тоже есть, не смотрел, но суть в том, что мост пишется в 10–15 строк.

Vue

В экосистеме Vue используется паттерн плагинов и механизм provide/inject. Ключ для provide стоит объявить через типизированный InjectionKey для сохранения типобезопасности:

export const DI_CONTAINER: InjectionKey<Container> = Symbol('DIContainer');  
  
// Vue плагин  
export const diPlugin = {
    install(app: App, { container }: { container: Container }) {
        app.provide(DI_CONTAINER, container);
    },
};  
  
// Composable для инъекции  
export const useInjection = <T>(token: ServiceIdentifier<T>): T => {  
    const container = inject(DI_CONTAINER);  

    if (!container) {
        throw new Error('diPlugin не установлен');
    }
  
    return container.get(token);
};  

Angular

Angular имеет свой мощный DI, поэтому мы просто создаем фабричные провайдеры (Factory Providers), которые достают нужные инстансы из нашего контейнера.

Тут есть один важный нюанс: если импортировать container напрямую из composition root в файл провайдеров, то в SSR один и тот же контейнер окажется общим для всех запросов всех пользователей. Правильнее пробросить сам контейнер через Angular-токен — тогда на сервере можно создать его на каждый запрос:

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: (container: Container) => container.get(AuthFacade.$),
        deps: [DI_CONTAINER],
    },
];  
  
// main.ts — контейнер создается здесь, на каждый bootstrap  
bootstrapApplication(AppComponent, {
    providers: [...provideDomainContainer(initAppContainer())],
});  

Выводы

Такой подход к Dependency Injection — это не просто оверхед ради оверхеда, а осознанное архитектурное решение. Сочетание независимого контейнера (InversifyJS), доменных модулей ContainerModule, границ на уровне экспортов и линтера, а также тонкого моста к UI позволяет нам:

  • Полностью отвязать доменную логику от UI-фреймворка.

  • Контролировать границы доменов — на этапе сборки, а не по договоренности.

  • Подменять любую зависимость в тестах, не поднимая приложение.

  • Собирать разные версии приложения (мобильную, десктопную, админку) из одного доменного кода.

  • Шарить инфраструктуру (например, кэш запросов) между разными частями приложения.

Стоит это 21 КБ в бандле и требование emitDecoratorMetadata в сборке. Для монорепы с десятком доменов — окупается; для приложения с двумя-тремя доменами я бы вообще со всем этим не заморачивался.

И главное. Dependency Injection — это не самоцель, а одно из средств решения проблемы изоляции модулей приложения. Цель — инверсия зависимостей: бизнес-логика не должна знать, в каком фреймворке она работает, какая база данных под капотом и как устроен кэш. Сам контейнер при этом ничего не изолирует — он лишь дает удобную композицию; изоляцию обеспечивают правила, которые вы согласились соблюдать, и инструменты, которые эти правила проверяют.

Если ваша архитектура позволяет заменить React на Vue, SQLite на IndexedDB или Axios на Fetch, не трогая ни одного файла в доменном слое — DI выполнил свою задачу, какой бы инструмент вы ни выбрали.

Что дальше?

Мы настроили DI и ввели правила его использования. Теперь пора поговорить о публичном API домена, выставляемом наружу для UI-компонентов.

В следующей статье цикла мы разберем:

  • Как фасад формирует границу между доменом и UI-интерфейсом.

  • Как работать с реактивными стримами.

  • Как домены общаются между собой через прямые вызовы.

  • Как легко подключить один и тот же домен к React, Vue, Angular.


Цикл статей «Tactical DDD во фронтенде»:

  1. «Почему мы снова говорим про архитектуру?»

  2. DI во фронтенде: от Context API к Composition Root (Вы здесь)

  3. UI как плагин: паттерн Фасад и реактивные потоки данных (Скоро)