Strona główna / Artykuły / DI frontend niezależny od frameworka z InversifyJS i korzeniem kompozycji

DI frontend niezależny od frameworka z InversifyJS i korzeniem kompozycji

Jak wybrać kontener DI dla TypeScript, zapakować każdą domenę jako ContainerModule, połączyć wszystko w jednym Composition Root i stworzyć most łączący go z React, Vue oraz Angular.

3230 słów

Złożone bazy kodu frontendu rzadko zawodzą z powodu jednego uszkodzonego komponentu; zawodzą, ponieważ reguły biznesowe stopniowo splatają się z hookami React, klientami HTTP oraz API do przechowywania danych, aż nic nie może być testowane ani ponownie wykorzystywane samodzielnie. Injekcja zależności to mechanizm na niskim poziomie, który utrzymuje te reguły oddzielone, ale tylko wtedy, gdy ich połączenia są celowo zaprojektowane. Ten przewodnik opisuje, jak wybrać kontener dla monorepo typu TypeScript obejmującego kilka domen, jak zdefiniować bezpieczne pod względem typów tokeny, jak zamknąć każdą domenę za pomocą pojedynczego modułu, jak zbudować strukturę w Composition Root oraz jak udostępnić ją frameworkom React, Vue i Angular za pomocą mostów składających się po dziesięć linii kodu każdy.

Dlaczego izolacja musi zostać ustalona przed sformułowaniem modelu domeny

Klasyczne zasady projektowania napędzanego domenami mówią, że szczegóły implementacji powinny zostać odłożone na później: najpierw modele entytetów i przypadki użycia, a narzędzia wybiera się później. W praktyce zespół rozpoczynający dużą zmianę architektoniczną odnosi korzyści z rozstrzygnięcia wcześniej jednego pytania technicznego, a mianowicie tego, w jaki sposób faktycznie będzie zapewniona izolacja modułów. Bez uzgodnionego mechanizmu pierwsze domeny są łączone w sposób ad hoc, a konwencje nigdy nie wracają do normy.

Zasada leżąca u podstaw to zasada odwrócenia zależności: polityka wysokiego poziomu powinna polegać na abstrakcjach, a konkretne szczegóły powinny polegać na tych samych abstrakcjach. iniekcja zależności to praktyczna technika, która ją realizuje. Klasy proszą o abstrakcyjne tokeny zamiast konkretnych implementacji, a coś poza nimi decyduje o tym, na co te tokeny się odnoszą. W DDD i Clean Architecture nie jest to opcjonalne ulepszenie – domena nie powinna wiedzieć, z jakim frameworkiem interfejsu użytkownika, klientem HTTP czy warstwą persistencji pracuje.

Większość zespołów front-endowych najpierw sięga po React Context (lub provide/inject w Vue). Wydaje się to wystarczające: deklaruje się wartość w pobliżu korzenia, a następnie odczytuje ją wszędzie niżej. Ograniczenia pojawiają się szybko – nie ma zarządzania cyklem życia, brakuje koncepcji fabryk czy zakresów, a każdy użytkownik tej wartości jest teraz powiązany z silnikiem React, więc logika nie może działać ani być testowana bez niego. Droga od tego punktu wyjścia do prawdziwego korzenia kompozycji jest tematem reszty tego przewodnika. Tworzenie specjalistycznego kontenera rzadko się opłaca, więc pierwszym krokiem jest uczciwa ocena dostępnych opcji IoC.

Kryteria dla kontenera DI w front-endzie

Frameworki backendowe takie jak NestJS, Spring i .NET dostarczają mechanizm DI jako rozwiązane, standardowe rozwiązanie. Kod frontendowy ma dodatkowe ograniczenia: koszt wykonywania, limity rozmiaru plików oraz konieczność pracy z różnymi frameworkami interfejsu użytkownika. Pięć kryteriów określa cel oceny:

  1. Agnostyczność wobec frameworków. Kontener musi działać z zwykłym TypeScriptem, bez żadnych zależności od React, Vue czy Angular, i musi funkcjonować w przeglądarce, w Node.js do SSR oraz w React Native.
  2. Modularność. Domena powinna móc dostarczyć uprzednio skonfigurowany moduł z zamkniętymi w nim powiązaniami, tak aby aplikacja pobierała go wyłącznie. To zapobiega powstawaniu 500-linijnego pliku main.ts, w którym każda usługa jest ręcznie rejestrowana.
  3. Zarządzanie cyklem życia. Singletoni, instancje tymczasowe oraz rozwiązywanie problemów w określonych kontekstach muszą być obsługiwane na najwyższym poziomie.
  • Testowalność. Zastąpienie zależności mockiem lub fałszywym obiektem powinno zajmować jedną linię kodu, bez konieczności ponownego budowania kontenera.
  • Wpływ na rozmiar pliku. Nakład pracy w środowisku produkcyjnym powinien być niewielki, zarówno w wersji surowej, jak i po minifikacji oraz kompresji gzip.
  • Kandydaci

    InversifyJS

    InversifyJS od dawna jest standardowym wyborem dla dużych projektów typu TypeScript i stanowi najpełniejszy kontener IoC w tym ekosystemie. Jest niezależny od frameworków i deklaruje zależności za pomocą dekoratorów. Jego ContainerModule grupuje powiązania w logiczne jednostki, a także obsługuje tryby inSingletonScope, inTransientScope i inRequestScope, kontenery potomne, ładowanie i usuwanie modułów w czasie wykonywania oraz haki aktywacyjne. Cena idzie w parze z wagą: był to najcięższy w użyciu wybór, a określanie aktualnych wersji jako lekkich byłoby mylące.

    TSyringe

    TSyringe, utrzymywany przez Microsoft, oferuje szeroki zakres funkcji w zamian za prostotę użycia. Automatycznie rozwiązuje zależności konstruktorów na podstawie metadanych TypeScript, dostarcza bogaty zestaw dekoratorów oraz obsługuje typowe tryby życia (Singleton, Transient, ResolutionScoped, ContainerScoped). Dla aplikacji średniej wielkości stanowi atrakcyjne rozwiązanie o niskich wymaganiach konfiguracyjnych. W architekturze wielodomenowej występują dwa minusy: brakuje koncepcji modułów pierwszej klasy, więc kompozycja musi być budowana ręcznie za pomocą funkcji rejestratora lub createChildContainer(), a ponadto narzędzie to polega na polyfillu reflect-metadata, co powoduje znaczną dodatkową wagę.

    Awilix

    Awilix całkowicie unika dekoratorów. Wykorzystuje API ES6 Proxy do dopasowywania zależności do nazw parametrów konstruktora lub do kluczy obiektu argumentów fabryki. Dzięki temu doskonale pasuje do zespołów, które nie chcą używać dekoratorów, a był to najmniejszy samodzielny kontener przeanalizowany. Obsługuje tryby życia SINGLETON, SCOPED i TRANSIENT; moduły są organizowane za pomocą dostosowanych funkcji rejestracji.

    Jedna specyficzna dla frontendu pułapka wymaga uwagi. W trybie iniekcji CLASSIC Awilix odczytuje nazwy parametrów konstruktora jako łańcuchy znaków, a narzędzia minifikujące zmieniają nazwy tych parametrów, przez co rozwiązanie przestaje działać w środowisku produkcyjnym. Tylko domyślny tryb PROXY przetrwa agresywne modyfikacje.

    Wbudowany injector w Angular

    Angular posiada jeden z najpotężniejszych hierarchicznych systemów DI w rozwoju frontendu, obejmujący iniekcję opartą na tokenach, dostawców o określonym zasięgu oraz wbudowane ładowanie opóźnione. Gdyby można go było używać bez środowiska runtime Angular, byłby poważnym kandydatem do realizacji warstwy domenowej. Niestety nie jest to możliwe, i to stanowi problem.

    React Context

    Context w rzeczywistości nie jest kontenerem DI; to sposób na uniknięcie problemu przenoszenia właściwości po łańcuchu komponentów. Dostawca znajduje się blisko korzenia, a komponenty odczytują jego wartości za pomocą useContext(). Każda zmiana dostarczonej wartości powoduje ponowne renderowanie wszystkich komponentów, które z niej korzystają, przy czym brakuje mechanizmów zarządzania cyklem życia komponentów, wiązania fabrycznego ani izolacji zasięgu. Jego jedyną rzeczywistą zaletą jest to, że nie dodaje żadnych dodatkowych rozmiarów do pliku bundle.

    Vue provide i inject

    Mechanizm Vue przekazuje wartości w dół drzewa komponentów i dzieli się ograniczeniami architektonicznymi Context: nie stanowi praktycznej podstawy dla grafu usług domenowych. Ma jednak jedną zaletę ergonomiczną, ponieważ zależności mogą być rejestrowane globalnie za pomocą pluginu, zamiast otaczać drzewo nawarstwionymi dostawcami. Współczesna funkcja inject() w Angularze opiera się na podobnej koncepcji aktywnego kontekstu iniekcji.

    Zwykła iniekcja przez konstruktory

    Podejście zero-library przekazuje każdą zależność wyraźnie przez konstruktory. Jest w pełni bezpieczne pod względem typów i nie generuje żadnego obciążenia w czasie wykonywania. Wadą jest skalowalność: gdy aplikacja ma około 10 do 15 usług domenowych, ręczne połączenie grafu w punkcie wejścia zamienia się w duży, kruchy plik, którego wszyscy boją się edytować.

    Co pokazuje to porównanie

    Rozmiary plików pakietowych porównano poprzez stworzenie pliku wejściowego, który importuje publiczną API każdego pakietu za pomocą esbuild --bundle --minify --platform=browser, a następnie skompresowanie uzyskanego wyniku za pomocą gzip -9, co pozwala oddzielić własne obciążenia kontenera od kodu aplikacji. Wynikły cztery wnioski:

    1. Framework DI należy do interfejsu użytkownika, a nie do domeny biznesowej. Umieszczenie funkcji useContext() lub anotacji @Injectable() z Angulara w bibliotekach domenowych łączy reguły biznesowe z jednym frameworkiem, co sprawia, że nie spełnia się pierwszy kryterium.
    2. Ręczna iniekcja sprawdza się tylko w małych projektach. W dużym monorepo przedsiębiorstwa element Composition Root zamienia się w tysiące linii ręcznie napisanego kodu łączącego komponenty.
  • InversifyJS wygrał pod względem modułowości. Jest to jedyny kandydat, w którym moduł (ContainerModule) stanowi wartość pierwszej klasy, którą biblioteka domenowa może stworzyć, zamknąć wewnątrz niej i wyeksportować jako jedną całość.
  • Konfiguracja budowania ma znaczenie. InversifyJS i TSyringe wymagają w pliku tsconfig.json ustawień experimentalDecorators: true, emitDecoratorMetadata: true oraz target: ES2022. InversifyJS v8 nie wymaga już oddzielnego polyfilla reflect-metadata. W przypadku narzędzi opartych na esbuild lub SWC (Vite, Next.js, Bun) należy upewnić się, że krok transformacji obsługuje metadane dekoratorów z wersji starszych, zazwyczaj za pomocą pluginu, ponieważ esbuild sam w sobie nie wyświetla metadanych dekoratorów.
  • Cztery zasady zapobiegające przekształceniu kontenera w lokalizator usług

    Sam kontener nie izoluje niczego; jeśli jest używany lekkomyślnie, staje się globalną kolekcją usług, do której może uzyskać dostęp każdy plik. Dlatego architektura opiera się na czterech konwencjach:

    • Abstrakcyjne tokeny DI utworzone za pomocą Symbol.for oraz właściwości $.
    • Każda biblioteka domenowa eksportuje tylko swój moduł kontenera, nigdy swoje konkretne klasy.
    • Jedyny punkt początkowy kompozycji buduje graf aplikacji.
    • Cienki most dla każdego frameworku UI umożliwia dostęp do kontenera z komponentów.

    Tokeny bezpieczne pod względem typu za pomocą Symbol.for i $

    Kontener potrzebuje identyfikatorów w czasie wykonywania, aby mapować abstrakcje na implementacje. Interfejsy w TypeScript znikają w czasie kompilacji, więc nie mogą bezpośrednio pełnić tej roli. Tutaj używany wzorzec łączy każdy interfejs w pakiecie @my-app/*-contracts z stałą o tym samym nazwie, której właściwość $ przechowuje obiekt typu 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>,
    };
    

    W tym fragmencie zawarte są trzy decyzje:

    1. Jeden nazwa dla typu i wartości. TypeScript przechowuje typy i wartości w oddzielnych przestrzeniach nazw, więc AuthFacade może być zarówno interfejsem, jak i nośnikiem tokena. Kompilator wybiera odpowiednie znaczenie na podstawie kontekstu, więc nikt nie musi wymyślać nazw takich jak AuthFacadeToken.
  • Symbol.for zamiast Symbol(). Każde wezwanie Symbol() zwraca zupełnie nową wartość, podczas gdy Symbol.for() wyszukuje klucz w globalnym rejestrze symboli jądra aplikacji. Jeśli pakiet kontraktów pojawi się dwukrotnie w dwóch bundlach, co może zdarzyć się w monorepo lub przy użyciu Module Federation, obie kopie nadal tworzą ten sam token, a wyszukiwania działają prawidłowo, zamiast nieoczekiwanie zawodzić. Z drugiej strony klucze rejestru są globalnymi łańcuchami, więc powinny być unikalne w całej aplikacji.
  • Kastowanie do ServiceIdentifier<AuthFacade>. Określenie typu $ jako Inversify’ego ServiceIdentifier<T> łączy token z jego interfejsem w czasie kompilacji, dzięki czemu mechanizmy rozwiązywania typów mogą wywnioskować właściwy typ bez konieczności użycia wyraźnych generyków.
  • Następny fragment pokazuje obie strony: klasę otrzymującą fasadę poprzez iniekcję konstruktora oraz bezpośredni wywołanie container.get, którego typ zwracany jest wywnioskowany na podstawie tokena.

    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
    

    Konsument odwołuje się wyłącznie do kontraktu. Nie ma pojęcia, co znajduje się wewnątrz @my-app/auth-core, a właśnie o to chodzi.

    Jeden domen, jeden ContainerModule

    Baza krytyczna każdej domeny udostępnia jedną rzecz: swój moduł DI. Przypady użycia, entity, porty i repozytoria pozostają prywatne. Wewnątrz modułu wewnętrzne przypadki użycia są powiązane ze sobą, natomiast publiczna fasada jest powiązana z tokenem swojego kontraktu.

    // 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';
    

    Encapsulacja jest egzekwowana dwa razy:

    1. Publiczna API. Plik index.ts biblioteki ponownie eksportuje jedynie authContainerModule. Klasy takie jak LoginUseCase czy CoreAuthFacade są po prostu niedostępne z innych pakietów.
    2. Zasady lintingu. Zasada ESLinta @nx/enforce-module-boundaries z Nx sprawdza tagi projektu, więc na przykład projekt o tagu type:core nie może importować projektu o tym samym tagu type:core z innego domeny. Szczegóły dotyczące deklarowania tagów i ograniczeń znajdują się w dokumentacji dotyczącej granic modułów w Nx.

    Granice eksportu zapobiegają przypadkowym importom, natomiast zasady lintingu uniemożliwiają celowe użycie skrótów poprzez złożone ścieżki. Razem sprawiają, że architektura jest weryfikowana przez systemy CI, a nie musi być pamiętana przez recenzentów.

    Korzeń kompozycji

    Korzeń kompozycji to miejsce, w którym tworzony jest graf zależności, zazwyczaj apps/my-app/src/composition-root.ts lub funkcja wywoływana z main.ts. Rządzi nim jedna zasada:

    Korzeń kompozycji to jedyne miejsce w bazie kodu, gdzie dozwolone jest importowanie konkretnych implementacji, adapterów infrastruktury oraz modułów jądra domeny.

    Funkcja poniżej najpierw łączy globalną infrastrukturę (klient HTTP oparty na Axios oraz logger stworzony za pomocą fabryki, aby mógł przyjmować skonfigurowane środki transportu), a następnie ładuje moduły domeny.

    // 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;
    };
    

    Dla pełności informacji, adapter BrowserLogger to zwykła klasa implementująca interfejs LoggerPort, która przekazuje każdą wiadomość do odpowiednich mechanizmów transmisji. Należy zauważyć, że nie zawiera żadnych dekoratorów; ponieważ jest tworzona wewnątrz funkcji toDynamicValue, kontener nie musi w ogóle sprawdzać jej konstruktora.

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

    Kilka aplikacji z jednego jądra domeny

    Ponieważ infrastruktura jest wybierana w Composition Root, różne aplikacje mogą ponownie używać tych samych pakietów domeny z różnymi adapterami oraz innym wyborem modułów. Aplikacja React Native może korzystać z pamięci przechowującej opartej na AsyncStorage, podczas gdy aplikacja administracyjna dla przeglądarki wykorzystuje IndexedDB i ładowa moduły związane z audytem zamiast z płatnościami.

    // 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 jest identyczny bit po bicie we wersjach mobilnych, administracyjnych i na komputerze. Nigdy nie dowiaduje się, czy magazynem danych jest AsyncStorage, czy IndexedDB.

    Zezwalanie jednej domenie na korzystanie z drugiej

    Załóżmy, że payments-core potrzebuje tokenu dostępu zarządzanego przez auth-core. Importowanie @my-app/auth-core jest zabronione zgodnie z regułami dotyczącymi granic. Zamiast tego przypadek użycia płatności polega na tokenie umowy domeny autoryzacyjnej, który znajduje się w pakiecie contracts i jest dozwolony.

    // 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 nie wiąże się z AuthFacade.$; jedynie go wykorzystuje. Wiązanie zostaje nawiązane, gdy korzeń kompozycji ładuje oba moduły do tego samego kontenera. Praktyczne konsekwencje: jeśli aplikacja ładuje funkcje płatności bez autoryzacji, rozwiązanie zawodzi w czasie wykonywania, dlatego warto przeprowadzić test sprawdzający dla każdego docelowego oprogramowania, aby upewnić się, że każdy publiczny token zostanie rozwiązany przynajmniej raz.

    Łączenie kontenera z frameworkami UI

    Ponieważ kontener nie wie nic o żadnej bibliotece UI, każdy framework otrzymuje mały adapter składający się z około 10 do 15 linii kodu.

    React

    Container jest tworzony tylko raz podczas uruchamiania i nigdy nie jest zastępowany, więc wartość kontekstu nigdy się nie zmienia. Dzięki temu unika się łańcucha ponownego renderowania zwykle związanego z Context: konsumentzy otrzymują stabilną referencję. Funkcja useInjection zapamiętuje wyniki wyszukiwania dla każdego kontenera i tokenu oraz rzuca wyraźny błąd, jeśli dostawca jest nieobecny.

    // 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

    System pluginów Vue w połączeniu z typowanym obiektem InjectionKey sprawia, że adapter jest jeszcze krótszy. Plugin dostarcza kontener na poziomie aplikacji, a komponenty komposable wykorzystują go do rozwiązywania tokenów.

    // 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

    Własny hierarchiczny injector Angular może delegować zadania do kontenera domeny za pośrednictwem dostawców typu factory. Przekazywanie kontenera za pomocą InjectionToken oraz tworzenie nowego kontenera przy każdym uruchomieniu zapobiega dzieleniu się stanem pomiędzy równoczesnymi żądaniami renderowanymi na serwerze.

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

    Każdy dodatkowy komponent Angular wymagający fasady musi mieć własny InjectionToken oraz dostawcę fabryki, więc lista ta rośnie wraz z publiczną API; generowanie jej na podstawie małego mapy jest opcją, gdy lista staje się długa.

    Koszty i kiedy to pominąć

    Ustawienie to dodaje około 21 KB po stłumieniu i wymaga użycia emitDecoratorMetadata podczas budowania. W przypadku korporacyjnego monorepo z dziesięcioma lub więcej domenami ten koszt szybko się zwraca. W aplikacji z dwoma lub trzema ekranami izolacja stanowi zbędny obciążenie; zwykła iniekcja konstruktora lub nawet singletoni na poziomie modułu będą lepsze rozwiązanie.

    Główne wnioski

    • Zachowaj mechanizmy DI frameworku (Context, provide/inject, dostawcy Angular) na granicy interfejsu użytkownika; rdzeń domeny powinien polegać wyłącznie na tokenach kontraktu.
  • Najpierw wybierz pojemnik dla modułu: domena, która eksportuje jeden zamknięty ContainerModule, sprawia, że punkt wejścia pozostaje mały.
  • Używaj tokenów Symbol.for o typie ServiceIdentifier<T>, aby zarówno duplikowane pakiety, jak i inferencja typów nadal funkcjonowały.
  • Narzucaj granice za pomocą eksportów i reguł lintingu, a nie tylko konwencji.
  • Pozwól dokładnie jednemu plikowi, czyli Composition Root, na dostęp do konkretnych implementacji; wtedy budowa aplikacji dla różnych platform staje się kwestią zamiany powiązań.
  • Pamiętaj, że iniekcja to tylko narzędzie. Celem jest inwersja: jeśli możesz zastąpić React przez Vue, SQLite przez IndexedDB lub Axios przez Fetch bez edytowania warstwy domeny, strategia spełniła swój cel.
  • Literatura pokrewna

  • Wybór struktury foldera w React: siedem układów i ich punkty załamania — Porównaj układy flat, oparte na typach, oparte na funkcjonalnościach, Atomic Design, DDD, Feature-Sliced Design oraz monorepo dla React i dowiedz się, w jakim stopniu każdy z nich przestaje działać.
  • Przenoszenie Angular HttpClient do backendu Fetch za pomocą withFetch() — Dowiedz się, dlaczego Angular’s HttpClient przechodzi z XMLHttpRequest na fetch, w jaki sposób withFetch() umożliwia edge SSR i transmisję strumieniową oraz co to oznacza dla twoich interceptorów.
  • Jak Angular Dependency Injection rozwiązuje usługi za pomocą inject() — Zrozum, w jaki sposób Angular DI dostarcza usługi poprzez funkcję inject(), providerów oraz hierarchicznych injectorów, a także jak określać zakres instancji i unikać błędów związanych z brakującymi providerami.
  • SOLID jako pytanie o zmianę: Refaktoryzacja usługi Spring Boot — Naucz się stosować każdą z zasad SOLID w backendzie Spring Boot, zadając pytania o to, co może ulec zmianie, oraz jak unikać nadmiernego projektowania, do którego często prowadzą te zasady.