Wstrzykiwanie zależności w monorepo typu TypeScript: InversifyJS dla React i Angular
Wstrzykiwanie zależności w monorepo typu TypeScript: wybór kontenera, definiowanie bezpiecznych pod względem typów tokenów, budowa Composition Root oraz połączenie go z React, Vue i Angular.
Źródła: Frontend Dependency Injection: From Context API to Composition Root
Wielkie 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. Iniekcja 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ą bibliotekom 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 na wysokim poziomie powinna polegać na abstrakcjach, a konkretne szczegóły powinny polegać na tych samych abstrakcjach. Iniekcja zależności to praktyczna technika, która to umożliwia. Klasy proszą o abstrakcyjne tokeny zamiast o konkretne implementacje, a coś spoza nich decyduje o tym, na co te tokeny się odnoszą. W DDD i Clean Architecture nie jest to opcjonalna ulepszenie – domena nie powinna wiedzieć, z jakim frameworkiem interfejsu użytkownika, klientem HTTP czy warstwą persistencji jest uruchamiana.
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 i można ją odczytać w dowolnym miejscu poniż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 iniekcji zależności 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:
- Agnostyczność wobec frameworków. Kontener musi działać w czystym TypeScript bez żadnych zależności od React, Vue czy Angular, a także musi funkcjonować w przeglądarce, w Node.js do SSR oraz w React Native.
- Modularność. Domena powinna móc dostarczyć uprzednio skonfigurowany moduł z zamkniętymi w nim powiązaniami, tak aby aplikacja pobierała go wyłącznie wtedy, gdy jest to konieczne. To zapobiega powstawaniu 500-linijnego pliku
main.ts, w którym każda usługa jest ręcznie rejestrowana. - 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 przekompilowania kontenera.
- Wpływ na rozmiar pliku. Obciążenie w środowisku produkcyjnym powinno być niewielkie, 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 najbardziej kompletny kontener IoC w tym ekosystemie. Jest niezależny od konkretnego frameworku 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. Wadą jest jego duży rozmiar – był to najcięższy wśród sprawdzonych opcji, a określanie aktualnych wersji jako lekkich byłoby mylące.
TSyringe
TSyringe, utrzymywany przez Microsoft, oferuje większą funkcjonalność w zamian za prostotę. 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 pułapka specyficzna dla frontendu 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, z wbudowaną iniekcją opartą na tokenach, dostawcami o określonym zasięgu oraz funkcją ładowania opóźnionego. 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 jest problem.
React Context
Context w rzeczywistości nie jest kontenerem DI; to sposób na uniknięcie problemu przenoszenia właściwości na wiele poziomów. Dostawca znajduje się blisko korzenia, a komponenty odczytują go za pomocą useContext(). Każda zmiana wartości dostarczanej powoduje ponowne renderowanie wszystkich komponentów, które z niej korzystają, przy czym brakuje mechanizmów zarządzania cyklem życia, 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 ma żadnych kosztów w czasie wykonywania. Wadą jest skalowalność: gdy aplikacja ma około 10 do 15 usług domenowych, ręczne łączenie grafu w punkcie wejścia przekształca się w duży, kruchy plik, którego wszyscy boją się edytować.
Co pokazuje to porównanie
Wielkość plików pakietów 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:
- 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. - 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 napisanych powiązań.
ContainerModule) stanowi wartość pierwszej klasy, którą biblioteka domenowa może stworzyć, zamknąć wewnątrz niej i wyeksportować jako jedną całość.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 nieostrożnie, 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.fororaz 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.
Bezpieczeństwo typów dzięki tokenom z Symbol.for i $
Kontener potrzebuje identyfikatorów w czasie wykonywania, aby mapować abstrakcje na implementacje. Interfejsy 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:
- Jedna nazwa dla typu i wartości. TypeScript przechowuje typy i wartości w oddzielnych przestrzeniach nazw, dzięki czemu
AuthFacademoż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 jakAuthFacadeToken. Symbol.forzamiastSymbol(). Każde wezwanieSymbol()zwraca zupełnie nową wartość, podczas gdySymbol.for()wyszukuje klucz w globalnym rejestrze symboli w czasie wykonywania. Jeśli pakiet contracts zostanie skopiowany dwukrotnie w dwóch bundlach, co może się zdarzyć w monorepo lub przy użyciu Module Federation, obie kopie nadal wytwarzają 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łym aplikacji.- Przekształcenie typu
ServiceIdentifier<AuthFacade>. Określenie typu$jako Inversify’egoServiceIdentifier<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 z 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 umowy. Nie ma pojęcia, co znajduje się wewnątrz @my-app/auth-core, a to właśnie jest sedno sprawy.
Jeden domen, jeden ContainerModule
Baza krytyczna każdej domeny udostępnia tylko jedną rzecz: swój moduł DI. Przypady użycia, entity, porty oraz 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 umowy.
// 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';
Kapsulacja jest egzekwowana dwa razy:
- Publiczna API. Plik
index.tsbiblioteki ponownie eksportuje tylkoauthContainerModule. Klasy takie jakLoginUseCaselubCoreAuthFacadesą po prostu niedostępne z innych pakietów. - Zasady lintingu. Zasada ESLinta
@nx/enforce-module-boundariesz Nx sprawdza tagi projektu, więc na przykład projekt o tagutype:corenie może importować projektu o tagutype:corez innego domeny. Szczegóły dotyczące deklarowania tagów i ograniczeń znajdują się w dokumentacji dotyczącej granic modułów w Nx.
Granica eksportu zapobiega przypadkowym importom, natomiast zasada lintingu uniemożliwia celowe użycie skrótów poprzez złożone ścieżki. Razem sprawiają one, że architektura jest sprawdzana 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, w którym 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 LoggerPort i kierująca każdą wiadomość do odpowiednich mechanizmów transmisji. Należy zauważyć, że nie zawiera żadnych dekoratorów; ponieważ jest tworzona wewnątrz toDynamicValue, kontener nie musi nigdy 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 wykorzystywać te same pakiety 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 używa IndexedDB i ładowała domenę audytową zamiast domeny płatności.
// 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 w wersjach mobilnych, administracyjnych i na komputerze. Nigdy nie dowiaduje się, czy magazynowanie odbywa się za pomocą AsyncStorage, czy IndexedDB.
Zezwalanie jednej domenie na korzystanie z drugiej
Załóżmy, że payments-core potrzebuje tokena dostępu, którym zarządza auth-core. Importowanie @my-app/auth-core jest zabronione zgodnie z zasadami rozgraniczeń. 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 token publiczny zostanie rozwiązany raz.
Łączenie kontenera z React, Vue i Angular
Ponieważ kontener nie wie nic o żadnej bibliotece UI, każda z tych frameworków 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ązанego z Context: konsumenty otrzymują stabilny odwołanie. Funkcja useInjection zapamiętuje wyniki wyszukiwania dla każdego kontenera i tokena 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 InjectionKey sprawiają, że adapter jest jeszcze krótszy. Plugin dostarcza kontener na poziomie aplikacji, a komponent kompozytowy wykorzystuje 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 Angulara 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 potrzebujący własnej fasady otrzymuje osobny 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 nakład pracy; zwykła iniekcja konstruktora lub nawet singletoni na poziomie modułu będą lepsze rozwiązanie.
Zasady iniekcji zależności w frontendzie
- Zachowaj mechanizmy DI frameworku (Context, provide/inject, dostawcy Angular) na obrzeżach interfejsu użytkownika; rdzeń domeny powinien polegać wyłącznie na tokenach kontraktów.
- 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.foro typieServiceIdentifier<T>, aby zarówno duplikowane pakiety, jak i inferencja typów nadal funkcjonowały. - Ustanawiaj granice za pomocą eksportów i reguł lintingu, a nie tylko konwencji.
- Pozwól tylko jednemu plikowi, czyli Composition Root, na dostęp do konkretnych implementacji; wtedy budowa aplikacji na wielu platformach 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
- Naprawa przeładowania propów w React za pomocą kompozycji i slotów: Dowiedz się, dlaczego propy w React o dużej liczbie ustawień powodują problemy z utrzymaniem kodu, oraz jak inwersja kontroli, kompozycja i sloty umożliwiają tworzenie naprawdę wielokrotnie używalnych komponentów.
- Włączanie narzędzi MCP do interfejsu chatowego w React z wbudowanym zatwierdzeniem przez człowieka: Dowiedz się, jak Model Context Protocol pasuje do aplikacji React: dlaczego backend powinien hostować MCP, jak działa serwer narzędzi oraz jak przesyłać i zatwierdzać wywołania narzędzi w interfejsie użytkownika.