Startseite / Artikel / NestJS-Injektionstoken: Warum useClass Singletone stillschweigend duplizieren kann

NestJS-Injektionstoken: Warum useClass Singletone stillschweigend duplizieren kann

Verstehen Sie Typen, Tokens und Anbieter in NestJS DI, warum Interfaces nicht injiziert werden können, und wie useExisting das Erstellen von zwei Instanzen desselben zustandsbehafteten Services vermeidet.

1672 Wörter

NestJS-Abhängigkeitsinjektion scheint anfangs mühelos zu sein: Man deklariert einen Konstruktorparameter, und das Framework liefert eine fertige Instanz. Hinter dieser Einfachheit verbirgt sich jedoch eine Zuordnung zwischen TypeScript-Typen, Laufzeit-Token und Provider-Definitionen, die bei Fehlverständnis zu schwer erkennbaren Fehlern führen kann. Dazu gehören doppelte Caches, zusätzliche Datenbankpools oder Mocks, die sich nie auflösen. Dieser Artikel entwickelt ein präzises mentales Modell dafür, wie NestJS Abhängigkeiten löst, erklärt, warum Interfaces nicht als Injektionschlüssel dienen können, und zeigt, wie eine einzige Provider-Einstellung darüber entscheidet, ob ein Service mit einer oder zwei Instanzen arbeitet.

Von automatischer Verkabelung zu expliziten Tokenen

Die typische erste Begegnung mit der DI in NestJS ist ein Konstruktor wie dieser:

constructor(
  private readonly emailService: EmailService,
) {}

Für eine Klassenzuverlässigkeit liest NestJS die von TypeScript ausgegebenen Metadaten zu den Typen der Konstruktorparameter (aktiviert durch emitDecoratorMetadata) und verwendet die Klasse selbst, um den passenden Anbieter zu finden. Es sind keine zusätzlichen Annotationen erforderlich.

Größere Codebasen sehen oft anders aus:

constructor(
  @Inject('EMAIL_SERVICE')
  private readonly emailService: EmailService,
) {}

Falls der Parameter bereits typisiert ist, was fügt @Inject() hinzu, und warum kann das Umstellen von useClass auf useExisting dazu führen, dass entweder eine oder zwei Instanzen bereitgestellt werden? Um dies zu beantworten, müssen drei Konzepte unterschieden werden, die in der Regel denselben Namen teilen.

Typ, Token und Anbieter sind drei verschiedene Dinge

Anfänger betrachten diese oft als ein und dasselbe Konzept, weil sie im einfachen Fall denselben Identifikator haben:

  • Typ: das, was der TypeScript-Compiler verwendet, um Ihren Code zu überprüfen. Er existiert nur zur Kompilierzeit.
  • Token: der Laufzeit-Schlüssel, den NestJS verwendet, um einen Anbieter in seinem Container zu finden.
  • Anbieter: die Registrierung, die NestJS mitteilt, wie ein Wert für ein Token erzeugt werden soll – sei es durch Instanziierung einer Klasse, Wiederverwendung eines anderen Anbieters, Rückgabe eines festen Wertes oder Aufruf einer Factory.
  • Mit der herkömmlichen klassenbasierten Injektion übernimmt eine Klasse gleichzeitig die Rolle des TypeScript-Typs und des NestJS-Tokens:

    CustomLoggerService
           ↓
      NestJS Token
           +
     TypeScript Type
    

    Ein benutzerdefiniertes Token trennt diese Rollen voneinander. Im folgenden Konstruktor dienen der String und die Typangabe völlig unterschiedlichen Zwecken:

    constructor(
      @Inject('logger')
      private readonly logger: CustomLoggerService,
    )
    

    Im Einzelnen sehen die Verantwortlichkeiten so aus:

    @Inject('logger')
           ↓
      NestJS Token (Finds the provider in memory)
    
    : CustomLoggerService
           ↓
      TypeScript Type (Gives you IDE autocomplete)
    

    Das Token ist das, was NestJS zur Suche nach der Instanz zur Laufzeit verwendet. Die Typangabe gibt dem Compiler und Ihrem Editor lediglich Informationen über seine Struktur für die Typüberprüfung und das Autocomplete. Eine nützliche Abkürzung: Das Token lokalisiert die Instanz, der Typ beschreibt deren Inhalt. Beachten Sie, dass NestJS nicht überprüft, ob beides übereinstimmt; wenn der Anbieter hinter 'logger' etwas anderes zurückgibt, wird TypeScript dies nicht erkennen.

    Warum man keine Schnittstelle injizieren kann

    Eine häufige Anfrage von Entwicklern, die mit NestJS neu sind, ist es, sich auf eine Schnittstelle zu stützen, um den Code entkoppelt zu halten – beispielsweise indem ein Parameter als IMailService definiert wird. Der natürliche erste Versuch scheitert:

    // Won't work at runtime
    constructor(
      private readonly mailService: IMailService,
    ) {}
    

    Zur Startzeit meldet NestJS Nest kann die Abhängigkeiten von UserService (?) nicht auflösen. Das Fragezeichen kennzeichnet den Parameter, den es nicht identifizieren konnte.

    Der Grund ist, dass in JavaScript keine Interfaces existieren. Wenn tsc Ihren Code kompiliert, werden alle Interface-Definitionen sowie alle reinen Typangaben entfernt. NestJS setzt auf Metadaten, die im laufenden Programm erhalten bleiben, und eine Interface hinterlässt nichts, was es lesen könnte; die erzeugten Metadaten für diesen Parameter reduzieren sich auf das generische Object, das keinen passenden Anbieter findet.

    Klassen sind anders: Sie werden zu echten JavaScript-Konstruktorfunktionen kompiliert, sodass sie auch zur Laufzeit weiterhin vorhanden sind und als Schlüssel dienen können.

    class CustomLoggerService
            ↓
    exists at runtime
            ↓
    can be used as a DI token
    
    interface IMailer
            ↓
    erased during compilation
            ↓
    cannot be used as a runtime DI token
    

    Wenn man also mit einer Abstraktion programmiert, muss man explizit ein Laufzeit-Token bereitstellen, wie zum Beispiel @Inject('MAIL_SERVICE'), und einen Anbieter unter diesemselben Token registrieren. Eine abstrakte Klasse ist eine weitere Alternative, die man kennen sollte: Da sie zu einer echten Funktion kompiliert wird, kann sie ohne @Inject() sowohl als Typ als auch als Token fungieren.

    Die Falle des doppelten Singleton: useClass versus useExisting

    Sobald eigene Token zum Einsatz kommen, muss jedes Modul NestJS mitteilen, wie diese gelöst werden sollen – und genau hier schlüpft häufig ein subtiler Fehler ein. Betrachten wir dieses Modul:

    @Module({
      providers: [
        CustomLoggerService,
        {
          provide: 'APP_LOGGER',
          useClass: CustomLoggerService, // The trap
        },
      ],
    })
    export class CommonModule {}
    

    Auf den ersten Blick scheint 'APP_LOGGER' nur ein weiterer Name für CustomLoggerService zu sein. Das ist nicht der Fall. Das Modul enthält nun zwei unabhängige Anbieterregistrierungen, und dank des standardmäßigen Singleton-Scope erhält jede davon ihre eigene Instanz:

    • Der Klassentoken CustomLoggerService wird durch den Aufbau der Klasse gelöst, wodurch Instanz A entsteht.
    • Der Zeichentoken 'APP_LOGGER' wird erneut durch den Aufbau der Klasse gelöst, wodurch Instanz B entsteht.

    Das Problem tritt nur bei Zuständen auf. Wenn der Logger einen In-Memory-Puffer speichert, eine Warteschlange führt, Anfragen zur Geschwindigkeitsbegrenzung zählt oder eine Verbindung besitzt, kommunizieren die Konsumierenden, die die Klasse injizieren, sowie diejenigen, die den Zeichentoken injizieren, mit unterschiedlichen Objekten, die niemals die Daten des anderen sehen.

    Die Lösung: ein Alias mit useExisting

    Falls es darum geht, einem bereits registrierten Anbieter einen zusätzlichen Namen zu geben, sollte useExisting verwendet werden. Damit weist man NestJS an, nichts Neues zu erstellen und den Token stattdessen auf die bereits vorhandene Instanz zu beziehen:

    @Module({
      providers: [
        CustomLoggerService,
        {
          provide: 'APP_LOGGER',
          useExisting: CustomLoggerService, // Points to the existing singleton
        },
      ],
    })
    export class CommonModule {}
    

    Eine einfache Möglichkeit, den Unterschied darzustellen, sind Kästchen und Namensschilder:

    • useClass erstellt eine zweite Box. Das Klassentoken kennzeichnet Box A, das Zeichentexttoken kennzeichnet Box B.
    • useExisting erstellt eine einzige Box und fügt beide Nametags dazu hinzu.

    Sobald der Alias vorhanden ist, erhält ein Verbraucher, der CustomLoggerService injiziert, sowie ein anderer, der 'APP_LOGGER' injiziert, dasselbe Objekt; daher ergibt ein strenger Gleichheitscheck zwischen ihnen true.

    useClass bleibt die richtige Wahl, wenn man tatsächlich eine separate Instanz benötigt oder wenn das Token die einzige Möglichkeit ist, die Klasse zu registrieren, wie im nächsten Abschnitt erläutert.

    Tokens als architektonischer Trennpunkt

    Für einen kleinen CRUD-Dienst können benutzerdefinierte Token übertrieben erscheinen. Ihr Nutzen zeigt sich, wenn eine Implementierung voraussichtlich geändert werden muss. Angenommen, Dutzende von Controllern nutzen einen auf Winston basierenden Dienst zur Protokollierung. Anstatt in jedem Controller WinstonLoggerService einzubinden, verlassen sie sich lediglich auf ein Token und eine Schnittstelle:

    constructor(
      @Inject('LOGGER') private readonly logger: LoggerInterface
    ) {}
    

    Das Modul entscheidet, welche Implementierung hinter diesem Token steckt:

    {
      provide: 'LOGGER',
      useClass: WinstonLoggerService,
    }
    

    Der Wechsel zu einem Cloud-Logging-Backend wie Google Cloud Logging oder AWS CloudWatch bedeutet dann nur eine einzige Änderung im Modul, ohne dass irgendwelche Verbraucher-Module angepasst werden müssen:

    {
      provide: 'LOGGER',
      useClass: CloudLoggerService,
    }
    

    Dieselbe Struktur macht das Testen einfach, denn ein Testmodul kann 'LOGGER' an einen Stub binden, ohne den getesteten Code anzufassen.

    Eine praktische Verbesserung: Einfache Zeichenketten lassen sich leicht falsch eingeben und können zwischen Modulen aufeinandertreffen. Durch die Definition von Tokens als exportierte Konstanten oder als Symbol-Werte bleibt ihre Konsistenz gewahrt, und der Compiler kann Tippfehler erkennen.

    Provider-Muster im Überblick

    Die vier am häufigsten vorkommenden Provider-Formen sowie die Situationen, in denen jede davon geeignet ist:

    • Klasse-Provider (useClass): Erstellt eine Instanz einer Klasse für ein Token. Verwenden Sie ihn, um ein Abstraktions-Token mit einer konkreten Implementierung zu verknüpfen oder die Implementierungen je nach Umgebung auszutauschen. Jede Registrierung erzeugt ihre eigene Instanz.
    • Alias-Provider (useExisting): Weist ein Token auf einen bereits vorhandenen Provider hin. Nutzen Sie ihn, um eine Instanz unter mehreren Namen bereitzustellen, ohne den Zustand zu duplizieren.
  • Wertlieferant (useValue): gibt einen festen Wert zurück, wie beispielsweise ein Konfigurationsobjekt, eine Konstante oder ein Mock in Tests.
  • Fabriklieferant (useFactory): berechnet den Wert mithilfe einer Funktion, gegebenenfalls mit in inject aufgeführten injizierten Abhängigkeiten. Verwenden Sie ihn, wenn die Erstellung von der Konfiguration abhängt oder asynchron ist, wie beispielsweise bei Datenbankverbindungen.
  • Vergessen Sie die Exports nicht

    Ein klassisches Problem ist ein korrekt in CommonModule konfigurierter Token, den UserModule dennoch nicht auflösen kann. Der providers-Array steuert, was ein Modul für seine eigene Verwendung registriert. Der exports-Array bestimmt, was es Modulen zur Verfügung stellt, die es importieren. Ein benutzerdefinierter Token wie 'LOGGER' muss im exports-Array erscheinen (und das importierende Modul muss CommonModule importieren), bevor externe Komponenten ihn einfügen können. Wenn Sie einen mit useExisting erstellten Alias exportieren, überprüfen Sie, ob die Nutzer auch auf den Provider zugreifen können, auf den er verweist – oder exportieren Sie beide Tokens.

    Kernpunkte

    • Ein Typ existiert nur zur Kompilierzeit, ein Token ist der Schlüssel zur Laufzeit, und ein Provider definiert, wie dieser Schlüssel erfüllt wird; halten Sie diese drei Aspekte beim Lesen von DI-Code getrennt.
  • Die Schnittstellen werden während der Kompilierung gelöscht, weshalb sie ein explizites Token über @Inject() benötigen oder eine abstrakte Klasse, die zur Laufzeit vorhanden ist.
  • Durch das Registrieren derselben Klasse unter zwei Token mit useClass entstehen zwei Singleton-Instanzen; verwenden Sie useExisting, wenn Sie nur einen Alias möchten.
  • Abstraktions-Token ermöglichen es Ihnen, Implementierungen und Mocks an einer Stelle auszutauschen; definieren Sie sie als gemeinsame Konstanten oder Symbole.
  • Falls ein Token in einem anderen Modul nicht gelöst werden kann, prüfen Sie zuerst exports und imports.
  • Für jede Injektion stellen Sie sich drei Fragen: Was ist das Token, welche Implementierung steht dahinter, und handelt es sich um eine separate Registrierung oder einen Alias?
  • Verwandte Literatur

  • Entwurf für fehlgeschlagene Anfragen in Angular: Zustände, Interceptor und Wiederholungsversuche — Erfahren Sie, wie Sie HTTP-Fehler in Angular klassifizieren, den Ladezustand bereinigen, die Verarbeitung in Interceptors zentralisieren, sicher erneut versuchen und Benutzern Nachrichten anzeigen können, auf die sie reagieren können.
  • Das Verständnis der SOLID-Prinzipien durch praktische Codebeispiele — Dieser Leitfaden erläutert alle fünf SOLID-Prinzipien anhand konkreter Codebeispiele und zeigt, wie sie in echten Projekten sowie React-Anwendungen angewandt werden.
  • Laravel oder NestJS? Abwägung von Architektur, Geschwindigkeit und Teamzusammenpassung — Ein praktischer Vergleich von Laravel und NestJS, der Architektur, ORMs, Standard-Sicherheitsmaßnahmen, Laufzeit- und Ladezeiten, die Lernkurve sowie die Anwendungsfälle beider Frameworks behandelt.
  • Sechs DDD-Regeln zur Strukturierung von Domänen in NestJS-Anwendungen — Erfahren Sie sechs praktische Regeln des domain-getriebenen Designs zur Organisation von NestJS-Modulen, Entitäten und Ereignissen, damit Funktionen isoliert und wartbar bleiben.
  • Wie man Claude Code sein NestJS-Monorepo beibringt: Routing, Regeln und Fähigkeiten — Wie man CLAUDE.md, Regeln, Fähigkeiten und Berechtigungen konfiguriert, damit Claude Code den Code in den richtigen NestJS-Dienst platziert und die Konventionen des Teams befolgt.