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.
Inhaltsverzeichnis
- 1. Was Dependency Injection wirklich löst
- 2. Constructor Injection: der einfachste Mechanismus
- 3. Ein minimaler DI-Container von Grund auf
- 4. Singleton vs. Transient: Lebenszyklen verwalten
- 5. Interfaces in JavaScript: Duck Typing und Tokens
- 6. Zirkuläre Abhängigkeiten erkennen und auflösen
- 7. Dependency Injection in Testumgebungen
- 8. Dependency Injection ohne Container
- 9. DI-Ansätze im Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.