DDDDomain-Driven DesignArchitectureSoftware Design

Domain-Driven Design (DDD) noch einmal systematisch erklärt

Sloth255
Sloth255
·20 min read·4,280 words

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 note

Die 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 domain

Die 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.

  1. Aggregate laden (Repositories aufrufen)
  2. An die Domäne delegieren (Entities / Domain Services die Regeln ausführen lassen)
  3. Transaktionsgrenzen verwalten
  4. Persistenz anweisen (über Repositories speichern)
  5. 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 Created

Man 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.

  1. Den Use Case in einem Satz formulieren: "Ein:e Nutzer:in bestellt ein Produkt"
  2. Die auftauchenden Begriffe extrahieren: Nutzer:in / Produkt / Bestellung / Bestand
  3. Aggregate und ihre Grenzen festlegen: Was gehört in das Order-Aggregate?
  4. Mit der Domain-Schicht beginnen: Entity, Value Object, Domain Service
  5. Tests für die Domain-Schicht schreiben (sie sollten ohne Datenbank laufen)
  6. Den Application Service schreiben: Repositories zunächst als Interfaces verwenden
  7. Die Infrastructure-Schicht implementieren: echte Repository-Implementierungen, ORM-Konfiguration
  8. Die Presentation-Schicht implementieren: Controller, Request- und Response-Typen
  9. 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.

  1. Context-Grenzen werden physisch sichtbar. Eine in sich geschlossene Gestaltung innerhalb von ordering/ entsteht fast automatisch.
  2. Eine spätere Aufteilung in Microservices wird leichter. contexts/ordering/ lässt sich nahezu direkt in ein eigenes Repository auslagern.
  3. Code-Reviews werden fokussierter. Man sieht sofort, ob eine Änderung innerhalb von ordering bleibt.

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 domain

Die 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 output

Ein 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.

  1. Context-Grenzen sauber ziehen - Auch dort, wo kein volles DDD eingesetzt wird, muss die Grenze klar sein
  2. Bei der Core Domain nicht sparen - Wer dort abkürzt, bereut es später fast immer
  3. CRUD-Bereiche schlicht halten - Kein künstliches DDD, wo es nichts bringt
  4. Für Reads einen separaten Weg zulassen - Read Models oder CQRS Lite nutzen
  5. 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.

  1. Die Sprache des Geschäfts sammeln - Gemeinsam mit Domänenexpert:innen ein Glossar aufbauen
  2. Grenzen bewusst machen - Abgrenzen, wo derselbe fachliche Kontext beginnt und endet
  3. Mit Value Objects anfangen - Primitive Typen durch bedeutungsvolle Typen ersetzen
  4. Aggregate identifizieren - In Transaktionsgrenzen denken
  5. 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"