Главная / Статьи / Фронтенд-DI, независимый от фреймворка, с InversifyJS и корнем композиции

Фронтенд-DI, независимый от фреймворка, с InversifyJS и корнем композиции

Как выбрать контейнер DI для TypeScript, упаковать каждую область как ContainerModule, связать всё в одном Composition Root и объединить его с React, Vue и Angular.

3230 слов

Большие кодовые базы фронтенда редко терпят неудачу из-за одного поврежденного компонента; они терпят неудачу потому, что бизнес-правила постепенно переплетаются с хуками 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 как готовым стандартным решением. Код фронтенда подвержен дополнительным ограничениям: затраты на выполнение, лимиты размера пакетов и необходимость совместной работы с различными фреймворками интерфейса. Пять критериев определяют цели оценки:

  1. Агностичность к фреймворку. Контейнер должен работать с обычным TypeScript без зависимостей от React, Vue или Angular во время выполнения и функционировать в браузере, в Node.js для SSR и в React Native.
  2. Модульность. Каждая область должна иметь возможность предоставлять заранее настроенный модуль с закрытыми связями внутри, чтобы приложение загружало только его. Это помогает избежать создания 500-строчного файла main.ts, в котором каждый сервис регистрируется вручную.
  3. Управление жизненным циклом. Синглтоны, временные экземпляры и решение проблем в рамках определенного контекста должны поддерживаться на первом уровне.
  • Возможность тестирования. Замена зависимости на имитацию или фиктивную версию должна занять одну строку кода, без необходимости пересоздания контейнера.
  • Влияние на размер пакета. Размер бинарного файла в продакшене должен быть небольшим — как в несжатом, так и в минифицированном с газпаковкой виде.
  • Кандидаты

    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; это позволяет отделить собственные накладные расходы контейнера от кода приложения. Были сделаны четыре вывода:

    1. Механизм инъекции зависимостей фреймворка относится к интерфейсу пользователя, а не к бизнес-логике. Использование функции useContext() или атрибута @Injectable() в библиотеках, отвечающих за бизнес-логику, приводит к связыванию бизнес-правил с одним фреймворком, что нарушает первый критерий.
    2. Ручная инъекция подходит только для небольших проектов. В крупном корпоративном монорепозитории структура Composition Root превращается в тысячи строк ручно написанных связей между компонентами.
  • InversifyJS победил благодаря модульности. Это единственный кандидат, в котором модуль (ContainerModule) является первоклассным объектом, с помощью которого библиотека может создавать структуры, заключать их в оболочки и экспортировать как единое целое.
  • Конфигурация сборки имеет значение. Для InversifyJS и TSyringe в файле 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>,
    };
    

    В этом фрагменте содержатся три решения:

    1. Одно имя для типа и значения. 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';
    

    Инкапсуляция обеспечивается дважды:

    1. Публичный API. Файл index.ts библиотеки повторно экспортирует только authContainerModule. Классы вроде LoginUseCase или CoreAuthFacade просто недоступны из других пакетов.
    2. Правила линтинга. Правило 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>, чтобы дублирующиеся пакеты и вывод типов продолжали работать корректно.
  • Устанавливайте границы с помощью экспортов и правил проверки кода, а не только конвенций.
  • Пусть ровно один файл, Composition Root, взаимодействует с конкретными реализациями; тогда многоплатформенная сборка становится просто заменой связей.
  • Помните, что инъекция — это лишь инструмент. Цель — инверсия: если вы можете заменить React на Vue, SQLite на IndexedDB или Axios на Fetch без изменения уровня домена, значит стратегия сработала.
  • Связанные материалы

  • Выбор структуры папок в React: семь лейаутов и их точки сбоя — Сравните плоскую, основанную на типах, основанную на функциях, Atomic Design, DDD, Feature-Sliced Design и монорепо-лейауты для React, а также узнайте, на каком уровне работа каждого из них прекращается.
  • Перенос Angular HttpClient в backend Fetch с помощью withFetch() — Узнайте, почему HttpClient в Angular переходит от XMLHttpRequest к fetch, как withFetch() обеспечивает edge SSR и стриминг, и что это означает для ваших интерцепторов.
  • Как Angular Dependency Injection разрешает доступ к сервисам с помощью inject() — Понять, как Angular DI предоставляет сервисы через функцию inject(), элементы providers и иерархические инжекторы, а также как ограничивать диапазон использования экземпляров и избегать ошибок, связанных с отсутствием провайдеров.
  • SOLID как вопрос к изменениям: рефакторинг сервиса Spring Boot — Научиться применять каждый принцип SOLID в бэкенде Spring Boot, определяя, что может измениться, и как избежать чрезмерной сложности проектирования, к которой часто приводит соблюдение принципов SOLID.