Startseite / Artikel

Abhängigkeitsinjektion in einem TypeScript-Monorepo: InversifyJS für React und Angular

Abhängigkeitsinjektion für ein TypeScript-Monorepo: Wählen Sie einen Container aus, definieren Sie typsichere Token, erstellen Sie eine Composition Root und verbinden Sie diese mit React, Vue und Angular.

Verlag: REACTAPP.TOP14 Min. Lesezeit3191 Wörter

Quellen: Frontend Dependency Injection: From Context API to Composition Root

Große Frontend-Codebasen versagen selten aufgrund eines einzelnen fehlerhaften Komponenten; sie versagen, weil Geschäftsregeln stillschweigend mit React-Hooks, HTTP-Clientn und Speicher-APIs verflochten werden, bis nichts mehr eigenständig getestet oder wiederverwendet werden kann. Dependency Injection ist das Mechanismus auf niedrigem Niveau, der diese Regeln voneinander trennt – allerdings nur, wenn die Verbindungen absichtlich gestaltet werden. Diese Anleitung führt durch die Auswahl eines Containers für ein mehrdomäniges TypeScript-Monorepo, die Definition typsicherer Token, das Abschließen jeder Domäne hinter einem einzigen Modul, das Zusammenstellen des Graphen in einer Composition Root sowie die Offenlegung gegenüber React, Vue und Angular durch Brücken, die jeweils aus etwa einem Dutzend Zeilen bestehen.

Warum die Isolierung bereits vor dem Modellieren der Domäne entschieden werden muss

Klassische Ratschläge des Domain-Driven Design besagen, dass Implementierungsdetails auf später verschoben werden sollten: Zuerst werden Modellentitäten und Anwendungsfallmodelle erstellt, die Tools werden anschließend ausgewählt. In der Praxis profitiert ein Team, das eine umfassende architektonische Veränderung einleitet, davon, bereits zu Beginn eine technische Frage zu klären – genauer gesagt, wie die Modulisolation tatsächlich durchgesetzt werden soll. Ohne eine vereinbarte Mechanismus werden die ersten Domänen ad hoc gestaltet, und die geltenden Konventionen finden nie wieder zur Geltung.

Das zugrundeliegende Prinzip ist das Dependency Inversion Principle: Hochlevel-Politiken sollten von Abstraktionen abhängen, und konkrete Details sollten von denselben Abstraktionen abhängen. Dependency Injection ist die praktische Technik, die dies ermöglicht. Klassen fordern abstrakte Token anstelle konkreter Implementierungen an, und etwas außerhalb von ihnen entscheidet, worauf sich diese Token beziehen. In DDD und Clean Architecture ist dies keine optionalen Verfeinerung; das Domain-Modell darf nicht wissen, mit welchem UI-Framework, HTTP-Client oder Persistenzschicht es arbeitet.

Die meisten Frontend-Teams greifen zuerst auf React Context (oder provide/inject in Vue) zurück. Das scheint ausreichend zu sein: Man deklariert einen Wert in der Wurzel und kann ihn überall darunter abrufen. Die Einschränkungen zeigen sich jedoch schnell. Es gibt keine Lebenszyklusverwaltung, kein Konzept von Factories oder Scopes, und jeder Verbraucher des Wertes ist nun mit dem React-Runtime verbunden, sodass die Logik ohne diesen nicht ausgeführt oder getestet werden kann. Der Weg von diesem Ausgangspunkt zu einer richtigen Composition Root wird im weiteren Verlauf dieses Leitfadens behandelt. Das Schreiben eines maßgeschneiderten Containers lohnt sich in der Regel selten, daher besteht die erste Aufgabe darin, die vorhandenen IoC-Optionen ehrlich zu bewerten.

Kriterien für einen Frontend-Abhängigkeitsinjektionscontainer

Backend-Frameworks wie NestJS, Spring und .NET bieten Dependency Injection als standardmäßig gelöste Funktion mit. Frontend-Code unterliegt zusätzlichen Einschränkungen: Laufzeitkosten, Bundle-Größenbeschränkungen sowie die Notwendigkeit, mit verschiedenen UI-Frameworks zu arbeiten. Fünf Kriterien bestimmen das Bewertungsziel:

  1. Framework-Agnostizität. Der Container muss in reinem TypeScript laufen, ohne Laufzeitabhängigkeiten zu React, Vue oder Angular, und im Browser, in Node.js für SSR sowie in React Native funktionieren.
  2. Modularität. Ein Domänenbereich sollte in der Lage sein, ein vorkonfiguriertes Modul mit eingebauten Bindungen bereitzustellen, sodass die Anwendung dieses Modul nur lädt. Dadurch wird verhindert, dass es zu einem 500 Zeilen langen main.ts kommt, in dem jede Dienstklasse manuell registriert werden muss.
  3. Lebenszyklusverwaltung. Singleton-Instanzen, vorübergehende Instanzen sowie scoped Auflösungen müssen erstklassig unterstützt werden.
  • Testbarkeit. Der Austausch einer Abhängigkeit durch ein Mock-Objekt sollte in einer Zeile erledigt werden können, ohne den Container neu erstellen zu müssen.
  • Einfluss auf das Bundle. Die Betriebslast sollte gering sein – sowohl in der Rohform als auch nach Minifizierung und Komprimierung mit GZIP.
  • Die Kandidaten

    InversifyJS

    InversifyJS ist seit Langem die Standardwahl für große TypeScript-Projekte und stellt den umfassendsten IoC-Container im Ökosystem dar. Er ist framework-unabhängig und deklariert Abhängigkeiten mithilfe von Decoratoren. Sein ContainerModule gruppiert Bindungen in logische Einheiten und unterstützt inSingletonScope, inTransientScope sowie inRequestScope, Untercontainer, das Laden und Entladen von Modulen zur Laufzeit sowie Aktivierungs-Hooks. Der Preis ist das Gewicht: Er war die schwerste Option im Vergleich, und es wäre irreführend, aktuelle Versionen als leicht zu bezeichnen.

    TSyringe

    TSyringe, das von Microsoft gepflegt wird, tauscht Breite gegen Bequemlichkeit ein. Es löst Konstruktorabhängigkeiten automatisch aus den TypeScript-Metadaten, bietet eine umfangreiche Sammlung an Dekoratoren und deckt die üblichen Lebenszyklen (Singleton, Transient, ResolutionScoped, ContainerScoped) ab. Für eine mittelgroße Anwendung stellt es eine attraktive, unkomplizierte Einrichtung dar. Zwei Aspekte sprechen in einer Mehrdomänenarchitektur dagegen: Es gibt kein Konzept erstklassiger Module, sodass die Komposition manuell mit Registrierungsfunktionen oder createChildContainer() erstellt werden muss, und es ist auf den reflect-metadata Polyfill angewiesen, was einen erheblichen Zusatzaufwand verursacht.

    Awilix

    Awilix vermeidet Dekoratoren vollständig. Es nutzt die ES6 Proxy API, um Abhängigkeiten mit den Namen der Konstruktorparameter oder mit Schlüsseln des Argument-Objekts einer Factory abzustimmen. Dadurch eignet es sich ideal für Teams, die keine Dekoratoren verwenden möchten, und es war der kleinste eigenständige Container, der bewertet wurde. Es unterstützt Lebenszyklen von SINGLETON, SCOPED und TRANSIENT; Module werden mithilfe von benutzerdefinierten Registrierungsfunktionen organisiert.

    Eine frontend-spezifische Fallstrick verdient Aufmerksamkeit. Im CLASSIC-Injection-Modus liest Awilix die Namen der Konstruktorargumente als Strings, und Minifier-Tools ändern diese Argumentnamen um, wodurch die Auflösung in der Produktion fehlschlägt. Nur der Standardmodus PROXY übersteht eine aggressive Umwandlung der Namen.

    Angulos eingebauter Injector

    Angular verfügt über eines der leistungsstärksten hierarchischen DI-Systeme in der Frontend-Entwicklung, mit tokenbasiertem Einbinden, begrenzten Providern sowie integriertem Lazy Loading. Wenn es ohne den Angular-Runtime verwendet werden könnte, wäre es ein ernsthafter Konkurrent für die Domain-Schicht. Das ist jedoch nicht möglich – und das ist das Problem.

    React Context

    Context ist eigentlich kein DI-Container; es handelt sich dabei um eine Methode, um das Prop-Drilling zu vermeiden. Ein Provider befindet sich in der Nähe der Wurzel, und Komponenten lesen ihn mit useContext() ab. Jede Änderung des bereitgestellten Wertes führt dazu, dass alle Verbraucher neu gerendert werden, und es gibt weder ein Lebenszyklusmanagement noch Factory-Bindings noch Isolierung der Scope. Sein einziger wirklicher Vorteil ist, dass er keinen zusätzlichen Platz im Bundle beansprucht.

    Vue provide und inject

    Vues Mechanismus leitet Werte den Komponentenbaum entlang und teilt die architektonischen Einschränkungen des Contexts: Er bildet keine praktische Grundlage für ein Netzwerk aus Domänen-Diensten. Dennoch bietet er einen ergonomischen Vorteil, da Abhängigkeiten global über ein Plugin registriert werden können, anstatt den Baum in verschachtelte Provider einzuhüllen. Angulos moderne inject()-Funktion stützt sich auf eine ähnliche Idee eines aktiven Injektionskontexts.

    Einfache Konstruktorinjektion

    Der Ansatz ohne zusätzliche Bibliotheken leitet jede Abhängigkeit explizit über Konstruktoren weiter. Er ist vollständig typsicher und verursacht keinen Laufzeit-Overhead. Der Nachteil liegt in der Skalierbarkeit: Sobald eine Anwendung etwa 10 bis 15 Domänen-Dienste hat, wird das Manuell-Verschalten des Netzwerks am Eingangspunkt zu einer großen, brüchigen Datei, die niemand mehr anfassen möchte.

    Was der Vergleich zeigt

    Die Größen der Bündel wurden ermittelt, indem ein Eintragsfile, das die öffentliche API jedes Pakets importiert, mit esbuild --bundle --minify --platform=browser gebündelt und das Ergebnis mit gzip -9 komprimiert wurde, wodurch die eigenen Overhead-Kosten des Containers vom Anwendungscode getrennt werden. Daraus ergaben sich vier Schlussfolgerungen:

    1. Framework-DI gehört zur UI und nicht zum Domain-Bereich. Die Verwendung von useContext() oder Angulars @Injectable() in Domain-Bibliotheken verbindet Geschäftsregeln mit einem bestimmten Framework und erfüllt somit nicht das erste Kriterium.
    2. Manuelle Injektion funktioniert nur bei kleinen Projekten. In einem großen Unternehmens-Monorepo führt der Composition Root zu Tausenden von manuell geschriebenen Verbindungen.
  • InversifyJS gewann bei der Modularität. Es ist der einzige Kandidat, bei dem ein Modul (ContainerModule) ein Erstklassewert ist, den eine Domänenbibliothek als eine Einheit erstellen, kapseln und exportieren kann.
  • Die Build-Konfiguration ist wichtig. InversifyJS und TSyringe benötigen in tsconfig.json die Einstellungen experimentalDecorators: true, emitDecoratorMetadata: true sowie target: ES2022. InversifyJS v8 benötigt keinen separaten reflect-metadata Polyfill mehr. Bei mit esbuild- oder SWC-basierten Werkzeugen (Vite, Next.js, Bun) muss überprüft werden, ob der Transformierungsprozess veraltete Dekoratormetadaten unterstützt – in der Regel über ein Plugin –, da esbuild selbst keine Dekoratormetadaten ausgibt.
  • Vier Regeln, die verhindern, dass der Container zu einem Service Locator wird

    Ein Container für sich allein isoliert nichts; wird unvorsichtig verwendet, wird er zu einer globalen Sammlung von Diensten, auf die jede Datei zugreifen kann. Die Architektur stützt sich daher auf vier Konventionen:

    • Abstrakte DI-Token, die mit Symbol.for sowie einer $-Eigenschaft erstellt werden.
    • Jede Domänenbibliothek exportiert nur ihr Container-Modul, niemals ihre konkreten Klassen.
    • Ein einziger Composition Root stellt das Anwendungsgraphen zusammen.
    • Ein dünner Brückenelement pro UI-Framework stellt den Container den Komponenten zur Verfügung.

    Typsicherheit mit Tokens von Symbol.for und $

    Ein Container benötigt Laufzeit-Identifikatoren, um Abstraktionen auf Implementierungen abzubilden. TypeScript-Interfaces verschwinden zur Kompilierzeit, sodass sie diese Rolle nicht direkt übernehmen können. Das hier verwendete Muster verknüpft jedes Interface in einem @my-app/*-contracts-Paket mit einer Konstante desselben Namens, deren $-Eigenschaft einen Symbol enthält.

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

    Drei Entscheidungen sind in diesem Auszug enthalten:

    1. Ein Name für Typ und Wert. TypeScript bewahrt Typen und Werte in getrennten Namensräumen auf, sodass AuthFacade sowohl die Schnittstelle als auch der Token-Träger sein kann. Der Compiler wählt aus dem Kontext die richtige Bedeutung aus, und niemand muss Namen wie AuthFacadeToken erfinden.
    2. Symbol.for anstelle von Symbol(). Jeder Aufruf von Symbol() gibt einen völlig neuen Wert zurück, während Symbol.for() nach dem Schlüssel im globalen Symbolregister der Laufzeit sucht. Wenn ein Packages aus einem Kontrakt in zwei Bundles dupliziert wird – was in einem Monorepo oder mit Module Federation vorkommen kann – erzeugen beide Kopien weiterhin denselben Token, und die Abfragen funktionieren weiterhin, anstatt mysteriös zu versagen. Der Nachteil ist, dass Registry-Schlüssel globale Zeichenketten sind, weshalb sie in der gesamten Anwendung eindeutig sein sollten.
    3. Die Typumwandlung ServiceIdentifier<AuthFacade>. Durch das Angben von $ als Inversifys ServiceIdentifier<T> wird der Token bereits zur Kompilierzeit mit seiner Schnittstelle verknüpft, sodass die Auflösungssysteme den richtigen Typ ohne explizite Generics ableiten können.

    Der folgende Auszug zeigt beide Ansätze: eine Klasse, die die Fassade über Konstruktorinjektion erhält, sowie einen direkten Aufruf von container.get, dessen Rückgabetyp aus dem Token abgeleitet wird.

    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
    

    Der Verbraucher bezieht sich stets nur auf den Vertrag. Er hat keine Ahnung, was sich in @my-app/auth-core befindet – und genau das ist der Sinn.

    Ein Domain, ein ContainerModule

    Jede Domain-Core-Bibliothek stellt nur eine Sache bereit: ihr DI-Modul. Anwendungsfall, Entitäten, Ports und Repositorien bleiben privat. Innerhalb des Moduls sind interne Anwendungsfälle an sich selbst gebunden, während die öffentliche Schnittstelle an ihr Vertrags-Token gebunden ist.

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

    Die Kapselung wird zweifach durchgesetzt:

    1. Öffentliche API. Die index.ts der Bibliothek gibt nur authContainerModule erneut aus. Klassen wie LoginUseCase oder CoreAuthFacade sind von anderen Paketen einfach nicht erreichbar.
    2. Lint-Regeln. Die ESLint-Regel @nx/enforce-module-boundaries von Nx überprüft die Projekt-Tags; ein Projekt mit dem Tag type:core darf beispielsweise kein Projekt mit dem Tag type:core aus einem anderen Bereich importieren. Weitere Informationen zur Deklaration von Tags und Einschränkungen finden Sie in der Dokumentation zu den Nx-Modulgrenzen.

    Die Exportgrenze verhindert versehentliche Importe; die Lint-Regel verhindert absichtliche Umwege über komplexe Pfade. Gemeinsam sorgen sie dafür, dass die Architektur von CI-Systemen überprüft wird, anstatt dass Prüfer sich daran erinnern müssen.

    Der Composition Root

    Die Composition Root ist der Ort, an dem das Abhängigkeitsdiagramm erstellt wird – in der Regel apps/my-app/src/composition-root.ts oder eine Funktion, die von main.ts aufgerufen wird. Es gilt eine einzige Regel dafür:

    Die Composition Root ist der einzige Ort im Codebase, an dem konkrete Implementierungen, Infrastrukturadapter sowie Kernmodule des Domänenbereichs importiert werden dürfen.

    Die untenstehende Funktion bindet zunächst die globale Infrastruktur (einen auf Axios basierenden HTTP-Client sowie einen Logger, der über eine Factory erstellt wird, damit er konfigurierte Transporte empfangen kann), und lädt anschließend die Domänenmodule.

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

    Zur Vollständigkeit: Der BrowserLogger-Adapter ist eine gewöhnliche Klasse, die LoggerPort implementiert und jede Nachricht an ihre jeweiligen Transporte weiterleitet. Beachten Sie, dass er keine Dekoratoren enthält; da er innerhalb von toDynamicValue erstellt wird, muss der Container seinen Konstruktor niemals überprüfen.

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

    Mehrere Anwendungen aus einem einzigen Domain-Kern

    Weil die Infrastruktur im Composition Root ausgewählt wird, können verschiedene Anwendungen dieselben Domain-Pakete mit unterschiedlichen Adaptern sowie einer anderen Auswahl an Modulen wiederverwenden. Eine React Native-Anwendung kann beispielsweise eine durch AsyncStorage unterstützte Speicherlösung nutzen, während eine Web-Admin-Anwendung IndexedDB verwendet und stattdessen ein Audit-Domain-Modul lädt.

    // 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 ist in den mobilen, administrativen und Desktop-Versionen bytegenau identisch. Es erfährt niemals, ob der Speicher AsyncStorage oder IndexedDB ist.

    Erlaubnis für einen Domain, eine andere zu verwenden

    Nehmen wir an, payments-core benötigt ein Zugriffstoken, das von auth-core verwaltet wird. Die Grenzregeln verbieten die Importierung von @my-app/auth-core. Stattdessen verlässt sich der Zahlungsfall auf das Vertrags-Token der Authentifizierungsdomain, das sich im contracts-Paket befindet und erlaubt ist.

    // 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 bindet AuthFacade.$ nicht; es verwendet es lediglich. Die Bindung erfolgt, wenn der Composition Root beide Module in denselben Container lädt. Die praktische Folge ist: Wenn eine Anwendung Zahlungen ohne Authentifizierung lädt, scheitert die Auflösung zur Laufzeit, weshalb es sinnvoll ist, für jedes Anwendungsziel einen Smoke Test durchzuführen, bei dem jeder öffentliche Token einmal aufgelöst wird.

    Verbindung des Containers mit React, Vue und Angular

    Da der Container nichts über UI-Bibliotheken weiß, erhält jedes Framework einen kleinen Adapter mit etwa 10 bis 15 Zeilen Code.

    React

    Der Container wird beim Start einmal erstellt und nie ersetzt, sodass sich der Kontextwert niemals ändert. Dadurch wird die mit Context üblicherweise verbundene Wiederrendern-Kaskade umgangen: Die Nutzer erhalten einen stabilen Referenzwert. useInjection merkt sich die Abfrage pro Container und Token und wirft einen klaren Fehler, wenn der Anbieter fehlt.

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

    Das Plugin-System von Vue sowie ein typisierter InjectionKey machen den Adapter noch kürzer. Das Plugin stellt den Container auf Anwendungsebene bereit, und ein Composable löst daraus Tokens auf.

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

    Angulos eigener hierarchischer Injector kann über Factory-Provider an den Domain-Container delegieren. Durch die Übermittlung des Containers über einen InjectionToken sowie die Erstellung eines neuen Containers bei jedem Bootstrap wird verhindert, dass gleichzeitige serverseitig gerenderte Anfragen denselben Zustand teilen.

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

    Jeder zusätzliche Angular-Komponenten-Fassade benötigt seinen eigenen InjectionToken sowie einen Factory-Provider, wodurch die Liste mit der Veröffentlichung der API wächst; die Erstellung aus einem kleinen Register ist eine Option, sobald die Liste sehr lang wird.

    Kosten und wann man darauf verzichten sollte

    Die Einrichtung fügt etwa 21 KB im Gzip-Format hinzu und erfordert emitDecoratorMetadata während des Builds. In einem Unternehmensmonorepo mit zehn oder mehr Domänen wird dieser Aufwand schnell wieder ausgeglichen. Bei einer Anwendung mit zwei oder drei Bildschirmen ist die Isolierung ein unnötiger Overhead; einfache Konstruktorinjektion oder sogar Singleton-Einträge auf Modulebene sind besser geeignet.

    Regeln für die Frontend-Abhängigkeitsinjektion

    • Halten Sie die DI-Mechanismen des Frameworks (Context, provide/inject, Angular Providers) am Rand der Benutzeroberfläche; der Kern der Domäne sollte nur auf Vertrags-Token angewiesen sein.
    • Wählen Sie zunächst einen Container für die Modulstruktur aus: Ein Domain, der ein versiegeltes ContainerModule exportiert, sorgt dafür, dass der Einstiegspunkt klein bleibt.
    • Verwenden Sie Symbol.for-Token vom Typ ServiceIdentifier<T>, damit sowohl duplizierte Pakete als auch die Typableitung weiterhin funktionieren.
    • Setzen Sie Grenzen durch Exporte und Lint-Regeln – nicht nur durch Konventionen.
    • Lassen Sie genau eine Datei, die Composition Root, konkrete Implementierungen berühren; mehrplattformbasierte Builds werden dann zu einer Frage des Austauschs von Bindings.
    • Vergessen Sie nicht: Injektion ist nur das Werkzeug. Das Ziel ist die Inversion – wenn Sie React durch Vue, SQLite durch IndexedDB oder Axios durch Fetch ersetzen können, ohne die Domain-Schicht zu ändern, hat die Strategie ihre Aufgabe erfüllt.

    Weitere Literatur

  • Auswahl einer React-Folderverstruktur: Sieben Layouts und ihre Bruchpunkte: Vergleichen Sie flache, typbasierte, funktionsbasierte, Atomic Design-, DDD-, Feature-Sliced-Design- sowie Monorepo-Layouts für React und erfahren Sie, ab welcher Größenordnung jedes Layout nicht mehr funktioniert.
  • Umstellung von Angular HttpClient auf das Fetch-Backend mit withFetch(): Erfahren Sie, warum Angulars HttpClient von XMLHttpRequest zu fetch wechselt, wie withFetch() Edge SSR und Streaming ermöglicht und welche Auswirkungen dies auf Ihre Interceptor-Funktionen hat.
  • Wie Angular Dependency Injection Dienste mit inject() auflöst: Erfahren Sie, wie Angular DI Dienste über inject(), Provider und hierarchische Injectoren bereitstellt sowie wie Sie Instanzen einschränken und Fehler aufgrund fehlender Provider vermeiden können.
  • SOLID als Frage zum Wandel: Refactoring eines Spring Boot Services: Lernen Sie, jedes SOLID-Prinzip in einem Spring Boot-Backend anzuwenden, indem Sie herausfinden, was sich wahrscheinlich ändern wird, und wie Sie die Überengineering-Probleme vermeiden, die SOLID oft mit sich bringt.