Von Constructor Injection bis zum DI-Container
Wer aus der Magento- oder Symfony-Welt kommt, kennt Dependency Injection bereits aus di.xml und dem Service Container. Dieser Artikel zeigt, wie sich dasselbe Prinzip in TypeScript-Projekten umsetzen lässt, mit Constructor Injection als Standardpattern, Interfaces als Verträgen für testbaren Code und der Entscheidung, wann ein DI-Container wie InversifyJS wirklich nötig ist.
Inhaltsverzeichnis
- 1. Was ist Dependency Injection und welches Problem löst sie?
- 2. Constructor Injection als einfachstes Pattern in TypeScript
- 3. Interfaces als Injection-Verträge
- 4. Praxisbeispiel: OrderService und PaymentGateway
- 5. Testbarkeit: Mocking und Testdoubles
- 6. Manuelles Wiring: Poor Man's DI und der Composition Root
- 7. Wann lohnt sich ein DI-Container wie InversifyJS oder tsyringe?
- 8. Die Brücke zu PHP: Magento ObjectManager und Symfony Service Container
- 9. Manuelles Wiring vs. DI-Container im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was ist Dependency Injection und welches Problem löst sie?
Dependency Injection (DI) ist ein Entwurfsmuster, bei dem eine Klasse ihre Abhängigkeiten nicht selbst erzeugt, sondern von außen übergeben bekommt. Anstatt dass ein OrderService intern new StripePaymentGateway() aufruft, erhält er eine fertige Instanz über den Konstruktor. Dieses Prinzip heißt Inversion of Control: Die Kontrolle darüber, welche konkrete Implementierung verwendet wird, liegt nicht mehr in der Klasse selbst, sondern beim Aufrufer. Das Ergebnis ist lose gekoppelter Code, der sich austauschen, erweitern und unabhängig testen lässt, ohne die Klasse selbst anzufassen.
Ohne DI sammeln sich in gewachsenen TypeScript-Projekten schnell versteckte Abhängigkeiten: Ein Service instanziiert intern einen HTTP-Client, eine Datenbankverbindung oder einen Logger, und plötzlich benötigt jeder Unit-Test eine echte Netzwerkverbindung oder Datenbank. DI löst genau dieses Problem, indem es Abhängigkeiten explizit macht und austauschbar hält. Für PHP-Entwickler, die Magento oder Symfony gewohnt sind, ist das kein neues Konzept, sondern dieselbe Idee, die dort seit Jahren über den Service Container läuft, nur ohne XML-Konfigurationsdatei.
2. Constructor Injection als einfachstes Pattern in TypeScript
Von den verschiedenen DI-Spielarten, Constructor Injection, Property Injection und Method Injection, ist Constructor Injection in TypeScript der idiomatischste und am wenigsten überraschende Ansatz. Alle Abhängigkeiten werden als Parameter im Konstruktor deklariert und über den private readonly-Modifier direkt als Klasseneigenschaften gespeichert. Das TypeScript-Compiler-Feature der Parameter-Properties spart dabei die sonst nötige doppelte Deklaration von Eigenschaft und Zuweisung im Konstruktorkörper.
Der entscheidende Vorteil gegenüber Property Injection, bei der Abhängigkeiten nachträglich per Setter zugewiesen werden: Eine Instanz kann nie in einem unvollständigen Zustand existieren. Sobald der Konstruktor durchläuft, sind alle Abhängigkeiten garantiert vorhanden, und der Compiler erzwingt das über die Typprüfung. Kein Framework, kein Decorator und kein Container ist dafür notwendig, reines TypeScript reicht vollständig aus. Genau das macht Constructor Injection zum richtigen Startpunkt für praktisch jedes Projekt, bevor über einen DI-Container überhaupt nachgedacht wird.
3. Interfaces als Injection-Verträge
Damit Constructor Injection ihren vollen Nutzen entfaltet, sollten Abhängigkeiten nicht als konkrete Klassen, sondern als Interfaces deklariert werden. Ein OrderService, der ein PaymentGateway-Interface statt einer konkreten StripePaymentGateway-Klasse injiziert bekommt, kennt nur den Vertrag: eine charge()-Methode mit bestimmter Signatur. Welche konkrete Implementierung diesen Vertrag erfüllt, ob Stripe, PayPal oder ein Test-Double, ist für den OrderService vollständig irrelevant.
Dieser Vertrag ist die Grundlage für Testbarkeit: In Tests wird einfach eine andere Implementierung desselben Interfaces eingesetzt, ohne dass Testcode und produktiver Code sich gegenseitig kennen müssen. TypeScripts strukturelles Typsystem macht das besonders angenehm, da eine Klasse ein Interface nicht explizit über implements deklarieren muss, um kompatibel zu sein, solange die Form passt. In der Praxis empfiehlt sich implements trotzdem explizit, weil der Compiler dann sofort meldet, wenn eine Implementierung vom Vertrag abweicht.
4. Praxisbeispiel: OrderService und PaymentGateway
Ein konkretes Beispiel macht das Muster greifbar: Ein OrderService soll Bestellungen abwickeln und dafür eine Zahlung auslösen. Statt die Zahlungsabwicklung selbst zu implementieren, definiert der Service lediglich, was er von einem Zahlungsanbieter erwartet, nämlich das PaymentGateway-Interface mit einer charge()-Methode. Die konkrete StripePaymentGateway-Klasse implementiert dieses Interface und wird dem OrderService von außen übergeben.
Der Code unten zeigt genau dieses Setup. Wichtig ist, dass OrderService keinerlei Kenntnis von Stripe, HTTP-Requests oder API-Keys hat, all das steckt ausschließlich in der konkreten Implementierung. Dieser Schnitt zwischen Vertrag und Implementierung erlaubt es später, ohne jede Änderung an OrderService, einen zweiten Zahlungsanbieter wie PayPal zu ergänzen oder den Anbieter je nach Umgebung, Land oder Kundensegment auszutauschen.
// Contract that any payment gateway implementation must fulfill
interface PaymentGateway {
charge(amountCents: number, currency: string): Promise<boolean>;
}
// OrderService depends on the PaymentGateway abstraction, not a concrete class
class OrderService {
constructor(private readonly paymentGateway: PaymentGateway) {}
async placeOrder(orderId: string, amountCents: number): Promise<void> {
const success = await this.paymentGateway.charge(amountCents, 'EUR');
if (!success) {
throw new Error(`Payment failed for order ${orderId}`);
}
console.log(`Order ${orderId} placed successfully`);
}
}
// Concrete implementation used in production
class StripePaymentGateway implements PaymentGateway {
async charge(amountCents: number, currency: string): Promise<boolean> {
// Real Stripe API call would happen here
return true;
}
}
5. Testbarkeit: Mocking und Testdoubles
Der eigentliche Gewinn von Dependency Injection zeigt sich beim Testen. Weil OrderService ausschließlich vom PaymentGateway-Interface abhängt, lässt sich im Unit-Test eine MockPaymentGateway-Klasse einsetzen, die dasselbe Interface implementiert, aber keinerlei echte Netzwerkaufrufe tätigt. Der Test bleibt dadurch schnell, deterministisch und unabhängig von externen Diensten wie einem Stripe-Sandbox-Account oder einer Internetverbindung.
Testdoubles wie MockPaymentGateway lassen sich zusätzlich so bauen, dass sie aufgezeichnete Aufrufe, simulierte Fehlerfälle oder feste Rückgabewerte bereitstellen, wie im Beispiel unten mit dem shouldSucceed-Flag. Das erlaubt es, sowohl den Erfolgsfall als auch Fehlerpfade wie eine abgelehnte Zahlung gezielt zu testen, ohne dass die reale Zahlungsinfrastruktur diese Zustände überhaupt herbeiführen könnte. Bibliotheken wie Jest oder Vitest bieten mit jest.mock() beziehungsweise vi.mock() zusätzliche Abkürzungen, aber das zugrunde liegende Prinzip bleibt dasselbe: Ersetzbarkeit durch einen gemeinsamen Vertrag.
// Test double implementing the same contract as the real gateway
class MockPaymentGateway implements PaymentGateway {
public calls: Array<{ amount: number; currency: string }> = [];
private readonly shouldSucceed: boolean;
constructor(shouldSucceed = true) {
this.shouldSucceed = shouldSucceed;
}
async charge(amountCents: number, currency: string): Promise<boolean> {
this.calls.push({ amount: amountCents, currency });
return this.shouldSucceed;
}
}
// Unit test: no network call, no real Stripe account needed
test('placeOrder throws when payment fails', async () => {
const failingGateway = new MockPaymentGateway(false);
const orderService = new OrderService(failingGateway);
await expect(orderService.placeOrder('order-1', 4999))
.rejects.toThrow('Payment failed for order order-1');
expect(failingGateway.calls).toHaveLength(1);
});
6. Manuelles Wiring: Poor Man's DI und der Composition Root
Nicht jedes Projekt braucht einen DI-Container. Für kleine bis mittelgroße Anwendungen reicht manuelles Wiring, umgangssprachlich auch „Poor Man's DI" genannt, vollkommen aus: An einer einzigen Stelle im Code, dem sogenannten Composition Root, werden alle konkreten Klassen instanziiert und über Konstruktoren zusammengesteckt. Diese Stelle liegt typischerweise ganz am Rand der Anwendung, etwa direkt im Einstiegspunkt index.ts oder in einer dedizierten composition-root.ts-Datei.
Der Vorteil dieses Ansatzes: Es gibt keine versteckte Magie, keinen Reflection-Mechanismus und keine Laufzeit-Bibliothek, die den Objektgraphen zusammensetzt. Jeder Entwickler kann den kompletten Aufbau der Anwendung nachvollziehen, indem er eine einzige Datei liest. Für Projekte mit einer überschaubaren Anzahl an Services, häufig unter zwanzig bis dreißig Klassen, ist das nicht nur ausreichend, sondern oft sogar wartungsfreundlicher als ein Container, weil TypeScript-Fehler bei falscher Verdrahtung schon zur Compile-Zeit auffallen, statt erst zur Laufzeit im Container.
// composition-root.ts: the single place where concrete classes meet
function createApp() {
const paymentGateway: PaymentGateway = new StripePaymentGateway();
const inventoryService: InventoryService = new WarehouseInventoryService();
const orderService = new OrderService(paymentGateway, inventoryService);
const orderController = new OrderController(orderService);
return { orderController };
}
// index.ts: the entry point wires nothing itself, it just calls the root
const { orderController } = createApp();
orderController.listen(3000);
7. Wann lohnt sich ein DI-Container wie InversifyJS oder tsyringe?
Ab einer bestimmten Projektgröße wird manuelles Wiring unhandlich: Wenn ein Objektgraph aus hunderten Services besteht, viele davon mit unterschiedlichen Lebenszyklen wie Singleton oder Request-Scoped, und Abhängigkeiten sich mehrfach quer durch die Anwendung wiederholen, übernimmt ein DI-Container wie InversifyJS oder tsyringe diese Verdrahtung automatisch. Über Decorators wie @injectable() und @inject() markiert man Klassen und ihre Abhängigkeiten, der Container löst den kompletten Graphen zur Laufzeit auf.
Der Preis dafür: Decorators benötigen reflect-metadata und entsprechende TypeScript-Compiler-Flags, Fehler in der Verdrahtung fallen oft erst zur Laufzeit statt zur Compile-Zeit auf, und neue Teammitglieder müssen zusätzlich das Container-Framework verstehen. Ein Container lohnt sich also dort, wo die Komplexität des Objektgraphen die Komplexität des Frameworks übersteigt, typischerweise in großen Backend-Services, nicht aber in kleinen CLI-Tools, Build-Skripten oder überschaubaren Frontend-Widgets, wo Constructor Injection plus Composition Root völlig ausreicht.
import { injectable, inject, Container } from 'inversify';
const TYPES = {
PaymentGateway: Symbol.for('PaymentGateway'),
OrderService: Symbol.for('OrderService'),
};
@injectable()
class StripePaymentGateway implements PaymentGateway {
async charge(amountCents: number, currency: string): Promise<boolean> {
return true;
}
}
@injectable()
class OrderService {
constructor(
@inject(TYPES.PaymentGateway) private readonly paymentGateway: PaymentGateway
) {}
async placeOrder(orderId: string, amountCents: number): Promise<void> {
await this.paymentGateway.charge(amountCents, 'EUR');
}
}
const container = new Container();
container.bind<PaymentGateway>(TYPES.PaymentGateway).to(StripePaymentGateway).inSingletonScope();
container.bind<OrderService>(TYPES.OrderService).to(OrderService);
const orderService = container.get<OrderService>(TYPES.OrderService);
8. Die Brücke zu PHP: Magento ObjectManager und Symfony Service Container
Für Entwickler, die aus Magento oder Symfony kommen, ist all das kein neues Konzept. Magentos di.xml definiert Preferences, also welche konkrete Klasse für ein Interface verwendet wird, und der ObjectManager löst diese Bindung automatisch auf, sobald ein Interface im Konstruktor type-gehintet ist. Virtual Types und Plugins erweitern dieses System um Varianten derselben Klasse mit unterschiedlicher Konfiguration und um nachträgliche Verhaltensänderungen, ohne die Originalklasse zu verändern. Genau dieselbe Rolle übernimmt container.bind() in InversifyJS.
Symfony geht mit Autowiring noch einen Schritt weiter: Der Service Container erkennt anhand der Typehints im Konstruktor automatisch, welcher Service injiziert werden soll, ganz ohne explizite Konfiguration, sofern es eine eindeutige Zuordnung gibt. container.resolve() in tsyringe funktioniert nach demselben Prinzip. Wer also bereits di.xml-Preferences oder Symfonys Autowiring verstanden hat, muss für TypeScript-DI kein neues mentales Modell aufbauen, sondern lediglich Namen und Syntax übertragen: Interface bleibt Interface, Constructor Injection bleibt Constructor Injection, nur die Konfigurationssprache wechselt von XML und YAML zu TypeScript-Decorators.
// TypeScript container binding: swap the interface implementation
container.bind<PaymentGateway>(TYPES.PaymentGateway).to(PaypalPaymentGateway);
// The equivalent Magento concept lives in di.xml as a preference:
//
// <preference for="App\Api\PaymentGatewayInterface"
// type="App\Model\PaypalPaymentGateway" />
//
// Magento's ObjectManager resolves the interface to the concrete class
// automatically wherever it is type hinted in a constructor, just like
// the TypeScript container resolves TYPES.PaymentGateway above.
// Symfony's service container does the same via autowiring: a type
// hinted constructor argument is resolved from the container without
// any manual "new" call, comparable to container.resolve() in tsyringe.
9. Manuelles Wiring vs. DI-Container im direkten Vergleich
Die Entscheidung zwischen manuellem Wiring und einem DI-Container hängt selten von persönlichem Geschmack ab, sondern von der Größe und den Anforderungen des Projekts. Die folgende Übersicht fasst die wichtigsten Entscheidungskriterien zusammen.
| Kriterium | Manuelles Wiring | DI-Container |
|---|---|---|
| Kleine App, wenige Services | Empfehlenswert | Unnötiger Overhead |
| Großer Abhängigkeitsgraph | Wird schnell unübersichtlich | Empfehlenswert |
| Bedarf an Decorators/Auto-Wiring | Nicht vorhanden | Kernfunktion |
| Build-/Laufzeit-Overhead | Keiner | reflect-metadata, Decorators nötig |
| Lernkurve im Team | Minimal, reines TypeScript | Zusätzliches Framework-Wissen nötig |
In der Praxis beginnen die meisten TypeScript-Projekte mit manuellem Wiring über einen Composition Root und wechseln erst zu einem Container, wenn der Objektgraph tatsächlich unhandlich wird, nicht vorsorglich am Projektanfang. Dieser pragmatische Ansatz spiegelt genau die Erfahrung wider, die auch viele Magento- und Symfony-Projekte machen: Die Konfiguration wächst mit der Komplexität der Anwendung, nicht umgekehrt.
Mironsoft
TypeScript-Architektur und Dependency Injection für skalierbare Magento- und Headless-Projekte
Dependency Injection professionell umsetzen?
Wir unterstützen euer Team beim Aufbau testbarer TypeScript-Architekturen, von Constructor Injection über Interfaces bis zur Entscheidung für oder gegen einen DI-Container, inklusive Anbindung an bestehende Magento- und Hyvä-Projekte.
DI-Architektur-Review
Bestehenden TypeScript-Code auf Kopplung und Testbarkeit prüfen, Interfaces und Composition Root sauber schneiden
Container-Einführung
InversifyJS oder tsyringe gezielt einführen, wenn der Objektgraph es rechtfertigt, inklusive Team-Onboarding
Test-Setup
Unit-Test-Infrastruktur mit Mocks und Testdoubles für bestehende Services aufbauen
10. Zusammenfassung
Dependency Injection in TypeScript-Projekten folgt demselben Grundprinzip, das PHP-Entwickler aus Magentos di.xml und Symfonys Service Container bereits kennen: Abhängigkeiten werden nicht von einer Klasse selbst erzeugt, sondern von außen über den Konstruktor übergeben. Interfaces fungieren dabei als Verträge, die austauschbare Implementierungen und vor allem testbaren Code ermöglichen, ohne dass Unit-Tests echte Netzwerkverbindungen, Datenbanken oder Drittanbieter-APIs benötigen.
Für die meisten Projekte reicht Constructor Injection kombiniert mit einem manuellen Composition Root vollständig aus, ganz ohne zusätzliches Framework. Ein DI-Container wie InversifyJS oder tsyringe lohnt sich erst, wenn der Objektgraph so groß wird, dass automatisches Auflösen über Decorators den Wartungsaufwand tatsächlich reduziert. Wer diese Entscheidung bewusst und projektabhängig trifft, statt reflexartig einen Container einzuführen, vermeidet unnötige Komplexität und bleibt trotzdem jederzeit in der Lage, bei wachsendem Bedarf nachzurüsten.
Dependency Injection in TypeScript - Das Wichtigste auf einen Blick
Constructor Injection
Abhängigkeiten als Parameter-Properties im Konstruktor deklarieren, kein Framework notwendig.
Interfaces als Verträge
PaymentGateway-Interface statt konkreter Klasse injizieren, Implementierungen frei austauschbar.
Composition Root
Ein zentraler Ort, meist index.ts, verdrahtet alle konkreten Klassen manuell.
DI-Container ab Bedarf
InversifyJS oder tsyringe erst bei großen Objektgraphen mit vielen Lebenszyklen einsetzen.