Blog
Teil 5 von 13 Backend Patterns

Domain-Driven Design

5 min Lesezeit
architecture ddd patterns

Dein User-Model hat 47 Felder. Ich weiß das, weil jedes User-Model irgendwann 47 Felder hat.

Marketing hat segmentId hinzugefügt. Billing braucht stripeCustomerId. Support will lastTicketDate. HR hat employeeLevel drangehängt für interne Nutzer. Jedes Feature-Team tackert seine Felder an das Model, weil “User” das zentrale Ding ist. Das Gravitationszentrum.

Bis es kollabiert. Und das passiert nicht nur mit User, sondern mit Order, Product, Customer. Mit jedem Konzept das mehr als ein Team anfasst.

Klick dich durch und schau, wie schnell es kippt:

Order Model
4 FELDER
interface Order {
idstringbase
customerIdstringbase
statusOrderStatusbase
createdAtDatebase
}
Vier Felder. Sauber.
sales
billing
shipping
support

Fünf Schritte. Von vier Feldern zu neunzehn. Und dann ändert Billing ein Feld und drei andere Teams brechen. Das ist kein hypothetisches Szenario. Das ist Dienstag.

Irgendwann sagt jemand: “Wir brauchen DDD.” Und dann passiert das Falsche. Jemand liest die ersten drei Kapitel von Evans’ Blue Book, benennt die Ordner um (entities/, repositories/, valueObjects/, services/), und das Team erklärt DDD für implementiert.

Das war nicht DDD. Das war ein Refactoring der Ordnerstruktur.

Was DDD wirklich löst

Domain-Driven Design hat zwei Hälften. Die taktische (Entities, Value Objects, Aggregates, Repositories) ist der Teil den alle kennen. Die strategische (Bounded Contexts, Ubiquitous Language, Context Mapping) ist der Teil der tatsächlich Probleme löst.

Rate mal, welche Hälfte die meisten Teams ignorieren.

Die strategische Seite beantwortet eine einzige Frage: wem gehört dieses Konzept? Nicht Code Ownership. Sondern: wer definiert, was “Kunde” bedeutet?

Ubiquitous Language

Der wichtigste Begriff in DDD ist nicht “Entity” oder “Aggregate.” Es ist Ubiquitous Language: die Idee, dass jeder Bounded Context seine eigene Sprache hat. Und dass dasselbe Wort in verschiedenen Kontexten verschiedene Dinge bedeutet.

Klick dich durch die Kontexte und schau was “Kunde” in jedem bedeutet:

"Kunde" im Kontext5 FELDER
interface SalesLead {
namestring
companystring
annualBudgetnumber
leadScorenumber
lastContactDateDate
}

Sales braucht Lead Score und Budget. Billing braucht Zahlungsbedingungen und Steuernummer. Support braucht Ticket-Historie. Shipping braucht Lieferadressen. Niemand braucht alles.

Das klingt nach Duplikation. Ist es auch. Aber Duplikation zwischen Kontexten ist gewollt, billiger als die Kopplung die du bekommst wenn alle dasselbe Model teilen. Das ist der DDD-Trade-off den die meisten nicht akzeptieren wollen.

Wenn du das in ein Customer-Interface packst, bekommst du 20 Felder, von denen jeder Kontext 5 braucht und 15 ignoriert. Genau das Problem das du oben mit dem Order-Model gesehen hast.

Der Ausweg: jeder Kontext definiert sein eigenes Model. SalesLead, BillingAccount, SupportContact, ShippingRecipient. Vier Interfaces statt einem. Keine Kopplung.

Bounded Contexts finden

Bounded Contexts folgen meistens Teamgrenzen. Nicht andersrum. Du beobachtest welche Teams welche Sprache benutzen und machst die implizite Grenze explizit.

Drei Heuristiken:

  1. Zwei Teams, ein Wort, zwei Bedeutungen: “Kunde” bedeutet für Sales was anderes als für Billing. Das ist eine Context-Grenze.
  2. Ein Model mit Feldern die nur ein Team nutzt: wenn Shipping nie leadScore liest und Sales nie trackingId, hast du zwei Kontexte.
  3. Jede Änderung betrifft mehrere Teams: das ist kein Feature-Problem, sondern eine fehlende Grenze.

Bounded Context ≠ Microservice

Ich sehe das ständig: “Wir haben 5 Bounded Contexts, also bauen wir 5 Microservices.” Nein.

Ein Bounded Context ist eine Modell-Grenze. Nicht eine Deployment-Grenze. Du kannst drei Bounded Contexts in einem Monolithen haben, als Module mit klaren Interfaces. Das ist oft die bessere Wahl als drei Services die sich über HTTP anschreien.

// modules/sales/models.ts
export interface SalesOrder {
  id: string;
  customerId: string;
  discount: number;
  campaignCode: string;
}

// modules/billing/models.ts
export interface Invoice {
  orderId: string;
  taxRates: TaxRate[];
  paymentMethod: PaymentMethod;
  dunningLevel: number;
}

// modules/shipping/models.ts
export interface Shipment {
  orderId: string;
  weight: number;
  trackingId: string | null;
  carrier: Carrier;
}

Drei Module. Drei Models. Ein Monolith. Kein Netzwerk-Overhead, kein verteiltes Tracing, kein Koordinations-Meeting. Und trotzdem: klare Grenzen, eigene Sprache pro Kontext, unabhängige Entwicklung.

“Majestic Monolith” nennt DHH das. Ich bin nicht in allem seiner Meinung, aber hier hat er Recht: die meisten Teams profitieren mehr von einem modularen Monolithen als von Microservices.

Kommunikation zwischen Kontexten

Irgendwann müssen Kontexte miteinander reden. Sales erstellt eine Bestellung, Billing muss eine Rechnung generieren. Drei Optionen:

Published Language: der sendende Kontext definiert ein Event-Schema. OrderPlaced { orderId, customerId, items, total }. Der empfangende Kontext mapped das auf sein eigenes Model.

Anti-Corruption Layer: der empfangende Kontext baut einen Adapter der das fremde Model in sein eigenes übersetzt. Nützlich gegen Legacy-APIs die du nicht kontrollierst.

Shared Kernel: zwei Kontexte teilen ein kleines, gemeinsames Model. Gefährlich. Funktioniert nur wenn beide Teams eng zusammenarbeiten und das Shared Model minimal halten.

// billing/adapters/salesAdapter.ts
import type { SalesOrderEvent } from '../../sales/events';
import type { Invoice } from '../models';

export function toInvoice(event: SalesOrderEvent): Omit<Invoice, 'id'> {
  return {
    orderId: event.orderId,
    taxRates: calculateTaxRates(event.items, event.customerId),
    paymentMethod: 'pending',
    dunningLevel: 0,
  };
}

Der Adapter lebt im Billing-Kontext. Er weiß wie Sales-Events aussehen und übersetzt sie. Wenn Sales sein Event-Format ändert, bricht nur der Adapter, nicht das Billing-Model.

Wann DDD sich nicht lohnt

Wenn dein Team 5 Leute hat und eine CRUD-App baut, brauchst du kein DDD. Du brauchst vernünftige Module und klare Interfaces. DDD lohnt sich wenn:

Wenn keins davon zutrifft: spar dir die Zeremonie. Gute Architektur ist nicht DDD oder nichts. Es ist ein Spektrum.

Die meisten Projekte brauchen kein Aggregate Root, kein Repository Pattern und kein Domain Event. Was sie brauchen: eine Antwort auf die Frage “wem gehört dieses Konzept?” Wenn du das hast, ist der Rest Details.