Dependency Injection in Vanilla JavaScript: Lose Kopplung ohne Framework
AI generated
JS
() =>
JavaScript · Dependency Injection · Inversion of Control · Framework agnostisch
Dependency Injection in Vanilla JavaScript
Lose Kopplung ohne Framework

Dependency Injection kehrt die Kontrolle über die Erzeugung von Abhängigkeiten um, statt jede Klasse ihre eigenen Kollaborateure selbst instanziieren zu lassen. Ohne Angular oder NestJS zeigt dieser Artikel, wie Constructor Injection, ein minimaler DI-Container und saubere Lifecycle-Verwaltung in reinem JavaScript lose Kopplung und einfache Testbarkeit ermöglichen.

19 Min. Lesezeit Constructor Injection · DI-Container · Singleton · Mocking Framework-unabhängig

1. Was Dependency Injection wirklich löst

Dependency Injection kehrt die Kontrolle über die Erzeugung von Abhängigkeiten um: statt dass eine Klasse ihre Kollaborateure selbst mit new instanziiert, erhält sie diese von außen übergeben. Dieses Prinzip, bekannt als Inversion of Control, entkoppelt eine Klasse von den konkreten Implementierungen ihrer Abhängigkeiten und macht sie damit unabhängig von Details wie einer konkreten Datenbankverbindung oder einem bestimmten HTTP-Client.

Ohne Dependency Injection verstreut sich das Wissen über konkrete Implementierungen über die gesamte Codebasis: jede Klasse, die einen Logger braucht, importiert und instanziiert ihn selbst, was einen Austausch der Logger-Implementierung, etwa für Tests, zu einer mühsamen Suche über viele Dateien macht. Mit Dependency Injection wird der Logger von außen übergeben, ein Test kann einen Mock-Logger einsetzen, ohne den Code der zu testenden Klasse zu verändern.

Anders als in vielen Java- oder C#-Ökosystemen ist Dependency Injection in JavaScript selten fest in ein Framework wie Angular oder NestJS eingebaut, sondern lässt sich als eigenständiges Muster in jeder Codebasis nachbauen. Dieser Artikel zeigt den Aufbau von Constructor Injection über einen minimalen DI-Container bis zu Lifecycle-Verwaltung und Testbarkeit, komplett framework-unabhängig.

2. Constructor Injection: der einfachste Mechanismus

Constructor Injection ist die einfachste Form der Dependency Injection: eine Klasse deklariert ihre Abhängigkeiten als Konstruktor-Parameter, statt sie intern zu erzeugen. Der Aufrufer, der die Klasse instanziiert, übergibt konkrete Implementierungen dieser Abhängigkeiten. Diese einfache Umkehrung macht die Klasse selbst testbar, weil ein Test beliebige Implementierungen, etwa Mocks oder Stubs, an derselben Stelle einsetzen kann, an der die Produktionsumgebung echte Implementierungen übergibt.

Ein wichtiges Detail bei Constructor Injection: die Klasse sollte nur von Abstraktionen abhängen, nicht von konkreten Implementierungen, ein Prinzip aus SOLID, bekannt als Dependency Inversion. In JavaScript ohne Interfaces bedeutet das praktisch, dass eine Klasse nur die Methoden nutzt, die sie tatsächlich braucht, statt eine konkrete Klasse als Typ zu erwarten. Diese Disziplin macht Dependency Injection auch ohne ein formales Typsystem wirksam.


// Constructor injection: dependencies passed in, not created internally
class OrderService {
  #logger;
  #paymentGateway;

  constructor(logger, paymentGateway) {
    this.#logger = logger;
    this.#paymentGateway = paymentGateway;
  }

  async placeOrder(order) {
    this.#logger.info(`Placing order ${order.id}`);
    await this.#paymentGateway.charge(order.total);
  }
}

// Production wiring
const service = new OrderService(consoleLogger, stripeGateway);

// Test wiring, no changes to OrderService itself needed
const testService = new OrderService(fakeLogger, fakePaymentGateway);

3. Ein minimaler DI-Container von Grund auf

Mit wachsender Anzahl an Abhängigkeiten wird manuelles Wiring, das Zusammensetzen aller Konstruktor-Argumente an einer zentralen Stelle, schnell unübersichtlich. Ein DI-Container automatisiert dieses Wiring: er verwaltet eine Registry von Factory-Funktionen unter benannten Tokens und löst Abhängigkeiten rekursiv auf, wenn ein Service angefordert wird. Der Container selbst ist dabei nur ein einfaches Objekt mit einer register()- und einer resolve()-Methode.

Der entscheidende Vorteil eines DI-Containers gegenüber manuellem Wiring: neue Abhängigkeiten werden an einer Stelle registriert, nicht an jeder Stelle im Code angepasst, an der die betroffene Klasse instanziiert wird. Diese zentrale Registry macht Dependency Injection auch in großen Codebasen mit hunderten Services wartbar, ohne dass jede Änderung an einer Abhängigkeit Kaskaden von Anpassungen nach sich zieht.


// Minimal DI container: registry of factories, resolved recursively
class Container {
  #factories = new Map();

  register(token, factory) {
    this.#factories.set(token, factory);
  }

  resolve(token) {
    const factory = this.#factories.get(token);
    if (!factory) throw new Error(`No registration found for "${token}"`);
    return factory(this); // pass the container so factories can resolve their own deps
  }
}

const container = new Container();
container.register('logger', () => new ConsoleLogger());
container.register('paymentGateway', () => new StripeGateway());
container.register('orderService', (c) =>
  new OrderService(c.resolve('logger'), c.resolve('paymentGateway'))
);

const orderService = container.resolve('orderService');

4. Singleton vs. Transient: Lebenszyklen verwalten

Nicht jede Abhängigkeit sollte bei jeder Auflösung neu erzeugt werden: eine Datenbankverbindung soll typischerweise als Singleton existieren, eine einzige Instanz, die über die gesamte Anwendungslaufzeit wiederverwendet wird, während ein Request-spezifisches Objekt als Transient bei jeder Auflösung neu erzeugt werden muss. Ein ausgereifter DI-Container unterscheidet diese beiden Lebenszyklen explizit bei der Registrierung.

Die Implementierung eines Singleton-Lebenszyklus cached das Ergebnis der Factory-Funktion beim ersten Aufruf und gibt bei jeder weiteren Anfrage dieselbe Instanz zurück, statt die Factory erneut auszuführen. Transient-Registrierungen rufen die Factory bei jeder resolve()-Anfrage neu auf. Diese Unterscheidung ist bei Dependency Injection in zustandsbehafteten Services entscheidend, ein gemeinsamer Cache-Service sollte als Singleton existieren, ein Formular-Validator dagegen oft als Transient, um keinen Zustand zwischen unabhängigen Aufrufen zu teilen.


// Container with explicit singleton vs. transient lifecycles
class LifecycleContainer {
  #registrations = new Map();
  #singletonInstances = new Map();

  registerSingleton(token, factory) {
    this.#registrations.set(token, { factory, lifecycle: 'singleton' });
  }

  registerTransient(token, factory) {
    this.#registrations.set(token, { factory, lifecycle: 'transient' });
  }

  resolve(token) {
    const registration = this.#registrations.get(token);
    if (!registration) throw new Error(`No registration for "${token}"`);

    if (registration.lifecycle === 'singleton') {
      if (!this.#singletonInstances.has(token)) {
        this.#singletonInstances.set(token, registration.factory(this));
      }
      return this.#singletonInstances.get(token);
    }
    return registration.factory(this); // transient: always fresh
  }
}

5. Interfaces in JavaScript: Duck Typing und Tokens

JavaScript hat keine nativen Interfaces, was Dependency Injection ohne TypeScript auf den ersten Blick erschwert, weil die klassische Registrierung nach Interface-Typ nicht möglich ist. In der Praxis funktioniert Duck Typing als pragmatischer Ersatz: eine Abhängigkeit muss nur die tatsächlich genutzten Methoden bereitstellen, unabhängig von ihrer Klassenherkunft. Ein Test-Double muss also nur charge() implementieren, um als Payment Gateway zu funktionieren, ganz ohne formale Interface-Deklaration.

Für die Registrierung selbst haben sich zwei Ansätze etabliert: String-basierte Tokens wie 'paymentGateway' sind einfach, aber anfällig für Tippfehler und Namenskollisionen. Symbol-basierte Tokens, etwa Symbol('PaymentGateway'), garantieren durch die Eindeutigkeit von Symbolen Kollisionsfreiheit, selbst wenn zwei Module denselben beschreibenden Namen wählen. Für größere Anwendungen mit vielen Registrierungen ist die Symbol-Variante die robustere Wahl bei Dependency Injection ohne TypeScript.

6. Zirkuläre Abhängigkeiten erkennen und auflösen

Eine zirkuläre Abhängigkeit entsteht, wenn Service A Service B benötigt, der wiederum Service A benötigt, ein Zustand, den ein naiver DI-Container mit unendlicher Rekursion und einem Stack Overflow beantwortet. Ein robuster Container erkennt diesen Fall, indem er während der Auflösung eines Tokens dieses Token in einem temporären Set als "in Bearbeitung" markiert und bei erneutem Antreffen desselben Tokens einen klaren Fehler wirft, statt endlos zu rekursieren.

Die nachhaltigere Lösung ist jedoch architektonisch: eine zirkuläre Abhängigkeit zwischen zwei Services deutet meist auf eine unsaubere Verantwortungsteilung hin, die sich durch Extraktion einer dritten, gemeinsamen Abstraktion auflösen lässt. Dependency Injection macht solche Zirkularitäten sichtbar, die in manuell verdrahtetem Code oft erst zur Laufzeit als schwer nachvollziehbarer Bug auffallen würden, statt schon beim Container-Setup als klarer Fehler.


// Circular dependency detection during resolution
class SafeContainer {
  #factories = new Map();
  #resolving = new Set(); // tracks tokens currently being resolved

  register(token, factory) {
    this.#factories.set(token, factory);
  }

  resolve(token) {
    if (this.#resolving.has(token)) {
      throw new Error(`Circular dependency detected while resolving "${token}"`);
    }
    const factory = this.#factories.get(token);
    if (!factory) throw new Error(`No registration for "${token}"`);

    this.#resolving.add(token);
    try {
      return factory(this);
    } finally {
      this.#resolving.delete(token);
    }
  }
}

7. Dependency Injection in Testumgebungen

Der größte praktische Nutzen von Dependency Injection zeigt sich beim Testen: eine Klasse, deren Abhängigkeiten über den Konstruktor kommen, lässt sich isoliert testen, indem Mocks oder Stubs anstelle echter Implementierungen übergeben werden, ohne Module-Mocking-Tricks wie jest.mock() auf Modulebene zu benötigen. Ein Test für OrderService übergibt einen Fake-Logger, der Aufrufe aufzeichnet, und ein Fake-Payment-Gateway, das immer erfolgreich antwortet, ganz ohne echte Netzwerkverbindung.

Diese Testbarkeit ist kein Nebeneffekt, sondern der eigentliche architektonische Gewinn von Dependency Injection: Code, der sich schwer testen lässt, verrät meist eine versteckte, fest verdrahtete Abhängigkeit, die besser über den Konstruktor injiziert werden sollte. Teams, die Dependency Injection konsequent anwenden, berichten regelmäßig von deutlich saubereren Unit-Tests, weil jede Abhängigkeit explizit sichtbar und austauschbar ist, statt implizit irgendwo im Klassenkörper zu stecken.


// Testing with injected fakes, no module mocking required
class FakeLogger {
  entries = [];
  info(message) { this.entries.push(message); }
}

class FakePaymentGateway {
  charged = [];
  async charge(amount) { this.charged.push(amount); }
}

// Unit test, no real network calls, no jest.mock() needed
const fakeLogger = new FakeLogger();
const fakeGateway = new FakePaymentGateway();
const service = new OrderService(fakeLogger, fakeGateway);

await service.placeOrder({ id: 1, total: 49.99 });

console.assert(fakeGateway.charged[0] === 49.99, 'Charge amount mismatch');
console.assert(fakeLogger.entries.length === 1, 'Logger should record one entry');

8. Dependency Injection ohne Container

Nicht jede Anwendung braucht einen vollständigen DI-Container: bei kleinen bis mittleren Codebasen reicht oft schlichtes manuelles Wiring an einer zentralen Composition-Root, einer einzigen Stelle im Code, an der alle Abhängigkeiten von Hand zusammengesetzt werden. Dieser Ansatz behält den Kernvorteil von Dependency Injection, lose Kopplung und Testbarkeit, ohne die zusätzliche Komplexität eines generischen Container-Frameworks.

Factory Functions und Higher-Order Functions bieten eine funktionale Alternative zu klassenbasierter Dependency Injection: statt eines Konstruktors nimmt eine Factory-Funktion Abhängigkeiten als Parameter entgegen und gibt ein Objekt mit gebundenen Methoden zurück, ein Muster, das in funktionalem JavaScript verbreitet ist und ganz ohne class-Syntax auskommt. Beide Ansätze, Composition Root und Factory Functions, sind für kleinere Projekte oft die pragmatischere Wahl als ein vollständiger DI-Container.

9. DI-Ansätze im Vergleich

Die Wahl zwischen einem DI-Container, einem Service Locator und manuellem Wiring hängt von Projektgröße und Team-Konventionen ab, wobei ein Service Locator einige der Vorteile von Dependency Injection wieder aufhebt, indem er Abhängigkeiten aus einer globalen Registry statt über den Konstruktor bezieht.

Ansatz Explizitheit der Abhängigkeiten Testbarkeit Bester Einsatz
Manuelles Wiring Vollständig explizit Sehr gut Kleine bis mittlere Codebasen
DI-Container Explizit über Registry Sehr gut Große Codebasen, viele Services
Service Locator Versteckt im Klassenkörper Schlechter Meist zu vermeiden
Factory Functions Explizit über Parameter Sehr gut Funktionaler Code ohne Klassen

Ein Service Locator, bei dem eine Klasse ihre Abhängigkeiten selbst aus einer globalen Registry abruft, statt sie über den Konstruktor zu erhalten, wird in der Fachliteratur häufig als Anti-Pattern gegenüber echter Dependency Injection beschrieben, weil die Abhängigkeiten dann versteckt im Klassenkörper stecken, statt in der Konstruktor-Signatur sichtbar zu sein. Für die meisten JavaScript-Projekte bleibt echte Constructor-basierte Dependency Injection, mit oder ohne Container, die nachhaltigere Wahl.

Mironsoft

Architektur-Refactoring, Testbarkeit und lose Kopplung im Backend und Frontend

Code, der sich nicht testen lässt, weil alles fest verdrahtet ist?

Wir refactorn eng gekoppelten Code auf Dependency Injection um, mit klaren Constructor-Signaturen, einem passenden Lifecycle-Modell und deutlich einfacheren Unit-Tests.

Kopplungs-Audit

Analyse bestehender Klassen auf fest verdrahtete, schwer testbare Abhängigkeiten

DI-Einführung

Umbau auf Constructor Injection, mit oder ohne eigenen DI-Container

Testbarkeit

Aufbau von Unit-Tests mit Fakes und Mocks statt Module-Mocking-Hacks

10. Zusammenfassung

Dependency Injection kehrt die Erzeugung von Abhängigkeiten um: Klassen deklarieren, was sie brauchen, statt es selbst zu erzeugen, und erhalten es über den Konstruktor. Constructor Injection ist der einfachste Einstieg, ein minimaler DI-Container automatisiert das Wiring bei wachsender Anzahl an Services, und die Unterscheidung zwischen Singleton- und Transient-Lebenszyklen verhindert unnötig geteilten oder unnötig neu erzeugten Zustand.

Der größte praktische Gewinn zeigt sich beim Testen: injizierte Abhängigkeiten lassen sich durch Fakes ersetzen, ohne Module-Mocking-Hacks, was Unit-Tests deutlich einfacher und schneller macht. Ob mit vollständigem Container, einer zentralen Composition Root oder funktionalen Factory Functions umgesetzt, Dependency Injection bleibt in JavaScript ein eigenständiges, framework-unabhängiges Architekturmuster, das lose Kopplung strukturell erzwingt.

Dependency Injection in Vanilla JavaScript — Das Wichtigste auf einen Blick

Constructor Injection

Abhängigkeiten kommen als Konstruktor-Parameter, nicht durch internes new.

DI-Container

Registry aus Factory-Funktionen, löst Abhängigkeiten rekursiv über resolve() auf.

Lifecycle

Singleton für geteilten Zustand, Transient für frische Instanzen bei jeder Auflösung.

Testbarkeit

Injizierte Fakes ersetzen echte Implementierungen, ganz ohne Module-Mocking.

11. FAQ: Dependency Injection in Vanilla JavaScript

1Was ist Dependency Injection?
Abhaengigkeiten werden von aussen uebergeben statt selbst erzeugt, meist per Konstruktor.
2Braucht man ein Framework dafuer?
Nein, Constructor Injection funktioniert mit reinem JavaScript.
3Singleton vs. Transient?
Singleton wird wiederverwendet, Transient bei jeder Anfrage neu erzeugt.
4Interfaces ohne TypeScript?
Ueber Duck Typing, nur genutzte Methoden muessen vorhanden sein.
5Zirkulaere Abhaengigkeiten erkennen?
Ueber ein Set aktuell aufgeloester Tokens, das bei Wiederholung einen Fehler wirft.
6Warum leichter testbar?
Abhaengigkeiten lassen sich durch Fakes ersetzen, ohne Modul-Mocking.
7Service Locator = Dependency Injection?
Nein, Service Locator versteckt Abhaengigkeiten im Klassenkoerper.
8Braucht jede App einen Container?
Nein, manuelles Wiring an einer Composition Root reicht oft.
9Was sind Factory Functions?
Funktionale Alternative, Abhaengigkeiten als Parameter statt Konstruktor.
10Lohnt sich DI in kleinen Projekten?
Ja, zumindest Constructor Injection ohne Container.