Zum Inhalt springen

Dependency Injection in der Praxis

Dependency Injection braucht weder Framework noch Decorators — so wendet man sie in TypeScript mit einfachen Mustern an, die Code testbar und flexibel machen.

3 Min. Lesezeit
TypeScript-Code, der Dependency Injection über Konstruktorparameter zeigt

Dependency Injection hat ein Imageproblem. Erwähnt man es, denken Entwickler an Annotation-Suppe im Java-Stil, XML-Konfigurationsdateien und Framework-Magie. In TypeScript ist DI einfach nur das Übergeben von Argumenten an Funktionen. Keine Decorators, keine Container, keine Zeremonie — nur explizite Abhängigkeiten, die Code testbar und komponierbar machen.

Das Problem, das DI löst

Wenn ein Modul seine eigenen Abhängigkeiten erzeugt, wird es unmöglich, es isoliert zu testen oder Implementierungen auszutauschen.

tstypescript
// ❌ Hard-coded dependency — impossible to test without a real database
import { prisma } from "./db";
 
export async function getActiveUsers() {
  return prisma.user.findMany({
    where: { status: "active" },
  });
}
 
// How do you test this without a running database?
// How do you switch to a different ORM?
// How do you mock slow queries in development?
tstypescript
// ✅ Dependency is injected — caller decides what to provide
interface UserRepository {
  findMany(filter: { where: { status: string } }): Promise<User[]>;
}
 
export function createUserService(repo: UserRepository) {
  return {
    async getActiveUsers() {
      return repo.findMany({ where: { status: "active" } });
    },
  };
}
 
// Production: createUserService(prisma.user)
// Tests: createUserService(mockUserRepo)

Die Funktion weiß nicht und kümmert sich nicht darum, ob sie mit Prisma, einem Mock oder einem In-Memory-Array spricht. Das ist das gesamte Wertversprechen.

Drei Muster in TypeScript

Constructor Injection (Klassen)

tstypescript
class OrderService {
  constructor(
    private readonly orderRepo: OrderRepository,
    private readonly paymentGateway: PaymentGateway,
    private readonly emailService: EmailService,
  ) {}
 
  async placeOrder(input: PlaceOrderInput): Promise<Order> {
    const order = await this.orderRepo.create(input);
    await this.paymentGateway.charge(order.total, input.paymentMethod);
    await this.emailService.sendConfirmation(order);
    return order;
  }
}

Factory-Function-Injection (funktionaler Stil)

tstypescript
function createOrderService(deps: {
  orderRepo: OrderRepository;
  paymentGateway: PaymentGateway;
  emailService: EmailService;
}) {
  return {
    async placeOrder(input: PlaceOrderInput): Promise<Order> {
      const order = await deps.orderRepo.create(input);
      await deps.paymentGateway.charge(order.total, input.paymentMethod);
      await deps.emailService.sendConfirmation(order);
      return order;
    },
  };
}

Parameter Injection (am einfachsten)

tstypescript
async function placeOrder(
  input: PlaceOrderInput,
  orderRepo: OrderRepository,
  paymentGateway: PaymentGateway,
) {
  const order = await orderRepo.create(input);
  await paymentGateway.charge(order.total, input.paymentMethod);
  return order;
}

Factory-Funktionen sind der Sweet Spot für die meisten TypeScript-Projekte. Sie geben dir Closures über Abhängigkeiten ohne Klassen-Zeremonie, und sie lassen sich natürlich komponieren.

Abhängigkeiten am Rand verdrahten

Der Composition Root ist der Ort, an dem echte Implementierungen erzeugt und miteinander verdrahtet werden. Er liegt am Einstiegspunkt der Anwendung — einem Server-Bootstrap, einer CLI-Main-Funktion oder einem Test-Setup.

tstypescript
// src/composition-root.ts — the ONE place where real deps are created
import { PrismaClient } from "@prisma/client";
import { StripeGateway } from "./infra/stripe";
import { SendGridEmailService } from "./infra/sendgrid";
import { createOrderService } from "./services/order";
import { createUserService } from "./services/user";
 
const prisma = new PrismaClient();
const paymentGateway = new StripeGateway(process.env.STRIPE_KEY!);
const emailService = new SendGridEmailService(process.env.SENDGRID_KEY!);
 
export const orderService = createOrderService({
  orderRepo: prisma.order,
  paymentGateway,
  emailService,
});
 
export const userService = createUserService(prisma.user);

Route-Handler importieren aus dem Composition Root. Services importieren niemals konkrete Implementierungen direkt.

Testen wird trivial

Mit DI kann jeder Test jede Abhängigkeit ersetzen. Keine Mocking-Bibliotheken, kein Monkey-Patching, keine jest.mock()-Magie.

tstypescript
// ❌ Without DI — need complex mocking setup
jest.mock("./db", () => ({
  prisma: { user: { findMany: jest.fn() } },
}));
 
// ✅ With DI — just pass a fake
import { createUserService } from "./services/user";
 
test("getActiveUsers filters by status", async () => {
  const mockRepo = {
    findMany: async (filter: any) => {
      expect(filter.where.status).toBe("active");
      return [
        { id: "1", name: "Alice", status: "active" },
        { id: "2", name: "Bob", status: "active" },
      ];
    },
  };
 
  const service = createUserService(mockRepo);
  const users = await service.getActiveUsers();
 
  expect(users).toHaveLength(2);
});

Der Test ist schnell, deterministisch und liest sich wie eine Spezifikation. Keine Datenbank, kein Netzwerk, kein Dateisystem.

Wann man keinen DI-Container braucht

DI-Container (wie tsyringe, inversify oder awilix) fügen automatische Auflösung hinzu — man registriert Interfaces und Implementierungen, und der Container verdrahtet sie. Das ist nützlich, wenn man 50+ Services mit tiefen Abhängigkeitsgraphen hat.

Für die meisten Webanwendungen ist manuelles Verdrahten in einem Composition Root klarer und leichter zu debuggen:

AnsatzGeeignet fürNachteil
Manuelles VerdrahtenKleine bis mittlere Apps, ≤20 ServicesUmständlich bei tiefem Abhängigkeitsgraphen
DI-ContainerGroße Apps, Plugin-Systeme, tiefe GraphenMagische Auflösung, schwerer nachzuvollziehen

Wenn dein Composition Root in eine Datei passt und du ihn von oben nach unten lesen kannst, brauchst du keinen Container.

Die wichtigsten Punkte

  1. DI ist einfach nur das Übergeben von Argumenten — kein Framework, keine Decorator-Zeremonie nötig
  2. Factory-Funktionen sind der pragmatische Sweet Spot in TypeScript
  3. Der Composition Root ist der einzige Ort, an dem echte Implementierungen verdrahtet werden
  4. Testen mit DI ist trivial — übergib Fakes direkt, keine Mock-Bibliotheken nötig
  5. Verzichte auf DI-Container, sofern dein Abhängigkeitsgraph nicht wirklich groß und tief ist
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX