Einleitung
"Ich weiß ungefähr, was DDD ist, aber was muss ich konkret tun, damit etwas wirklich DDD ist?"
"Worin besteht der Unterschied zwischen Entity und Value Object eigentlich genau?"
"Wenn ich ein Repository baue, ist das dann schon DDD?"
Solche Fragen haben wahrscheinlich viele. In diesem Artikel ordne ich die Essenz von DDD anhand von Eric Evans' Domain-Driven Design und Vaughn Vernons Implementing Domain-Driven Design noch einmal so, dass sie in der Praxis wirklich nutzbar ist.
Die zentralen Begriffe orientieren sich so weit wie möglich an Evans' DDD Reference und Vaughn Vernons offizieller Zusammenfassung.
Was ist DDD?
Domain-Driven Design (DDD) ist ein Entwurfsansatz, der bei Software mit komplexer Geschäftslogik auf folgender Idee beruht:
Stelle die Domäne ins Zentrum der Software und schreibe den Code in der Sprache dieser Domäne
Der wichtige Punkt ist: DDD ist weder ein Framework noch eine einzelne Architektur. Es ist eine Sammlung von Denkweisen und Techniken. Deshalb wird ein System nicht automatisch zu "DDD", nur weil irgendwo eine DDD-Bibliothek eingebunden wurde.
DDD besteht grob aus zwei Ebenen
DDD lässt sich grob in zwei Bereiche aufteilen.
| Kategorie | Alias | Worum es geht |
|---|---|---|
| Strategic Design | Strategic Design | Die Domäne sinnvoll aufteilen und Grenzen ziehen |
| Tactical Design | Tactical Design | Die Domäne auf Code-Ebene ausdrücken |
Oft wird angenommen, "DDD = Entities und Value Objects bauen". Das ist aber nur der taktische Teil. Wirklich entscheidend ist sogar eher das Strategic Design.
Strategic Design
Ubiquitous Language
Das ist der Ausgangspunkt von DDD und zugleich sein wichtigstes Konzept.
Domänenexpert:innen, Entwickler:innen und Stakeholder verwenden dieselben Begriffe
Wenn in einem E-Commerce-System zum Beispiel von einer "Bestellung" gesprochen wird, kann das je nach Person etwas anderes bedeuten:
- Marketing: "In dem Moment, in dem etwas in den Warenkorb gelegt wird, liegt eine Bestellung vor"
- Buchhaltung: "Erst mit abgeschlossener Zahlung liegt eine Bestellung vor"
- Logistik: "Erst wenn ein Versandauftrag erzeugt wurde, liegt eine Bestellung vor"
Dass ein Begriff je nach Perspektive etwas anderes bedeutet, ist völlig normal. Wenn man diese Unschärfe ungefiltert in den Code übernimmt, weiß am Ende niemand mehr, wofür die Klasse Order eigentlich steht.
Das Modell muss zum Rückgrat der Sprache werden, und diese Sprache muss in Gesprächen genauso wie im Code durchgängig verwendet werden. Das ist die Grundlage von DDD.
Bounded Context
Eine Ubiquitous Language muss nicht im gesamten Unternehmen einheitlich sein, und meistens kann sie das auch gar nicht.
Deshalb zieht man Grenzen nach dem Prinzip: "Innerhalb dieses Bereichs bedeutet dieses Wort genau das hier." Das ist ein Bounded Context. Diese Grenze ist nicht nur eine Gesprächsvereinbarung. Sie zeigt sich auch in der Teamstruktur, im Geltungsbereich innerhalb der Anwendung sowie in der Trennung von Codebases und Datenspeichern.
flowchart LR
subgraph Sales["Vertriebskontext"]
S["Order =<br/>Kaufabsicht nach dem Checkout"]
end
subgraph Logistics["Logistikkontext"]
L["Order =<br/>Paket mit Versandauftrag"]
end
N["Dasselbe Order kann Unterschiedliches bedeuten"]
S -.-> N
L -.-> N
classDef context fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef note fill:#ffffff,stroke:#94a3b8,color:#334155,stroke-dasharray: 5 5
class S,L context
class N noteDie Karte dieser Grenzen nennt man Context Map. Sie ist auch ein starkes Hilfsmittel, wenn Microservice-Grenzen festgelegt werden sollen.
Wie man aus Anforderungen eine Domäne herausarbeitet
"Und wie findet man die Domäne nun konkret?" Diese Frage stellt sich früher oder später jede:r. Hier sind einige Ansätze, die sich im Alltag tatsächlich bewähren.
1. In Interviews bewusst nach Verben und Nomen suchen
Wenn du mit Kund:innen oder Fachbereichen sprichst, lohnt es sich, bewusst Nomen = Modellkandidaten und Verben = Verhaltenskandidaten getrennt mitzuschreiben.
"Eine:r Vertriebsmitarbeiter:in erstellt ein Angebot, sendet es an den Kunden und wandelt es nach der Freigabe in einen Auftrag um."
Schon aus diesem einen Satz lassen sich extrahieren:
- Nomen: Vertriebsmitarbeiter:in, Angebot, Kunde, Auftrag -> Kandidaten für Entity / Value Object
- Verben: erstellen, senden, freigeben, in einen Auftrag umwandeln -> Kandidaten für Verhalten / Domain Events
Die Wörter, die Fachleute ganz selbstverständlich verwenden, sind das Rohmaterial der Ubiquitous Language.
2. EventStorming einsetzen
Bei komplexen Domänen ist EventStorming äußerst wirksam: Man ordnet Ereignisse mit Haftnotizen auf einem Whiteboard oder in Tools wie Miro oder FigJam an.
flowchart LR
E1["🟧 Angebot angefragt"] --> E2["🟧 Angebot erstellt"] --> E3["🟧 Angebot gesendet"]
E3 --> C2["🟦 Command<br/>Angebot freigeben"] --> E4["🟧 Angebot freigegeben"] --> E5["🟧 Auftrag angenommen"]
E3 --> C1["🟦 Command<br/>Angebot stornieren"] --> E6["🟧 Angebot storniert"]
A1["🟪 Aggregat<br/>Angebot"] -.löst die Ereignisse aus.-> E2
R1["🟨 Regel<br/>Nur freigegebene Angebote können in Aufträge umgewandelt werden"] -.Restriktion.-> E5
classDef event fill:#fdba74,stroke:#ea580c,color:#7c2d12
classDef command fill:#bfdbfe,stroke:#2563eb,color:#1e3a8a
classDef aggregate fill:#ddd6fe,stroke:#7c3aed,color:#4c1d95
classDef rule fill:#fde68a,stroke:#ca8a04,color:#713f12
class E1,E2,E3,E4,E5,E6 event
class C1,C2 command
class A1 aggregate
class R1 rule- 🟧 Orange Zettel: Domain Events, in der Vergangenheitsform formuliert
- 🟦 Blaue Zettel: Commands, also Aktionen von jemandem
- 🟪 Violette Zettel: Aggregate, die diese Ereignisse auslösen. Beispiel:
Angebot - 🟨 Gelbe Zettel: Geschäftsregeln / Restriktionen. Beispiel: „Nur freigegebene Angebote können in Aufträge umgewandelt werden“
Zum Beispiel ist „Angebot freigeben“ ein Command, weil es eine Anweisung ist, die jemand ausführt. „Angebot freigegeben“ dagegen ist ein Domain Event, weil es eine bereits eingetretene Tatsache beschreibt.
Wenn Fachseite und Entwicklung das gemeinsam machen, wird implizites Wissen auf einen Schlag sichtbar. Der größte Wert liegt darin, dass dabei ganz natürlich Gespräche entstehen wie: "Ach, hier fällt also diese Entscheidung?" oder "In diesem Fall gibt es aber eigentlich eine Ausnahme..."
3. Immer nach Ausnahmefällen fragen
In der Anforderungsaufnahme steckt die meiste Information oft im Ausnahmefluss.
| Frage | Welche Information sie hervorholt |
|---|---|
| "Wann funktioniert das nicht?" | Invarianten und Geschäftsregeln |
| "Welche problematischen Fälle gab es in der Vergangenheit?" | Versteckte Restriktionen |
| "Was wird aktuell manuell umgangen?" | Noch nicht implementierte Geschäftsregeln |
| "Was hat sich in diesem Prozess in den letzten fünf Jahren verändert?" | Veränderliche und stabile Bereiche |
Wenn du nur nach dem Happy Path fragst, wirst du die Konturen der Domäne nicht richtig erkennen. Das eigentliche Wesen der Domäne versteckt sich oft genau dort, wo es weh tut.
4. In Core, Supporting und Generic unterscheiden
Nicht jede identifizierte Domäne muss mit derselben Intensität umgesetzt werden.
| Kategorie | Beschreibung | Strategie |
|---|---|---|
| Core Domain | Quelle des Wettbewerbsvorteils | Sorgfältig mit DDD selbst entwickeln |
| Supporting Subdomain | Unterstützt den Kern fachlich | Leichtgewichtig selbst bauen oder auslagern |
| Generic Subdomain | In fast jedem Unternehmen ähnlich, etwa Authentifizierung oder Zahlungsabwicklung | SaaS / OSS einsetzen |
Am Beispiel eines E-Commerce-Systems:
- Core: Produktempfehlungen, Preisstrategie, Bestandsreservierung
- Supporting: Produktkatalogverwaltung, Lieferkoordination
- Generic: Authentifizierung, Zahlung, E-Mail-Versand
Wer versucht, alles mit maximalem Aufwand zu behandeln, scheitert fast sicher. Klar zu machen, worin tatsächlich investiert werden soll, ist der erste Schritt guter Grenzziehung.
5. Kriterien zum Ziehen von Context-Grenzen
Wo genau Grenzen gezogen werden, ist Erfahrungssache. Die folgenden Signale helfen aber bei der Einschätzung.
- 🚩 Dasselbe Wort bekommt eine andere Bedeutung (wie im
Order-Beispiel zu Beginn) - 🚩 Die zuständige Organisation oder Abteilung wechselt (Conway's Law)
- 🚩 Der Lebenszyklus ist unterschiedlich (Bestellungen und Kund:innen haben nicht dieselbe Lebensdauer)
- 🚩 Die Änderungsfrequenz ist unterschiedlich (Preisstrategien ändern sich oft, Adressverwaltung eher selten)
- 🚩 Das benötigte Konsistenzniveau ist unterschiedlich (Bestand braucht starke Konsistenz, Empfehlungen dürfen eventual consistent sein)
Sobald du solche Verschiebungen bemerkst, hast du meist einen guten Kandidaten für eine Grenze gefunden.
Tactical Design
Ab hier geht es um den Code.
Value Object
Ein Value Object repräsentiert "den Wert selbst" und hat die folgenden Eigenschaften:
- Unveränderlich (immutable)
- Vergleich über Wertgleichheit statt über Identität
- Keine Seiteneffekte
// ❌ Nur ein String
const email: string = "user@example.com";
// ✅ Value Object
class Email {
constructor(private readonly value: string) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
throw new Error("Ungültige E-Mail-Adresse");
}
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
Der entscheidende Punkt ist, die Regel "Das ist eine gültige E-Mail-Adresse" in den Typ einzuschließen. So verteilt sich Validierung nicht über die ganze Codebasis.
Entity
Eine Entity ist ein Objekt, dessen Identität durch einen Bezeichner (ID) bestimmt wird.
class User {
constructor(
public readonly id: UserId,
private name: UserName,
private email: Email,
) {}
changeEmail(newEmail: Email): void {
this.email = newEmail;
}
equals(other: User): boolean {
return this.id.equals(other.id);
}
}
Auch wenn sich Name oder E-Mail ändern: Ist die id gleich, ist es derselbe User. Das ist das Wesen einer Entity.
Aggregate
Ein Aggregate ist eine Konsistenzgrenze, die mehrere Entities und Value Objects zu einer Einheit zusammenfasst.
Die Entity, die als Einstiegspunkt dient, nennt man Aggregate Root. Von außen wird immer über diese Wurzel auf das Aggregate zugegriffen.
// Aggregatwurzel
class Order {
private items: OrderItem[] = [];
constructor(
public readonly id: OrderId,
private readonly userId: UserId,
) {}
// Die Aggregatwurzel schützt die Konsistenz innerhalb des Aggregats.
addItem(product: Product, quantity: number): void {
if (this.items.length >= 100) {
throw new Error("Die Artikelgrenze pro Bestellung wurde überschritten");
}
this.items.push(new OrderItem(product.id, quantity, product.price));
}
totalPrice(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero(),
);
}
}
Eine wichtige Regel lautet: Aggregate so klein wie möglich halten. Werden sie zu groß, kommen Konflikte bei gleichzeitigen Änderungen und hohe Ladeaufwände fast automatisch.
Repository
Ein Repository ist eine Abstraktion für die Persistenz von Aggregaten. Es liegt dazwischen, damit die Domänenschicht nicht von Infrastruktur abhängt.
// Domänenschicht: nur das Interface definieren.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// Infrastrukturschicht: die Implementierung liegt außen.
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise<Order | null> { /* ... */ }
async save(order: Order): Promise<void> { /* ... */ }
}
Wichtig ist hier: Ein Repository ist eine Abstraktion, die globalen Zugriff auf Aggregate Roots bietet und für Aufrufer wie eine Sammlung wirkt. Es geht also nicht primär um "in die Datenbank speichern", sondern um den Zugriffspunkt, über den Aggregate abgelegt und wieder geholt werden.
Domain Service
Ein Domain Service ist der Ort für wichtige fachliche Abläufe oder Transformationen, die sich nicht natürlich als Verantwortung einer Entity oder eines Value Objects ausdrücken lassen.
// Eine Prüfung auf doppelte E-Mail-Adressen kann User nicht allein entscheiden.
class UserDuplicationChecker {
constructor(private readonly userRepository: UserRepository) {}
async exists(email: Email): Promise<boolean> {
return (await this.userRepository.findByEmail(email)) !== null;
}
}
Entity oder Service? Ein einfacher Entscheidungsfluss
Beim Implementieren stellt sich ständig die Frage: "Gehört diese Logik in die Entity oder in einen Service?" Mit dem folgenden Fluss lässt sich das in den meisten Fällen sauber entscheiden.
flowchart TB
Start["Betrifft die Logik..."] --> Q1{"ändert sie nur den Zustand eines einzelnen Objekts?"}
Q1 -- YES --> E["Entity-Methode"]
Q1 -- NO --> Q2{"entscheidet sie über mehrere Aggregate hinweg?"}
Q2 -- YES --> D["Domain Service"]
Q2 -- NO --> Q3{"braucht sie ein Repository oder einen externen Service?"}
Q3 -- YES --> D
Q3 -- NO --> Q4{"klingt sie natürlicher wie ein Verb als wie ein Nomen?"}
Q4 -- YES --> D
Q4 -- NO --> V["Entity / Value Object"]
classDef question fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef answer fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
class Q1,Q2,Q3,Q4 question
class E,D,V answer✅ Was in eine Entity gehört
Logik, die den eigenen Zustand und die eigenen Invarianten schützt, gehört in die Entity.
class Order {
private status: OrderStatus;
private items: OrderItem[];
// ✅ Eigene Zustandsübergänge selbst schützen.
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error("Eine versandte Bestellung kann nicht storniert werden");
}
this.status = OrderStatus.CANCELLED;
}
// ✅ Logik, die nur aus den eigenen Daten berechnet.
totalPrice(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.subtotal()),
Money.zero(),
);
}
// ✅ Die eigene Invariante prüfen.
addItem(item: OrderItem): void {
if (this.items.length >= 100) throw new Error("Das Limit wurde überschritten");
this.items.push(item);
}
}
Ein hilfreicher Test: Wenn es natürlich klingt, die Frage zu stellen "Order, kannst du storniert werden?", dann ist das wahrscheinlich Verantwortung der Entity.
✅ Was in einen Domain Service gehört
Logik, die nicht von einer einzelnen Entity allein entschieden werden kann oder Interaktion mit außen benötigt, gehört in einen Domain Service.
// ✅ Überweisungslogik über zwei Aggregate hinweg.
class TransferService {
transfer(from: Account, to: Account, amount: Money): void {
from.withdraw(amount);
to.deposit(amount);
// Der Begriff "Überweisung" gehört zu keinem einzelnen Konto.
}
}
// ✅ Dublettenprüfung mit Repository.
class UserDuplicationChecker {
constructor(private readonly userRepository: UserRepository) {}
async exists(email: Email): Promise<boolean> {
return (await this.userRepository.findByEmail(email)) !== null;
}
}
Auch hier hilft eine einfache Frage: Wenn du fragst "User, weißt du, ob diese E-Mail schon von jemand anderem registriert wurde?" und die natürliche Antwort lautet "Woher soll ich das wissen?", dann gehört es in einen Service.
❌ Anti-Pattern: alles in Services legen
// ❌ So darf es nicht aussehen.
class OrderService {
cancel(order: Order): void {
if (order.status === "SHIPPED") throw new Error(/* ... */);
order.status = "CANCELLED"; // per Setter überschrieben
}
totalPrice(order: Order): Money { /* Summe über items berechnen */ }
addItem(order: Order, item: OrderItem): void { /* ... */ }
}
class Order {
status: string;
items: OrderItem[];
// nur Getter / Setter ← anämisches Domänenmodell
}
Das ist ein typisches Transaction Script. Die Entity ist zu einer bloßen Datenstruktur verkommen. Die Regel lautet: Wenn sich ein Verhalten mit Order als Subjekt formulieren lässt, dann gehört es in Order.
Kurz zusammengefasst
| Perspektive | In Entity | In Domain Service |
|---|---|---|
| Subjekt | Das Objekt selbst | Verb / Handlung |
| Zustand | Ändert den eigenen Zustand | Zustandslos (stateless) |
| Abhängigkeiten | Hängt nicht von Repository ab | Darf von Repository / externen Systemen abhängen |
| Umfang | Innerhalb eines einzelnen Aggregats | Über mehrere Aggregate hinweg |
| Beispiel | order.cancel() |
transferService.transfer(a, b, money) |
Domain Event
Ein Domain Event ist ein Objekt, das "ein wichtiges Ereignis in der Domäne" ausdrückt. Es passt sehr gut zu Microservices und asynchroner Verarbeitung und wird deshalb in den letzten Jahren besonders häufig genutzt.
class OrderPlaced {
constructor(
public readonly orderId: OrderId,
public readonly userId: UserId,
public readonly occurredAt: Date,
) {}
}
So lassen sich Seiteneffekte lose koppeln, zum Beispiel: "Bestellung aufgegeben -> E-Mail senden" oder "Bestellung aufgegeben -> Punkte gutschreiben".
Schichtenarchitektur
DDD wird häufig in einer Struktur wie dieser umgesetzt.
flowchart TB
P["Presentation-Schicht<br/>UI / API"]
A["Application-Schicht<br/>Use Cases"]
D["Domain-Schicht<br/>Modell / Regeln<br/>Hauptakteur"]
I["Infrastructure-Schicht<br/>DB / APIs"]
P --> A --> D
I --> D
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
class P,A,I layer
class D domainDie Abhängigkeiten zeigen immer nach innen. Die Domänenschicht hängt von keiner anderen Schicht ab. Um das konsequent durchzuhalten, werden oft Onion Architecture oder Hexagonal Architecture (Ports & Adapters) verwendet.
Die Verantwortung jeder Schicht klar halten
In der Praxis entsteht Chaos am leichtesten dort, wo Logik in alle Schichten ausläuft. Es hilft, die Verantwortung jeder Schicht auf einen Satz herunterzubrechen.
| Schicht | Verantwortung | Was sie nicht tun darf |
|---|---|---|
| Presentation | Requests annehmen und Ergebnisse darstellen | Geschäftsregeln schreiben |
| Application | Use Cases koordinieren | Geschäftsregeln schreiben |
| Domain | Die Geschäftsregeln selbst tragen | Von Frameworks oder Datenbanken abhängen |
| Infrastructure | Persistenz und externe Kommunikation umsetzen | Fachliche Entscheidungen treffen |
Der zentrale Grundsatz ist einfach und streng: Geschäftsregeln gehören ausschließlich in die Domain-Schicht. Wenn das eingehalten wird, überlebt die Fachlogik auch einen Frameworkwechsel.
Die Rolle der Presentation-Schicht
Sie nimmt Eingaben von Nutzer:innen oder anderen Systemen entgegen, reicht sie an die Application-Schicht weiter und gibt das Ergebnis zurück. Die goldene Regel lautet: so dünn wie möglich.
// ✅ Guter Controller: er leitet nur weiter.
@Controller("orders")
class OrderController {
constructor(private readonly placeOrder: PlaceOrderUseCase) {}
@Post()
async create(@Body() body: PlaceOrderRequest): Promise<OrderResponse> {
// 1. Request in Form bringen (in DTO umwandeln).
const command = new PlaceOrderCommand(
body.userId,
body.items.map(i => ({ productId: i.productId, quantity: i.quantity })),
);
// 2. An den Use Case delegieren.
const result = await this.placeOrder.execute(command);
// 3. Response formen und zurückgeben.
return OrderResponse.from(result);
}
}
Was die Presentation-Schicht tun soll:
- Request parsen und formal validieren, etwa Pflichtfelder, Typen oder Längen
- Authentifizierung und Autorisierung am Einstieg behandeln
- In das passende Response-Format übersetzen
- HTTP-Statuscodes festlegen
Was sie nicht tun soll, sind fachliche Entscheidungen wie "Wenn kein Bestand vorhanden ist, gib 400 zurück". Das ist Sache der Domain-Schicht. Presentation übersetzt nur das Ergebnis der Domain in die Sprache von HTTP.
Die Rolle der Application-Schicht
Diese Schicht wird in DDD am häufigsten missverstanden. Ein Application Service ist der Koordinator eines Use Cases und enthält keine Geschäftslogik.
// ✅ Guter Application Service: koordiniert nur den Ablauf.
class PlaceOrderUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly productRepository: ProductRepository,
private readonly orderRepository: OrderRepository,
private readonly inventoryService: InventoryService, // Domain Service
private readonly transactionManager: TransactionManager,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: PlaceOrderCommand): Promise<OrderResult> {
return this.transactionManager.run(async () => {
// 1. Benötigte Aggregate laden.
const user = await this.userRepository.findById(command.userId);
if (!user) throw new UserNotFoundError(command.userId);
const products = await this.productRepository.findByIds(
command.items.map(i => i.productId),
);
// 2. Verarbeitung an die Domäne delegieren (Geschäftsregeln bleiben in Domain).
const order = Order.place(user, products, command.items);
// 3. Domain Service aufrufen (Entscheidung über mehrere Aggregate).
await this.inventoryService.reserve(order);
// 4. Persistieren.
await this.orderRepository.save(order);
// 5. Domain Events veröffentlichen.
await this.eventPublisher.publishAll(order.pullEvents());
return OrderResult.from(order);
});
}
}
Die Verantwortung eines Application Service lässt sich auf fünf Punkte verdichten.
- Aggregate laden (Repositories aufrufen)
- An die Domäne delegieren (Entities / Domain Services die Regeln ausführen lassen)
- Transaktionsgrenzen verwalten
- Persistenz anweisen (über Repositories speichern)
- Domain Events veröffentlichen
Typische Beispiele für Code, der nicht in einen Application Service gehört:
// ❌ Anti-Pattern: Geschäftsregeln lecken in die Application-Schicht.
class PlaceOrderUseCase {
async execute(command: PlaceOrderCommand) {
// ❌ Bestandsprüfung hier leckt Domänenwissen nach außen.
if (product.stock < command.quantity) {
throw new Error("Nicht auf Lager");
}
// ❌ Die Gesamtsumme hier zu berechnen ist Orders Aufgabe.
const total = command.items.reduce((s, i) => s + i.price * i.quantity, 0);
// ❌ Den Status hier zu entscheiden ist Orders Aufgabe.
if (user.status === "BLOCKED") throw new Error("Konto gesperrt");
await this.orderRepository.save({ /* ... */ });
}
}
Ein guter Prüfstein lautet: "Wenn ich diesen Code einem Fachbereich zeige, ist darin Fachlogik erkennbar?" Wenn hier per if fachliche Entscheidungen getroffen werden, dann ist das ein klares Zeichen dafür, dass diese Logik in die Domain-Schicht gehört.
Der Ablauf einer Anfrage durch alle Schichten
Schauen wir uns den Ablauf an, wenn in einer Anwendung tatsächlich jemand den Bestellbutton klickt.
sequenceDiagram
participant Browser
participant Presentation as Presentation / OrderController
participant Application as Application / PlaceOrderUseCase
participant Domain as Domain / Order.place(...)
participant Infrastructure as Infrastructure / Repository-Implementierung
Browser->>Presentation: POST /orders
Presentation->>Application: PlaceOrderCommand übergeben
Note over Application: Transaktion starten
Application->>Infrastructure: userRepository.findById()
Application->>Infrastructure: productRepository.findByIds()
Infrastructure-->>Application: User / Product-Aggregate
Application->>Domain: Order.place(user, products, items)
activate Domain
Domain->>Domain: Geschäftsregeln prüfen
Domain->>Domain: Invarianten prüfen
Domain->>Domain: OrderPlaced-Event erzeugen
Domain-->>Application: Order-Aggregat und OrderPlaced
deactivate Domain
Application->>Domain: inventoryService.reserve(order)
Domain-->>Application: Ergebnis der Bestandsreservierung
Application->>Infrastructure: orderRepository.save(order)
Application->>Infrastructure: eventPublisher.publishAll(...)
Infrastructure-->>Application: Persistenz abgeschlossen
Note over Application: Transaktion committen
Application-->>Presentation: OrderResult
Presentation-->>Browser: 201 CreatedMan sieht hier deutlich: Fachliche Entscheidungen fallen ausschließlich innerhalb der Domain-Schicht. Alle anderen Schichten erledigen nur Vorbereitung und Nacharbeit, damit die Domain ihre Aufgabe sauber erfüllen kann.
Die Grenze zwischen DTOs und Domänenobjekten
Wenn Schichten überquert werden, gilt als Best Practice: Domänenobjekte nicht direkt nach außen geben.
// Domain-Schicht
class Order { /* Aggregat mit Geschäftslogik */ }
// Application-Schicht: DTOs für Ein- / Ausgabe
class PlaceOrderCommand { /* Request-DTO */ }
class OrderResult { /* Response-DTO */ }
// Presentation-Schicht: HTTP-Typen
class PlaceOrderRequest { /* JSON-Schema */ }
class OrderResponse { /* Response-JSON */ }
Das wirkt vielleicht umständlich, bringt aber viel:
- Die Domain bleibt unversehrt, auch wenn sich das API-Format ändert
- Serialisierungsanforderungen verschmutzen nicht die Domäne
- Ein Wechsel zu GraphQL oder gRPC bleibt lokal begrenzt
Vor allem sollten Domain Objects niemals mit ORM-Dekoratoren wie @Column oder Serialisierungsannotationen wie @Expose versehen werden. In dem Moment hängt die Domäne am Framework.
Empfohlene Reihenfolge bei der Implementierung
Zum Schluss noch die empfohlene Reihenfolge, wenn ein neues Feature mit DDD umgesetzt wird.
- Den Use Case in einem Satz formulieren: "Ein:e Nutzer:in bestellt ein Produkt"
- Die auftauchenden Begriffe extrahieren: Nutzer:in / Produkt / Bestellung / Bestand
- Aggregate und ihre Grenzen festlegen: Was gehört in das
Order-Aggregate? - Mit der Domain-Schicht beginnen: Entity, Value Object, Domain Service
- Tests für die Domain-Schicht schreiben (sie sollten ohne Datenbank laufen)
- Den Application Service schreiben: Repositories zunächst als Interfaces verwenden
- Die Infrastructure-Schicht implementieren: echte Repository-Implementierungen, ORM-Konfiguration
- Die Presentation-Schicht implementieren: Controller, Request- und Response-Typen
- E2E-Tests
Entscheidend ist, von innen nach außen zu schreiben. Mit dem Datenbankschema zu beginnen, widerspricht dem Geist von DDD. Zuerst kommt das Domänenmodell, danach die Tabellen. Dieses Prinzip sollte nicht aufgegeben werden.
Best Practices für die Ordnerstruktur
Die erste Frage bei DDD-Ordnerstrukturen lautet meist:
Soll man nach Schichten oder nach Domänen/Funktionen schneiden?
Die kurze Antwort lautet: Erst nach Domänen, innerhalb davon nach Schichten.
❌ Schichten zuerst (nahe am Anti-Pattern)
src/
├── controllers/
│ ├── user.controller.ts
│ ├── order.controller.ts
│ └── product.controller.ts
├── services/
│ ├── user.service.ts
│ ├── order.service.ts
│ └── product.service.ts
├── repositories/
│ ├── user.repository.ts
│ ├── order.repository.ts
│ └── product.repository.ts
└── entities/
├── user.entity.ts
├── order.entity.ts
└── product.entity.ts
Am Anfang wirkt das übersichtlich, aber mit wachsender Größe:
- muss man für einen Use Case durch vier Ordner springen
- werden Domänengrenzen unsichtbar
- wird eine spätere Aufteilung in Microservices deutlich schwieriger
✅ Domänen zuerst (empfohlen)
src/
├── contexts/ ← Nach Bounded Contexts organisiert
│ ├── ordering/ ← Bestellkontext
│ │ ├── domain/
│ │ │ ├── order.ts (Entity / Aggregate Root)
│ │ │ ├── order-item.ts
│ │ │ ├── order-id.ts (Value Object)
│ │ │ ├── order-status.ts
│ │ │ ├── order.repository.ts(nur Interface)
│ │ │ └── events/
│ │ │ └── order-placed.ts
│ │ ├── application/
│ │ │ ├── place-order.usecase.ts
│ │ │ └── cancel-order.usecase.ts
│ │ ├── infrastructure/
│ │ │ ├── postgres-order.repository.ts
│ │ │ └── stripe-payment.gateway.ts
│ │ └── presentation/
│ │ ├── order.controller.ts
│ │ └── dto/
│ │ ├── place-order.request.ts
│ │ └── order.response.ts
│ ├── catalog/ ← Produktkatalog-Kontext
│ │ └── (gleiche Struktur)
│ └── shipping/ ← Versandkontext
│ └── (gleiche Struktur)
├── shared-kernel/ ← Context-übergreifende Value Objects
│ ├── money.ts
│ ├── email.ts
│ └── user-id.ts
└── shared/ ← Querschnittliche technische Basis
├── logger.ts
├── transaction-manager.ts
└── event-bus.ts
Dieses Layout hat drei große Vorteile.
- Context-Grenzen werden physisch sichtbar. Eine in sich geschlossene Gestaltung innerhalb von
ordering/entsteht fast automatisch. - Eine spätere Aufteilung in Microservices wird leichter.
contexts/ordering/lässt sich nahezu direkt in ein eigenes Repository auslagern. - Code-Reviews werden fokussierter. Man sieht sofort, ob eine Änderung innerhalb von
orderingbleibt.
Struktur innerhalb der Domain-Schicht
Wenn domain/ zu groß wird, empfiehlt es sich, nach Aggregaten zu schneiden.
ordering/domain/
├── order/
│ ├── order.ts (Aggregatwurzel)
│ ├── order-item.ts
│ ├── order-id.ts
│ ├── order-status.ts
│ ├── order.repository.ts
│ └── events/
│ ├── order-placed.ts
│ └── order-cancelled.ts
├── customer/
│ ├── customer.ts
│ └── customer.repository.ts
└── services/
└── pricing.service.ts (Domain Service)
Wenn ein Aggregate wie eine eigene kleine Welt organisiert ist, werden auch seine Abhängigkeiten deutlich leichter nachvollziehbar.
Struktur im Monorepo
Wenn mehrere Services in einem Repository verwaltet werden, sieht die Struktur typischerweise so aus.
monorepo/
├── apps/
│ ├── web/ (Next.js-Frontend)
│ ├── api/ (Backend-API)
│ └── admin/ (Admin-Oberfläche)
├── packages/
│ ├── ordering-context/ ← Als Paket gekapselter Bounded Context
│ ├── catalog-context/
│ ├── shipping-context/
│ └── shared-kernel/
└── package.json
Der wichtige Punkt ist, dass jeder Bounded Context ein eigenständiges npm-Paket ist. apps/api importiert diese Pakete nur. Dadurch lassen sich Abhängigkeiten zwischen Contexts auf Paketebene erzwingen.
Hinweise zur Benennung
Auch bei Dateinamen und Klassennamen hilft Konsistenz. Diese Regeln funktionieren in der Praxis oft gut.
| Ziel | Benennungsbeispiel |
|---|---|
| Entity / Aggregate Root | Order (Substantiv, Singular) |
| Value Object | Email, Money, OrderId |
| Repository (Interface) | OrderRepository |
| Repository (Implementierung) | PostgresOrderRepository |
| Domain Service | PricingService, TransferService |
| Application Service | PlaceOrderUseCase, CancelOrderUseCase |
| Domain Event | OrderPlaced, OrderCancelled (Vergangenheitsform) |
| Command (DTO) | PlaceOrderCommand |
Gerade die Vergangenheitsform bei Domain Events ist wichtig. Sie macht explizit: "Das ist etwas, das passiert ist", nicht "etwas, das wir gleich tun werden".
Schichtenmodell in Systemen mit UI
Bisher ging es vor allem um Backend-Aspekte. Aber was passiert, wenn zusätzlich eine UI (Web-Frontend) ins Spiel kommt?
Wenn man die vier Backend-Schichten einfach ins Frontend kopiert...
Die Antwort ist einfach: Besser nicht mit Gewalt übernehmen. Das Frontend hat eigene Anforderungen, und wenn man ihm dieselbe Schichtenstruktur wie dem Server aufzwingt, wird es schnell unnatürlich.
[Frontend-spezifische Themen]
- Routing
- Bildschirmzustand (Loading, Fehler, Auswahl usw.)
- Formularvalidierung (für UX)
- Animation
- Reaktives Re-Rendering
Wenn all das in die Domain-Schicht gepresst wird, hängt die Domain plötzlich von React, Vue oder einem anderen Framework ab. Genau das will man vermeiden.
Ein praktikables Frontend-Schichtenmodell
Praktisch hat sich eher folgende Aufteilung bewährt.
flowchart TB
R["Pages / Routes-Schicht<br/>Next.js app/, pages/ usw."]
C["Components-Schicht<br/>UI-Komponenten / Darstellung"]
V["ViewModel / Hooks-Schicht<br/>Zustand und Use-Case-Aufrufe"]
A["Application-Schicht<br/>Use Cases / API-Clients"]
D["Domain-Schicht<br/>minimales Frontend-Domänenmodell"]
R --> C --> V --> A --> D
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef domain fill:#c7d2fe,stroke:#4338ca,color:#1e1b4b
class R,C,V,A layer
class D domainDie Verantwortung der Schichten sieht dann so aus.
| Schicht | Verantwortung | Beispiel |
|---|---|---|
| Pages / Routes | URL und Seite zuordnen | Seite /orders/new |
| Components | Darstellung und Interaktion | <OrderForm />, <Button /> |
| ViewModel / Hooks | Bildschirmzustand verwalten, Use Cases auslösen | useOrderForm() |
| Application | APIs aufrufen, Daten aufbereiten | placeOrderUseCase() |
| Domain | Minimale Domänenregeln im Frontend | Order.canCancel() |
Beispiel mit React + TypeScript
// Domain-Schicht: eine schlanke Version der Backend-Domänenregeln behalten.
class Order {
constructor(
public readonly id: string,
public readonly status: OrderStatus,
public readonly items: OrderItem[],
) {}
// Die UI-Entscheidung "Darf der Cancel-Button aktiv sein?" wird hier gebündelt.
canCancel(): boolean {
return this.status === "PENDING" || this.status === "CONFIRMED";
}
totalPrice(): number {
return this.items.reduce((s, i) => s + i.price * i.quantity, 0);
}
}
// Application-Schicht: Use Case, der die API aufruft.
class PlaceOrderUseCase {
constructor(private readonly api: OrderApiClient) {}
async execute(items: CartItem[]): Promise<Order> {
const response = await this.api.placeOrder({ items });
return Order.fromResponse(response);
}
}
// ViewModel / Hooks-Schicht: Bildschirmzustand verwalten.
function useOrderForm() {
const [items, setItems] = useState<CartItem[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const placeOrder = useMemo(() => new PlaceOrderUseCase(orderApi), []);
const submit = async () => {
setIsSubmitting(true);
try {
await placeOrder.execute(items);
} catch (e) {
setError(formatError(e));
} finally {
setIsSubmitting(false);
}
};
return { items, setItems, isSubmitting, error, submit };
}
// Components-Schicht: nur auf Darstellung konzentrieren.
function OrderForm() {
const { items, isSubmitting, error, submit } = useOrderForm();
return (
<form onSubmit={(e) => { e.preventDefault(); submit(); }}>
{/* JSX */}
</form>
);
}
Wichtig ist hier, dass sich die Component auf die Darstellung konzentrieren kann. Zustandsverwaltung liegt im Hook, fachliche Regeln wie canCancel() in der Domain und API-Aufrufe in der Application-Schicht.
Wie viel Domain braucht das Frontend?
Das hängt von der Art des Projekts ab.
| Art des Projekts | Domain-Schicht im Frontend |
|---|---|
| Fast nur einfache CRUD-Masken | Fast überflüssig, API-Responses können direkt genutzt werden |
| Komplexe Berechnungen oder Entscheidungen in der UI | Sinnvoll, etwa Preisneuberechnung oder Auswahlregeln |
| Offline-Fähigkeit / Mobile | Deutlich sinnvoller (siehe später) |
Wichtig ist außerdem: Es gibt keinen Grund, das serverseitige Domänenmodell 1:1 ins Frontend zu kopieren. Im Frontend genügt alles, was für Nutzerinteraktionen relevant ist.
BFF als Option
Wenn "die Datenform aus dem Backend nicht zum Frontend passt" oder "eine Seite mehrere APIs kombinieren muss", dann kann ein BFF dazwischen sehr hilfreich sein.
flowchart TB
Browser["Browser"]
BFF["BFF<br/>Next.js API Routes / Hono usw.<br/>Für den Bildschirm optimierter Endpunkt"]
Ordering["Bestellservice"]
Catalog["Katalogservice"]
Inventory["Bestandsservice"]
Response["Für den Bildschirm aufbereitete Antwort"]
Browser --> BFF
BFF --> Ordering
BFF --> Catalog
BFF --> Inventory
Ordering --> BFF
Catalog --> BFF
Inventory --> BFF
BFF --> Response --> Browser
classDef layer fill:#eef2ff,stroke:#6366f1,color:#312e81
classDef service fill:#e0e7ff,stroke:#4f46e5,color:#312e81
classDef output fill:#ffffff,stroke:#94a3b8,color:#334155
class Browser,BFF layer
class Ordering,Catalog,Inventory service
class Response outputEin BFF ist eine dünne Schicht nur für den Bildschirm, ohne eigene Domäne. Er kann Daten aggregiert laden oder Ergebnisse aus mehreren Contexts in ein einziges, für die UI optimiertes JSON umformen. Es ist auch naheliegend, ihn als Query-Seite in einem CQRS-Ansatz zu nutzen.
Lässt sich DDD in mobilen Apps einsetzen?
"Ist DDD nicht eher ein Thema für die Serverseite?"
Das hört man oft, aber DDD lässt sich sehr gut auch in mobilen Apps anwenden. Gerade wenn Offline-Fähigkeit oder Synchronisierung eine Rolle spielen, wird der Nutzen eines Domänenmodells oft sogar größer.
Typische Besonderheiten mobiler Apps
| Besonderheit | Auswirkung |
|---|---|
| Offline-Fähigkeit | Lokale Datenbank nötig -> Repository wird wertvoll |
| Push-Benachrichtigungen / Hintergrundverarbeitung | Passt gut zu Domain Events |
| Sensoren / Kamera des Geräts | Als Infrastructure behandeln |
| App-Lebenszyklus | Zustands-Persistenz wird komplexer |
| Versionskompatibilität | Abwärtskompatibilität mit älteren Geräten nötig |
Wenn das alles direkt in Activity, Fragment oder ViewController landet, ist ein System schnell an dem Punkt, an dem ein OS-Update alles zerlegt.
Beispiel für eine Schichtenstruktur in nativen Apps (Swift / Kotlin)
App/
├── Domain/
│ ├── Entities/
│ │ ├── Recipe.swift
│ │ └── Ingredient.swift
│ ├── ValueObjects/
│ │ └── CookingTime.swift
│ ├── Repositories/
│ │ └── RecipeRepository.swift (protocol / interface)
├── Application/
│ └── UseCases/
│ └── SearchRecipeUseCase.swift
├── Infrastructure/
│ ├── Repositories/
│ │ ├── CoreDataRecipeRepository.swift (lokale DB)
│ │ └── ApiRecipeRepository.swift (Remote-API)
│ ├── Storage/
│ │ └── KeyValueStore.swift
│ └── Sync/
│ └── RecipeSyncService.swift
└── Presentation/
├── ViewModels/
│ └── RecipeListViewModel.swift
└── Views/
└── RecipeListView.swift (SwiftUI)
Wie in den vorherigen Abschnitten bleibt der Use Case in der Application-Schicht, während sich die Domain-Schicht auf Modell und Regeln konzentriert.
Beispiel: Wo Repository bei Offline-Unterstützung glänzt
// Domain-Schicht: mehrere Persistenzziele abstrahieren.
protocol RecipeRepository {
func find(id: RecipeId) async throws -> Recipe?
func save(_ recipe: Recipe) async throws
}
// Infrastructure-Schicht: lokal + remote zusammensetzen.
class HybridRecipeRepository: RecipeRepository {
let local: CoreDataRecipeRepository
let remote: ApiRecipeRepository
func find(id: RecipeId) async throws -> Recipe? {
// Auch offline aus dem lokalen Speicher lesbar.
if let cached = try await local.find(id: id) {
return cached
}
let remote = try await remote.find(id: id)
if let recipe = remote {
try await local.save(recipe) // Cache aktualisieren
}
return remote
}
}
Weil die Offline-Logik innerhalb der Repository-Implementierung gekapselt ist, müssen ViewModel und Domäne nur noch "holen" sagen. Genau darin liegt die Stärke von DDD.
Einsatz in React Native / Flutter
Auch in Cross-Platform-Projekten gilt dieselbe Denkweise. Ein großer Vorteil ist sogar, dass eine gemeinsame Domain-Schicht zwischen Web und Mobile geteilt werden kann.
shared/
└── domain/ ← Von Web und Mobile gemeinsam genutzt
├── recipe.ts
└── recipe.repository.ts
apps/
├── web/
│ └── infrastructure/
│ └── api-recipe.repository.ts
└── mobile/
└── infrastructure/
└── sqlite-recipe.repository.ts (offline first)
Dass diese Struktur funktioniert, liegt genau daran, dass die Domain von nichts anderem abhängt. Das ist DDD in seiner reinsten Form.
Aber bitte leichtgewichtig bleiben
Die komplette serverseitige DDD-Ausstattung in eine mobile App zu tragen, ist meist zu viel. In mobilen Anwendungen gilt oft:
- Aggregate-Grenzen müssen nicht so streng sein wie auf dem Server, weil Konsistenz letztlich serverseitig abgesichert wird
- Domain Events reichen häufig als lokale Event-Benachrichtigungen
- CQRS ist in vielen Fällen nicht notwendig
Ein realistisches Gleichgewicht ist, eine schlanke Client-Version des serverseitigen Domänenmodells zu behalten.
Koexistenz von CRUD, Admin-Oberflächen und DDD
Wer bis hier gelesen hat, denkt vielleicht: "Der Großteil unserer App besteht aus Einstellungsseiten und Admin-Masken. Ist das dann überhaupt DDD?" Dieses Unbehagen ist berechtigt.
DDD ist für komplexe Domänen gedacht. Dort, wo keine echte fachliche Komplexität vorliegt, macht es den Code oft nur unnötig länger.
Das "Gewicht" einer Domäne einschätzen
Selbst innerhalb eines Projekts ist die Komplexität je nach Funktion unterschiedlich.
| Funktion | Quelle der Komplexität | Umsetzungsstil |
|---|---|---|
| Bestellabwicklung | Allokation, Preislogik, komplexe Zustandswechsel | Mit DDD investieren |
| Bestandsmanagement | Reservierung, Freigabe, Konsistenzwahrung | Mit DDD investieren |
| Produktstammdaten | größtenteils CRUD | Leichtgewichtig ist ausreichend |
| Nutzereinstellungen | bloßes Speichern einfacher Werte | CRUD genügt |
| Admin-Logansicht | nur abfragen und anzeigen | CRUD genügt |
Wichtig ist hier, dass sich die Einteilung in Core, Supporting und Generic direkt auf den Implementierungsstil übertragen lässt.
| Domänenkategorie | Implementierungsstil | Beispiel |
|---|---|---|
| Core Domain | Volles DDD | Auftragsabwicklung, Bestand, Preisstrategie |
| Supporting Subdomain | Leichtgewichtiges DDD oder Transaction Script | Produktstammdaten, Versandkonfiguration |
| Generic Subdomain | CRUD oder SaaS | Authentifizierung, E-Mail-Versand, Einstellungsseiten |
Wie schreibt man die CRUD-Teile?
Leichte Bereiche dürfen ganz unkompliziert als Transaction Script umgesetzt werden.
// ✅ Für das Aktualisieren eines Einstellungsbildschirms reicht das.
class UpdateSiteSettingsHandler {
constructor(private readonly db: Database) {}
async execute(input: UpdateSiteSettingsInput): Promise<void> {
await this.db.siteSettings.update({
where: { id: 1 },
data: input,
});
}
}
Wenn man hier mit Gewalt Entities, Repositories und Domain Services einführt, steigt nur die Code-Menge, ohne dass man etwas gewinnt.
Beide Stile im selben Projekt mischen
In der Praxis ist es realistisch, je nach Context unterschiedliche Implementierungsstile zuzulassen.
src/contexts/
├── ordering/ ← Volles DDD
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── presentation/
├── inventory/ ← Volles DDD
│ └── (wie oben)
├── catalog/ ← Leichtes DDD (Entities behalten, Services klein halten)
│ └── (vereinfachte Version)
└── admin/ ← CRUD-basiert
├── handlers/ (nur dünne Handler)
└── controllers/
Solange es innerhalb eines Contexts konsistent bleibt, ist das völlig in Ordnung. Es ist nicht nötig, im gesamten Projekt überall denselben Stil durchzusetzen.
Für read-only Admin-APIs ist CQRS oft praktisch
Ein typischer Fall in Admin-Oberflächen ist: "Ich möchte Daten aus mehreren Aggregaten für eine Liste zusammenführen." Das ist fast immer lesen über Aggregate hinweg und passt deshalb schlecht zu den strengen Regeln der Aggregate in DDD.
Hier hilft ein leichtgewichtiges CQRS: Schreiboperationen (Command) laufen strikt über Aggregate, Leseoperationen (Query) dürfen mit rohem SQL umgesetzt werden.
// Schreiben läuft über Aggregate (streng).
class CancelOrderUseCase {
async execute(orderId: OrderId): Promise<void> {
const order = await this.orderRepository.findById(orderId);
order.cancel(); // Domänenregel
await this.orderRepository.save(order);
}
}
// Read-only-Abfragen dürfen rohes SQL nutzen (für den Bildschirm optimiert).
class OrderListQuery {
async findForAdmin(filter: AdminFilter): Promise<OrderListView[]> {
return this.db.raw(`
SELECT o.id, o.status, u.name AS user_name, p.name AS product_name, ...
FROM orders o
JOIN users u ON ...
JOIN order_items oi ON ...
WHERE ...
`);
}
}
Man sollte nicht Aggregate einzeln laden und dann im Speicher zusammenjoinen. Für Reads nutzt man Read-Werkzeuge. Das ist der pragmatische Weg.
Prinzipien für die Mischung von CRUD und DDD
Zum Abschluss die wichtigsten Regeln beim Mischen beider Ansätze.
- Context-Grenzen sauber ziehen - Auch dort, wo kein volles DDD eingesetzt wird, muss die Grenze klar sein
- Bei der Core Domain nicht sparen - Wer dort abkürzt, bereut es später fast immer
- CRUD-Bereiche schlicht halten - Kein künstliches DDD, wo es nichts bringt
- Für Reads einen separaten Weg zulassen - Read Models oder CQRS Lite nutzen
- Später aufwerten, wenn es komplex wird - Was als CRUD beginnt, kann später zu DDD migrieren
DDD ist keine Religion, sondern ein Werkzeug. Der Aufwand muss zur Komplexität der Domäne passen.
Häufige Fallstricke
1. Anämisches Domänenmodell
Eine Entity besteht nur noch aus Gettern und Settern, während die Logik in Services ausläuft. Merke dir: Ein Objekt ohne Verhalten ist kein richtiges Objekt.
2. DDD auf alles anwenden
Einstellungsseiten, Admin-Masken, einfache CRUD-Flows: Wenn DDD überall hineingetragen wird, sinkt die Entwicklungsgeschwindigkeit ganz sicher. Es sollte nur dort eingesetzt werden, wo eine komplexe Domäne vorhanden ist. Der Abschnitt zur Koexistenz von CRUD, Admin-Oberflächen und DDD behandelt das ebenfalls.
3. Nur taktische Muster herauspicken
"Ich habe Entities und Repositories gebaut, also ist das DDD" - so einfach ist es nicht. Ohne die Diskussion über Ubiquitous Language und Bounded Contexts entfalten taktische Muster nur einen Bruchteil ihrer Wirkung.
Schritte für den Einstieg in DDD
Wenn du DDD in der Praxis einführen willst, ist ein schrittweises Vorgehen deutlich sinnvoller als ein sofortiger Vollausbau.
- Die Sprache des Geschäfts sammeln - Gemeinsam mit Domänenexpert:innen ein Glossar aufbauen
- Grenzen bewusst machen - Abgrenzen, wo derselbe fachliche Kontext beginnt und endet
- Mit Value Objects anfangen - Primitive Typen durch bedeutungsvolle Typen ersetzen
- Aggregate identifizieren - In Transaktionsgrenzen denken
- Mit Events entkoppeln - Domain Events dort einführen, wo es nötig ist
Fazit
Für mich besteht das Wesen von DDD darin, der Komplexität der Domäne im Code direkt ins Gesicht zu sehen.
Wenn das Auswendiglernen von Mustern zum Selbstzweck wird, wird der Code oft eher komplizierter. Die Ubiquitous Language als Team gemeinsam zu entwickeln ist der eigentliche Startpunkt von DDD - und in gewisser Weise auch sein Ziel.
Vielleicht beginnt der nächste Code-Review ja mit einer einfachen Frage: "Würde dieser Klassenname auch für die Fachseite Sinn ergeben?"
Literatur
- Eric Evans "Domain-Driven Design: Tackling Complexity in the Heart of Software"
- Eric Evans "DDD Reference: Definitions and Pattern Summaries" https://www.domainlanguage.com/ddd/reference/
- Vaughn Vernon "Implementing Domain-Driven Design"
- Masanobu Naruse "Einführung in Domain-Driven Design"
- Toru Masuda "Prinzipien des Systemdesigns für die Praxis"
- Martin Fowler "Patterns of Enterprise Application Architecture"
- Robert C. Martin "Clean Architecture"
- Alberto Brandolini "Introducing EventStorming"
