Zum Inhalt springen

Dependency-Injection-Muster in TypeScript-Anwendungen

Dependency Injection in TypeScript mit Constructor Injection, Factory Functions, DI-Containern und Modulkomposition — testbar, ohne Framework-Lock-in.

4 Min. Lesezeit
Ein Abhängigkeitsgraph, der Services zeigt, die über Injection statt direkter Imports verbunden sind

Das Problem, das DI löst

Wenn ein Modul seine Abhängigkeiten direkt importiert und instanziiert, lässt es sich nicht isoliert testen, Implementierungen austauschen oder in anderen Kontexten wiederverwenden. Dependency Injection kehrt diese Kontrolle um: Der Aufrufer stellt die Abhängigkeiten bereit, anstatt dass das Modul sie selbst erstellt.

Constructor Injection

Das einfachste und expliziteste Muster. Abhängigkeiten sind Parameter, und das Typsystem erzwingt den Vertrag.

tstypescript
// ❌ Hard-coded dependency — impossible to test without a real database
class OrderService {
  private db = new PostgresDatabase();
 
  async getOrder(id: string): Promise<Order> {
    return this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
  }
}
 
// ✅ Constructor injection — dependency provided from outside
interface Database {
  query<T>(sql: string, params: unknown[]): Promise<T>;
}
 
class OrderService {
  constructor(private db: Database) {}
 
  async getOrder(id: string): Promise<Order> {
    return this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
  }
}
 
// Production
const service = new OrderService(new PostgresDatabase(connectionString));
 
// Test — inject a mock
const mockDb: Database = {
  query: async () => ({ id: "1", total: 100, status: "completed" }) as any,
};
const testService = new OrderService(mockDb);

Factory Functions statt Klassen

Nicht alles muss eine Klasse sein. Factory Functions erreichen dieselbe Entkopplung mit weniger Aufwand.

tstypescript
// ❌ Class with constructor injection for simple logic
class PriceCalculator {
  constructor(
    private taxService: TaxService,
    private discountService: DiscountService
  ) {}
 
  async calculate(items: CartItem[]): Promise<number> {
    const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    const discount = await this.discountService.getDiscount(items);
    const tax = await this.taxService.getTax(subtotal - discount);
    return subtotal - discount + tax;
  }
}
 
// ✅ Factory function — same testability, less boilerplate
interface PricingDeps {
  getTax: (amount: number) => Promise<number>;
  getDiscount: (items: CartItem[]) => Promise<number>;
}
 
function createPriceCalculator(deps: PricingDeps) {
  return {
    async calculate(items: CartItem[]): Promise<number> {
      const subtotal = items.reduce(
        (sum, i) => sum + i.price * i.quantity, 0
      );
      const discount = await deps.getDiscount(items);
      const tax = await deps.getTax(subtotal - discount);
      return subtotal - discount + tax;
    },
  };
}
 
// Usage
const calculator = createPriceCalculator({
  getTax: taxService.getTax.bind(taxService),
  getDiscount: discountService.getDiscount.bind(discountService),
});

Composition Root

Verbinde alle Abhängigkeiten an einem einzigen Ort beim Start der Anwendung. Dies ist der einzige Ort, der konkrete Implementierungen kennt.

tstypescript
// composition-root.ts — the only file that imports concrete implementations
import { PostgresDatabase } from "./infra/postgres";
import { StripePaymentGateway } from "./infra/stripe";
import { SendGridEmailService } from "./infra/sendgrid";
import { OrderService } from "./services/order-service";
import { PaymentService } from "./services/payment-service";
import { NotificationService } from "./services/notification-service";
 
export function createAppContext(config: AppConfig) {
  // Infrastructure
  const db = new PostgresDatabase(config.databaseUrl);
  const payments = new StripePaymentGateway(config.stripeKey);
  const email = new SendGridEmailService(config.sendgridKey);
 
  // Services — wired with their dependencies
  const orderService = new OrderService(db);
  const paymentService = new PaymentService(payments, db);
  const notificationService = new NotificationService(email);
 
  return {
    orderService,
    paymentService,
    notificationService,
    // Expose shutdown for graceful cleanup
    async shutdown() {
      await db.close();
    },
  };
}
 
// server.ts
const app = createAppContext(loadConfig());
 
// Route handlers receive services from the composition root
router.post("/orders", async (req, res) => {
  const order = await app.orderService.create(req.body);
  await app.notificationService.sendOrderConfirmation(order);
  res.json(order);
});

Leichtgewichtiger DI-Container

Wenn der Abhängigkeitsgraph komplex wird, vermeidet ein einfacher Container das manuelle Verdrahten ohne schweres Framework.

tstypescript
type Factory<T> = (container: Container) => T;
 
class Container {
  private factories = new Map<string, Factory<unknown>>();
  private singletons = new Map<string, unknown>();
 
  register<T>(name: string, factory: Factory<T>): void {
    this.factories.set(name, factory);
  }
 
  singleton<T>(name: string, factory: Factory<T>): void {
    this.factories.set(name, (c) => {
      if (!this.singletons.has(name)) {
        this.singletons.set(name, factory(c));
      }
      return this.singletons.get(name)!;
    });
  }
 
  resolve<T>(name: string): T {
    const factory = this.factories.get(name);
    if (!factory) throw new Error(`No registration for "${name}"`);
    return factory(this) as T;
  }
}
 
// Registration
const container = new Container();
 
container.singleton("database", () =>
  new PostgresDatabase(process.env.DATABASE_URL!)
);
 
container.singleton("orderService", (c) =>
  new OrderService(c.resolve("database"))
);
 
container.singleton("paymentService", (c) =>
  new PaymentService(
    c.resolve("paymentGateway"),
    c.resolve("database")
  )
);
 
// Resolution
const orderService = container.resolve<OrderService>("orderService");

Testen mit Dependency Injection

DI macht Testen unkompliziert. Ersetze echte Abhängigkeiten durch Test Doubles, die vorhersagbare Ergebnisse liefern.

tstypescript
import { describe, it, expect } from "vitest";
 
describe("OrderService", () => {
  function createTestOrderService(
    overrides: Partial<OrderServiceDeps> = {}
  ) {
    const defaults: OrderServiceDeps = {
      db: {
        query: async () => [],
        execute: async () => ({ rowCount: 1 }),
      },
      eventBus: {
        publish: async () => {},
      },
      idGenerator: () => "test-id-123",
    };
 
    return new OrderService({ ...defaults, ...overrides });
  }
 
  it("creates an order and publishes event", async () => {
    const publishedEvents: unknown[] = [];
 
    const service = createTestOrderService({
      eventBus: {
        publish: async (event) => {
          publishedEvents.push(event);
        },
      },
    });
 
    const order = await service.create({
      userId: "user-1",
      items: [{ productId: "p1", quantity: 2, price: 10 }],
    });
 
    expect(order.id).toBe("test-id-123");
    expect(publishedEvents).toHaveLength(1);
    expect(publishedEvents[0]).toMatchObject({
      type: "order.created",
      orderId: "test-id-123",
    });
  });
 
  it("throws when no items provided", async () => {
    const service = createTestOrderService();
 
    await expect(
      service.create({ userId: "user-1", items: [] })
    ).rejects.toThrow("Order must have at least one item");
  });
});

Wann man DI nicht verwenden sollte

Nicht jede Funktion braucht injizierbare Abhängigkeiten. Reine Funktionen, Hilfsfunktionen und Leaf-Module ohne Seiteneffekte können direkt importiert werden. DI schafft Wert an Systemgrenzen—Datenbankzugriff, externe APIs, Message Queues—wo Testbarkeit und Austauschbarkeit nötig sind.

tstypescript
// No DI needed — pure function with no side effects
function calculateTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
 
// DI valuable — external dependency with side effects
function createPaymentProcessor(deps: { gateway: PaymentGateway }) {
  return {
    async charge(amount: number, token: string): Promise<PaymentResult> {
      return deps.gateway.charge(amount, token);
    },
  };
}

Wichtige Erkenntnisse

Dependency Injection geht darum, Abhängigkeiten explizit und von außen bereitzustellen, anstatt sie intern zu erstellen. Constructor Injection reicht für die meisten Fälle aus—beginne damit, bevor du Container oder Decorators verwendest. Factory Functions bieten dieselbe Entkopplung wie Klassen mit weniger Boilerplate.

Verbinde alle konkreten Implementierungen in einer einzigen Composition Root, damit der Rest deiner Codebasis nur von Interfaces abhängt. Reserviere DI für Grenzen mit Seiteneffekten: Datenbanken, APIs, Message Queues und Dateisysteme. Reine Funktionen und deterministische Hilfsfunktionen brauchen keine Injection—das wäre Over-Engineering. Der Test, ob DI gerechtfertigt ist, ist einfach: Wenn du für ein Modul keinen schnellen, isolierten Test schreiben kannst, müssen seine Abhängigkeiten injizierbar sein.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX