System-Design
Domain-Driven Design für komplexe Systeme: Wann es hilft – und wann nicht
Domain-Driven Design ist entweder die Lösung für Ihre Komplexitätsprobleme oder eine weitere Schicht davon. Der Unterschied liegt nicht im Pattern, sondern im Kontext. Dieser Artikel erklärt DDD pragmatisch: Was es ist, wann es hilft, und wann Sie es besser lassen.
DDD ist kein Framework. Es ist eine Denkweise – und die muss zum Problem passen.
Was ist DDD eigentlich?
Domain-Driven Design, geprägt von Eric Evans in seinem Blue Book (2003), ist ein Ansatz zur Software-Entwicklung, der die Fachdomäne ins Zentrum stellt. Nicht die Datenbank. Nicht das Framework. Die Domäne.
DDD besteht aus zwei Teilen:
- Strategic Design: Wie teilen wir ein großes System in handhabbare Teile? (Bounded Contexts, Context Maps)
- Tactical Design: Wie modellieren wir innerhalb eines Teils? (Entities, Value Objects, Aggregates, Repositories)
Die meisten Teams springen direkt zum Tactical Design – Entities, Repositories, das Übliche. Das ist ein Fehler. Der strategische Teil ist wichtiger.
Strategic Design: Das Große Bild
Ubiquitous Language
Die Grundidee: Entwickler und Fachexperten sprechen dieselbe Sprache. Nicht „User" im Code und „Kunde" im Meeting. Nicht „Order" hier und „Bestellung" dort.
// Schlecht: Technische Sprache, die niemand im Business versteht
class User {
private String userId;
private List<Transaction> transactions;
public void processTransaction(Transaction t) { ... }
}
// Besser: Domänensprache, die der Fachbereich versteht
class Kunde {
private Kundennummer kundennummer;
private List<Bestellung> bestellungen;
public Bestellung bestellungAufgeben(Warenkorb warenkorb) { ... }
}
Ubiquitous Language ist nicht nur Übersetzung – es ist ein gemeinsames Modell. Wenn der Fachbereich sagt „Kunde storniert Bestellung", muss es im Code eine Methode kunde.bestellungStornieren() geben, keine orderService.cancelOrder().
Bounded Context
Ein Bounded Context ist eine Grenze, innerhalb derer ein bestimmtes Modell gilt. Dieselben Begriffe können in verschiedenen Contexts unterschiedliche Bedeutungen haben.
Beispiel E-Commerce:
| Begriff | Sales Context | Shipping Context | Accounting Context |
|---|---|---|---|
| Kunde | Hat Warenkorb, Wunschliste, Kaufhistorie | Hat Lieferadresse, Zustellpräferenzen | Hat Zahlungshistorie, Kreditlimit |
| Produkt | Hat Preis, Beschreibung, Bilder | Hat Gewicht, Maße, Gefahrgut-Flag | Hat Einkaufspreis, Marge, Steuerklasse |
| Bestellung | Warenkorb → Bestellung → Bezahlt | Zu versenden → Versendet → Zugestellt | Rechnung → Bezahlt → Gebucht |
Der Fehler: Ein einziges Customer-Objekt mit allen Attributen aus allen Contexts. Das Ergebnis ist ein God Object mit 50 Feldern, von denen jeder Service nur 10 braucht.
// Anti-Pattern: Ein Modell für alles
class Customer {
// Sales
private ShoppingCart cart;
private List<WishlistItem> wishlist;
// Shipping
private Address shippingAddress;
private DeliveryPreferences deliveryPrefs;
// Accounting
private PaymentHistory payments;
private BigDecimal creditLimit;
// ... 40 weitere Felder
}
// DDD: Separate Modelle pro Context
// Sales Context
class Kunde {
private Kundennummer id;
private Warenkorb warenkorb;
private List<Wunschartikel> wunschliste;
}
// Shipping Context
class Empfaenger {
private EmpfaengerId id; // Kann dieselbe Kundennummer sein
private Lieferadresse adresse;
private Zustellpraeferenzen praeferenzen;
}
// Accounting Context
class Debitor {
private DebitorNummer id;
private Zahlungshistorie zahlungen;
private Kreditlimit limit;
}
Wichtig: Ein Bounded Context ist ein Sprachraum. Ob er als Microservice, Modul oder Teil eines Monolithen umgesetzt wird, ist eine technische Entscheidung – keine fachliche.
Context Map
Wie kommunizieren Bounded Contexts miteinander? Eine Context Map visualisiert die Beziehungen:
| Beziehung | Bedeutung | Beispiel |
|---|---|---|
| Shared Kernel | Beide Teams teilen Code/Modell | Gemeinsame Domain-Objekte in einer Library |
| Customer-Supplier | Upstream liefert, Downstream konsumiert | Inventory liefert Bestand an Sales |
| Conformist | Downstream übernimmt Modell von Upstream | Wir passen uns an externe API an |
| Anti-Corruption Layer | Übersetzungsschicht zwischen Modellen | Legacy-System wird übersetzt |
| Open Host Service | Öffentliche API für mehrere Clients | REST-API für Partner |
| Published Language | Dokumentiertes, stabiles Format | JSON-Schema, Protobuf |
// Anti-Corruption Layer Beispiel
class LegacyOrderAdapter implements OrderRepository {
private final LegacyErpClient erpClient;
@Override
public Bestellung findById(BestellungsId id) {
// Legacy-System hat völlig andere Struktur
ErpOrderDto legacy = erpClient.getOrder(id.getValue());
// Übersetzung in unser Domänenmodell
return new Bestellung(
new BestellungsId(legacy.orderNumber),
mapToKunde(legacy.customerData),
mapToPositionen(legacy.lineItems),
mapToStatus(legacy.statusCode)
);
}
private BestellStatus mapToStatus(String erpStatus) {
// ERP hat Codes wie "10", "20", "30"
return switch(erpStatus) {
case "10" -> BestellStatus.NEU;
case "20" -> BestellStatus.IN_BEARBEITUNG;
case "30" -> BestellStatus.VERSENDET;
default -> BestellStatus.UNBEKANNT;
};
}
}
Tactical Design: Die Bausteine
Entity vs. Value Object
Der fundamentale Unterschied:
- Entity: Hat Identität. Zwei Objekte mit gleichen Attributen sind unterschiedlich, wenn ihre IDs unterschiedlich sind.
- Value Object: Hat keine Identität. Zwei Objekte mit gleichen Attributen sind gleich.
// Entity: Identität zählt
class Kunde {
private final KundenId id; // Das macht ihn zur Entity
private String name;
private Adresse adresse;
// Zwei Kunden mit gleichem Namen sind verschiedene Kunden
@Override
public boolean equals(Object o) {
return id.equals(((Kunde) o).id);
}
}
// Value Object: Werte zählen
class Adresse {
private final String strasse;
private final String plz;
private final String ort;
// Zwei identische Adressen sind gleich
@Override
public boolean equals(Object o) {
Adresse other = (Adresse) o;
return strasse.equals(other.strasse)
&& plz.equals(other.plz)
&& ort.equals(other.ort);
}
}
Aggregate
Ein Aggregate ist eine Gruppe von Objekten, die zusammen eine Konsistenzgrenze bilden. Änderungen gehen immer durch die Aggregate Root.
// Aggregate: Bestellung
class Bestellung { // Aggregate Root
private final BestellungsId id;
private final KundenId kundenId;
private List<Bestellposition> positionen; // Teil des Aggregates
private BestellStatus status;
private Geld gesamtbetrag;
// Änderungen nur durch die Root
public void positionHinzufuegen(Artikel artikel, int menge) {
if (status != BestellStatus.OFFEN) {
throw new BestellungNichtAenderbarException();
}
positionen.add(new Bestellposition(artikel, menge));
gesamtbetragNeuBerechnen();
}
public void abschliessen() {
if (positionen.isEmpty()) {
throw new LeereBestellungException();
}
this.status = BestellStatus.ABGESCHLOSSEN;
// Domain Event publizieren
DomainEvents.publish(new BestellungAbgeschlossen(this.id));
}
private void gesamtbetragNeuBerechnen() {
this.gesamtbetrag = positionen.stream()
.map(Bestellposition::getPositionspreis)
.reduce(Geld.ZERO, Geld::plus);
}
}
// Teil des Aggregates, keine eigene Identität außerhalb
class Bestellposition { // Nicht direkt zugreifbar
private final Artikel artikel;
private final int menge;
private final Geld einzelpreis;
public Geld getPositionspreis() {
return einzelpreis.mal(menge);
}
}
Warnsignal: Wenn ein Aggregate mehr als ein Repository braucht oder regelmäßig über mehrere Transaktionen geändert wird, ist es zu groß.
Repository
Ein Repository ist eine Abstraktion über die Persistenz. Es gibt Aggregates rein und raus – als wäre es eine Collection.
// Repository Interface (in der Domain)
interface BestellungRepository {
Bestellung findById(BestellungsId id);
List<Bestellung> findByKunde(KundenId kundenId);
void save(Bestellung bestellung);
}
// Implementierung (in der Infrastruktur)
class JpaBestellungRepository implements BestellungRepository {
private final EntityManager em;
@Override
public Bestellung findById(BestellungsId id) {
BestellungEntity entity = em.find(BestellungEntity.class, id.getValue());
return mapToDomain(entity); // ORM-Entity → Domain-Objekt
}
@Override
public void save(Bestellung bestellung) {
BestellungEntity entity = mapToEntity(bestellung);
em.merge(entity);
}
}
Der Punkt: Die Domäne weiß nichts von JPA, Hibernate oder SQL. Sie kennt nur das Repository-Interface.
Domain Service
Wenn eine Logik zu keiner Entity gehört, aber trotzdem Domänenlogik ist:
// Domain Service: Preisberechnung über mehrere Aggregates
class Preisberechnung {
public Geld berechneGesamtpreis(
Warenkorb warenkorb,
Kunde kunde,
Gutschein gutschein) {
Geld basis = warenkorb.getWarenwert();
Geld mitKundenrabatt = kunde.getRabattstufe().anwenden(basis);
Geld mitGutschein = gutschein.anwenden(mitKundenrabatt);
return mitGutschein;
}
}
Domain Events
Wenn etwas Wichtiges in der Domäne passiert, publizieren Sie ein Event:
// Domain Event
record BestellungAbgeschlossen(
BestellungsId bestellungsId,
KundenId kundenId,
Geld gesamtbetrag,
Instant zeitpunkt
) implements DomainEvent {}
// Im Aggregate
class Bestellung {
public void abschliessen() {
// ... Validierung ...
this.status = BestellStatus.ABGESCHLOSSEN;
DomainEvents.publish(new BestellungAbgeschlossen(
this.id,
this.kundenId,
this.gesamtbetrag,
Instant.now()
));
}
}
// Event Handler (anderer Bounded Context)
class LagerReservierung {
@EventHandler
public void onBestellungAbgeschlossen(BestellungAbgeschlossen event) {
// Lager reservieren
}
}
class EmailVersand {
@EventHandler
public void onBestellungAbgeschlossen(BestellungAbgeschlossen event) {
// Bestätigung senden
}
}
Wann DDD – und wann nicht?
DDD lohnt sich wenn:
- Komplexe Domänenlogik: Viele Geschäftsregeln, Validierungen, Zustandsübergänge
- Langlebige Software: System wird über Jahre weiterentwickelt
- Mehrere Teams: Klare Grenzen (Bounded Contexts) helfen bei der Koordination
- Enge Business-Zusammenarbeit: Fachexperten sind verfügbar und involviert
- Unklare/sich ändernde Anforderungen: Flexibles Modell nötig
DDD ist Overkill wenn:
- CRUD-Anwendungen: Daten rein, Daten raus, fertig
- Reine Integrationen: Daten von A nach B schieben
- Reports/Analytics: Lesen, nicht schreiben
- Prototypen: Schnelligkeit > Struktur
- Kleine Teams ohne Fachexperten: Ubiquitous Language mit wem?
Häufige Fehler
1. DDD als Technologie verstehen
DDD ist kein Framework. Sie können DDD nicht installieren. Wenn Ihr „DDD-Projekt" damit beginnt, dass jemand eine Library evaluiert, sind Sie auf dem falschen Weg.
2. Anemic Domain Model
Entities, die nur Getter/Setter haben, und Services, die alle Logik enthalten:
// Anti-Pattern: Anemic Domain Model
class Order {
private String status;
public String getStatus() { return status; }
public void setStatus(String s) { this.status = s; }
}
class OrderService {
public void completeOrder(Order order) {
if (order.getStatus().equals("OPEN")) {
order.setStatus("COMPLETED");
// ... 100 Zeilen Logik ...
}
}
}
// Besser: Rich Domain Model
class Order {
private OrderStatus status;
public void complete() {
if (this.status != OrderStatus.OPEN) {
throw new InvalidOrderStateException();
}
this.status = OrderStatus.COMPLETED;
// Logik gehört hierhin
}
}
3. Falsche Aggregate-Grenzen
Zu große Aggregates (alles ist verbunden) oder zu kleine (jede Tabelle ist ein Aggregate). Die richtige Größe orientiert sich an Transaktionsgrenzen und Invarianten.
4. Ubiquitous Language ignorieren
Code in Englisch, Fachbereich spricht Deutsch, Meetings sind ein Übersetzungs-Marathon. Entweder Sie committen zur gemeinsamen Sprache, oder Sie lassen es.
5. DDD überall anwenden
Nicht jeder Bounded Context braucht taktisches DDD. Ein Reporting-Context kann ein einfaches Query-Modell sein. Ein Notification-Context braucht keine Aggregates.
Praktischer Einstieg
Schritt 1: Event Storming
Bevor Sie Code schreiben: Event Storming mit dem Fachbereich. Auf einem großen Whiteboard (oder Miro) sammeln Sie:
- Orange: Domain Events (Was passiert?)
- Blau: Commands (Was löst es aus?)
- Gelb: Aggregates (Wer ist verantwortlich?)
- Pink: External Systems
- Rot: Pain Points, offene Fragen
Nach 2-4 Stunden haben Sie ein Modell, das vom Business verstanden wird – und Sie sehen Bounded Contexts entstehen.
Schritt 2: Core Domain identifizieren
Nicht alles ist gleich wichtig. Identifizieren Sie:
- Core Domain: Das, was Ihr Unternehmen einzigartig macht. Hier investieren Sie.
- Supporting Subdomain: Wichtig, aber nicht differenzierend. Kann Standard sein.
- Generic Subdomain: Commodity. Kaufen oder minimaler Aufwand.
Schritt 3: Bounded Contexts definieren
Basierend auf Event Storming: Wo sind die sprachlichen Grenzen? Wo ändern sich Begriffe? Dort liegen Context-Grenzen.
Schritt 4: Context Map erstellen
Wie sprechen die Contexts miteinander? Wer ist upstream, wer downstream? Wo brauchen Sie Anti-Corruption Layer?
Schritt 5: Tactical Design für Core Domain
Erst jetzt: Aggregates, Entities, Value Objects – aber nur für die Core Domain. Der Rest kann einfacher sein.
Fazit
Domain-Driven Design ist mächtig, aber nicht universell. Es ist ein Werkzeug für komplexe Domänen, nicht ein Architektur-Standard für jedes Projekt.
Die wichtigsten Punkte:
- Strategic vor Tactical: Bounded Contexts sind wichtiger als Aggregate-Patterns
- Ubiquitous Language: Der unterschätzte Kern von DDD
- Richtige Grenzen: Nicht zu groß, nicht zu klein
- Nicht überall: DDD für Core Domain, simpler für den Rest
- Mit dem Business: DDD ohne Fachexperten ist Theater
DDD ist keine Architektur – es ist eine Konversation zwischen Code und Business. Wenn diese Konversation nicht stattfindet, ist DDD nur ein teures Labeling-Projekt.
DDD scheitert selten an Code – sondern an Organisationen, die keine fachlichen Entscheidungen treffen wollen.
Domain Modeling Workshop?
Ich moderiere Event Storming Sessions, helfe bei der Definition von Bounded Contexts und begleite Teams bei der Einführung von DDD – pragmatisch, nicht dogmatisch.
Workshop anfragen