Dependency Injection in TypeScript-Projekten umsetzen
<T>
type
TypeScript · Dependency Injection · Testing · Architecture
Dependency Injection in TypeScript-Projekten umsetzen
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.

13 Min. Lesezeit Constructor Injection · Interfaces · Testing InversifyJS · tsyringe · Magento-Vergleich

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.

11. FAQ: Dependency Injection in TypeScript

1Was ist der Unterschied zwischen Dependency Injection und einem DI-Container?
Dependency Injection ist das Entwurfsmuster selbst. Ein DI-Container ist ein optionales Werkzeug, das die Verdrahtung automatisch über Decorators und Reflection übernimmt, wenn der Objektgraph groß genug dafür ist.
2Warum ist Constructor Injection die bevorzugte DI-Methode in TypeScript?
Sie garantiert über den Compiler, dass eine Instanz nie ohne ihre Abhängigkeiten existiert, kommt ohne Framework aus und nutzt TypeScripts Parameter-Properties.
3Muss ich für Dependency Injection immer Interfaces verwenden?
Nicht zwingend, aber ohne Interface verliert man die Austauschbarkeit von Implementierungen für Tests und alternative Anbieter, den zentralen Vorteil von DI.
4Wie teste ich Code, der Dependency Injection nutzt?
Über eine Testdouble-Klasse, die dasselbe Interface implementiert, etwa MockPaymentGateway statt StripePaymentGateway, injiziert über denselben Konstruktor.
5Was ist ein Composition Root?
Die eine zentrale Stelle im Code, meist der Einstiegspunkt, an der alle konkreten Klassen instanziiert und zu einem vollständigen Objektgraphen zusammengesteckt werden.
6Wann lohnt sich InversifyJS oder tsyringe?
Ab einer bestimmten Anzahl an Services mit unterschiedlichen Lebenszyklen und wiederkehrenden Abhängigkeiten, wenn manuelles Wiring unhandlich wird.
7Wie unterscheidet sich TypeScript-DI von Magentos di.xml?
di.xml definiert Preferences, die der ObjectManager beim Type-Hint automatisch auflöst. Ein TypeScript-Container übernimmt dieselbe Aufgabe über container.bind() statt XML.
8Wie unterscheidet sich TypeScript-DI von Symfonys Service Container?
Symfonys Autowiring erkennt Typehints automatisch. tsyringe funktioniert mit container.resolve() nach demselben Prinzip, nur mit Decorators statt YAML-Konfiguration.
9Brauche ich reflect-metadata für Dependency Injection in TypeScript?
Nur bei Decorator-basierten Containern wie InversifyJS oder tsyringe. Reines Constructor Injection mit Composition Root benötigt weder reflect-metadata noch besondere Compiler-Flags.
10Ist Dependency Injection auch für kleine TypeScript-Projekte sinnvoll?
Ja, aber ohne Container. Constructor Injection mit Interfaces und einem einfachen Composition Root verbessert Testbarkeit und Struktur bereits in kleinen Projekten.