Sechs DDD-Regeln zur Strukturierung von Domänen in NestJS-Anwendungen
Erfahren Sie sechs praktische Regeln des domain-getriebenen Designs zur Organisation von NestJS-Modulen, Entitäten und Ereignissen, damit Funktionen isoliert und wartbar bleiben.
Bereits nach einem halben Jahr in den meisten NestJS-Codebasen tritt ein bekanntes Problem auf. Man fügt ein einzelnes Feld hinzu, um eine Funktion zu unterstützen, und plötzlich versagt ein Test in einem völlig unabhängigen Teil der Anwendung. Jemand im Team fragt, wo eigentlich die Logik für die Auftragsverarbeitung zu finden ist – und die ehrliche Antwort lautet: „Sie ist überall etwas verstreut.“
Das ist kein Zeichen für nachlässiges Arbeiten. In der Regel zeigt es vielmehr darauf hin, dass der Code nach technischen Schichten organisiert wurde statt nach den Konzepten, die er darstellt. Ein vollständiges Domain-Driven Design ist ein umfangreiches Konzept, das die meisten Teams nie vollständig übernehmen. Was folgt, ist eine vereinfachte Variante: sechs Regeln, die in einer Nest-Anwendung tatsächlich Vorteile bringen, wobei die überflüssigen Formalitäten weggelassen werden. Man kann das als selektives DDD betrachten.
Regel 1 – Lassen Sie kein einziges Modell die gesamte Anwendung bedienen
Fast jedes verworrene Backend hat ein „God Object“ in seinem Zentrum. Oft wird es Order genannt, es verfügt über Dutzende von optionalen Spalten, und die Hälfte des Codebases importiert es. Irgendwann führt eine Änderung, die für das Lagerteam vorgenommen wurde, stillschweigend dazu, dass der Checkout nicht mehr funktioniert.
Dieses einzige Order ist eigentlich drei verschiedene Aufgabenbereiche, die sich unter einem Namen verbergen:
- Checkout kümmert sich um Preise, Rabatte und eine Zahlungsoption.
- Fulfillment kümmert sich um SKUs und eine Versandadresse – aber nicht um Rabatte.
- Billing kümmert sich um einen Betrag und eine Rechnungsnummer.
Wenn eine Klasse versucht, alle drei Aufgaben zu erfüllen, landet ein Feld für Rabatte direkt neben der Versandadresse. Ändert man eines davon, besteht die Gefahr, dass die anderen beiden beeinträchtigt werden.
Die Lösung besteht darin, jedem Bereich sein eigenes Modell zu geben und es ihnen zu ermöglichen, über Nachrichten miteinander zu kommunizieren, anstatt eine gemeinsame Klasse zu verwenden.
class Cart {
lines: CartLine[];
discount: Money;
paymentIntentId: string;
}
class FulfillmentOrder {
orderId: string;
shipTo: Address;
picks: Pick[];
}
this.events.emit(new OrderPlaced(order.id, order.shipTo, picks));
Beachten Sie, dass FulfillmentOrder nur über orderId identifiziert wird und keinerlei Preis- oder Rabattdaten enthält – der Checkout übermittelt den Erfüllungsvorgang durch das Ereignis OrderPlaced, anstatt seine eigene interne Klasse offenzulegen. Das bedeutet, dass eine Preisänderung niemals in die Lagerlogik eindringen kann, nur weil der Compiler es zulässt. Jeder dieser Bereiche bildet einen begrenzten Kontext: sein eigenes Modell, in dem das Wort „Bestellung“ eine präzise Bedeutung hat. In einem Monolithen könnte das einfach eine Gruppe von Modulen sein, die ihre eigenen Datenbanktabellen besitzen; in einer Microservices-Architektur könnte es sich um einen völlig separaten Dienst handeln. In jedem Fall gilt die Regel – teilen Sie niemals ein Modell über die Grenzen hinweg.
Ein nützlicher Test ist folgender: Eine Änderung innerhalb eines Kontexts sollte Sie niemals dazu zwingen, einen anderen zu bearbeiten. Wenn dem so ist, sind Ihre Grenzen an der falschen Stelle gezogen.
Regel 2 – Ein Modul ist ein Domänenbereich, keine Schicht
Stellen Sie sich vor, Ihr Produktmanager bittet um Unterstützung bei der Verpackung von Bestellungen als Geschenk. Schauen Sie sich an, was das kostet, wenn das Projekt nach technischen Rollen statt nach Sachgebieten organisiert ist:
src/
├── controllers/ # order, auth, product, shipment, payment...
├── services/ # order, auth, product, shipment, payment...
├── entities/
└── enums/ # every enum in the whole app
Sie öffnen den Ordner controllers/ und scrollen an Authentifizierungs- und Versandfunktionen vorbei, um den Bestellcontroller zu finden. Anschließend wiederholen Sie diesen Scrollvorgang in services/, sowie erneut in entities/ und enums/. Vier oder fünf Ordner, vier oder fünf Scrollvorgänge – und die einzige Funktion, die Sie hinzufügen möchten, ist in jedem dieser Ordner verteilt.
Diese Struktur beantwortet die Frage „Zeigen Sie mir alle Controller“, was eigentlich fast niemand stellt. Die eigentliche Frage lautet vielmehr „Zeigen Sie mir alles, was mit Bestellungen zu tun hat.“ Strukturieren Sie daher den Code zunächst nach Domänen:
modules/orders/
├── controllers/
├── dto/
├── entities/
│ ├── order.entity.ts
│ └── order-status.enum.ts # the enum sits next to what it uses
├── repositories/
└── orders.module.ts
Mit dieser Struktur betrifft die Verpackung von Geschenken genau einen Ordner. Beachten Sie, dass es keine allgemeine enums/-Verzeichnis-Ebene auf höchster Ebene gibt – eine Enumeration gehört neben dem, was sie beschreibt. Die einzige Einschränkung: common/ sollte nur Inhalte enthalten, die keiner bestimmten Domäne zugehören, wie beispielsweise Hilfsfunktionen für die Seitenverteilung oder eine Basis-Klasse für Repositorien. Sobald common/ anfängt, zu wissen, was eine Bestellung ist, wird es faktisch zu einem weiteren Modul in Verkleidung.
Regel 3 – Module sollten von Schnittstellen abhängen, nicht voneinander gegenseitig von Diensten
Stellen Sie sich zwei Funktionen vor, die im selben Sprint veröffentlicht werden sollen. Die Produktseite muss „3 offene Bestellungen“ anzeigen, weshalb catalog darauf zugreift und OrderService einbindet. Gleichzeitig benötigt die Quittung Produktnamen, weshalb orders ProductService einbindet. Nest weigert sich, dies miteinander zu verknüpfen:
Nest cannot create the CatalogModule instance.
- A circular dependency between modules. Use forwardRef() to avoid it.
Das Einbinden in forwardRef() lässt den Fehler verschwinden, führt aber auch dazu, dass die beiden Module dauerhaft miteinander verbunden werden. Die eigentliche Lösung besteht darin, von einer kleinen von einem selbst definierten Schnittstelle abzuhängen, anstatt auf die Dienste eines anderen Moduls zuzugreifen.
catalog muss die Löschung eines Produkts verhindern, das sich noch in einer offenen Bestellung befindet – doch nur orders besitzt diese Information. Anstatt orders zu importieren, definiert catalog einfach die Frage, auf deren Beantwortung es angewiesen ist:
export interface ProductUsageGuard {
isProductInUse(productId: string): Promise<boolean>;
}
Das orders-Modul liefert die Antwort, indem es diese Schnittstelle implementiert und sich selbst registriert, sodass catalog die Anfrage stellen kann, ohne jemals etwas aus orders importieren zu müssen:
for (const guard of this.guards) {
if (await guard.isProductInUse(id)) throw new ProductInUseError(id);
}
Before: catalog ⇄ orders circular — Nest won't boot
After: catalog ◄──implements── orders one way — catalog owns the interface
Die Abhängigkeit zeigt nun in eine einzige Richtung, wodurch es keinen Zyklus gibt und forwardRef() nicht erforderlich ist. Als Bonus kann das Abonnementsmodul, falls später eine Regel wie „Ein Produkt, das mit einer aktiven Abonnementverbindung verbunden ist, darf nicht gelöscht werden“, hinzukommt, seinen eigenen Schutzmechanismus registrieren, und catalog muss dabei überhaupt nicht geändert werden.
Regel 4 – Halten Sie Controller schlank und überlassen Sie die Logik den Entitäten
Betrachten Sie eine Regel wie „Man kann eine bereits versandte Bestellung nicht stornieren.“ Wo sollte diese Logik untergebracht werden? In vielen Codebasen landet sie dort, wo sie zum ersten Mal benötigt wird – in der Regel versteckt innerhalb eines Services. Dann braucht das Admin-Dashboard dieselbe Überprüfung, genauso wie der nächtliche Batch-Job, und schließlich auch ein webhook-Handler. Jeder Ort implementiert die Regel etwas anders, jemand vergisst die vierte Kopie, und plötzlich erhalten bereits versandte Bestellungen Rückerstattungen.
Wenn eine Entität nichts weiter ist als ein Satz öffentlicher Felder, die von anderem Code direkt verändert werden, entsteht ein schwaches Modell – und das Symptom ist immer dasselbe: Geschäftsregeln dringen nach außen und werden in jedem Service kopiert, der mit den Daten arbeitet.
Anstatt dessen sollte die Regel dem Objekt zugeordnet werden, das tatsächlich den Zustand besitzt:
@Entity()
export class Order {
status: OrderStatus = OrderStatus.DRAFT;
cancel(): void {
if (this.status === OrderStatus.SHIPPED) {
throw new Error('Cannot cancel an order that already shipped');
}
this.status = OrderStatus.CANCELLED;
}
Nun gibt es genau einen Ort, an dem „cancel“ definiert ist, und kein Aufrufer hat eine Möglichkeit, die Überprüfung zu umgehen – es gibt einfach keinen alternativen Weg. Es ist trivial, Unit-Tests durchzuführen, ohne eine Datenbank anzufassen. Die Service-Schicht koordiniert lediglich die Schritte (order.cancel(), Rückerstattung ausstellen, persistieren), während der Controller fast vollständig entfällt:
@Post(':id/cancel')
cancel(@Param('id') id: string) {
return this.orders.cancel(id);
}
So sieht die Aufgabenteilung in einem Überblick aus:
HTTP ─► Controller ─► Service ─► Order (the rules)
└─────► Repository ─► DB (the queries)
Entitäten stellen die Regeln sicher, Repositorien kümmern sich um Abfragen, Services koordinieren die Reihenfolge der Aufrufe und Controller befassen sich ausschließlich mit HTTP.
Regel 5 – Entwerfen Sie Ihre Daten so, dass ungültige Zustände nicht existieren können
Regel 4 verlagerte die Logik auf Entitäten. Zwei weitere Muster erledigen den Rest, wobei jedes eine bestimmte Art von Fehler ausschließt.
Wertobjekte handhaben Primitive, die Regeln enthalten. Die Gesamtsumme einer Bestellung ist lediglich eine Zahl; dadurch wird nicht verhindert, dass ein Gutschein sie unter Null drückt oder dass eine Rückerstattung in EUR auf eine mit USD berechnete Bestellung angewendet wird. Die Regeln, die „Geld“ definieren, befinden sich nicht an einem bestimmten Ort. Dies lässt sich beheben, indem man dem Geld einen eigenen Typ gibt, der diese Regeln durchsetzt:
export class Money {
private constructor(readonly cents: number, readonly currency: string) {}
static of(cents: number, currency: string): Money {
if (cents < 0) throw new Error('Money cannot be negative');
return new Money(cents, currency);
}
add(o: Money): Money {
if (o.currency !== this.currency) throw new Error('Currency mismatch');
return Money.of(this.cents + o.cents, this.currency);
}
}
Sobald dies implementiert ist, können keine negativen Gesamtsummen oder Währungsunterschiede mehr entstehen – der Typ selbst verhindert das. Das ist ein Wertobjekt: ein kleiner, unveränderlicher Typ, der durch seinen Wert und nicht durch eine ID identifiziert wird. Verwenden Sie solche Objekte immer dann, wenn ein Primitiv mit Regeln verbunden ist, die Sie immer wieder überprüfen müssen – wie Geld, E-Mail-Adressen oder CIDR-Bereiche – doch vermeiden Sie sie bei etwas Einfachem wie einer reinen Identifikationsnummer.
Aggregaten verarbeiten eine Regel, die mehrere Objekte umfasst. Die Gesamtsumme einer Bestellung muss stets der Summe ihrer Einträge entsprechen. Wenn OrderLine sein eigenes Repository erhält, wird früher oder später jemand einen Eintrag speichern, ohne die übergeordnete Bestellung zu aktualisieren, wodurch die Gesamtsumme unbemerkt falsch wird. Die Lösung besteht darin, diesen Weg von vornherein nicht zuzulassen: Machen Sie Order zum Wurzelelement des Aggregats – das einzige Objekt, das Sie laden oder speichern, der Eingangspunkt zu diesem Teil des Modells. Es gibt kein OrderLineRepository; Einträge werden ausschließlich über die Bestellung selbst geändert:
addLine(sku: string, price: Money, qty: number): void {
if (this.status !== OrderStatus.DRAFT) throw new Error('Order already placed');
this.lines.push(new OrderLine(sku, price, qty));
this.total = this.sumOfLines();
}
Durch einen einzigen Eingangspunkt kann die Invarianz nicht versehentlich verletzt werden. Halten Sie Ihre Aggregate so klein wie möglich – nur das, was tatsächlich innerhalb derselben Transaktion geändert werden muss – und beziehen Sie sich auf andere Aggregate über IDs anstelle von direkten Objektreferenzen.
Regel 6 – Ereignisse veröffentlichen anstelle direkter Aufrufe von Services
Der Checkout beginnt einfach, doch dann erweitert sich place() stetig: Bestellung speichern, Versand anfordern, Rechnung erstellen, E-Mail senden. Zu diesem Zeitpunkt importiert orders bereits die Hälfte der Anwendung und muss über jeden nachfolgenden Schritt Bescheid wissen. Fügen Sie im nächsten Quartal eine Funktion für Loyalitätspunkte hinzu, müssen Sie wieder am Checkout-Modul anpassen – obwohl das nichts mit dem eigentlichen Checkout zu tun hat.
Drehen Sie die Richtung um. orders erledigt seine eigene Aufgabe und teilt anschließend mit, was passiert ist – es hat keine Ahnung, wer, falls überhaupt jemand, zuhört:
this.events.emit(new OrderPlaced(order.id, order.customerId, items));
Jeder interessierte Kontext reagiert unabhängig:
@OnEvent(OrderPlaced.name)
handle(e: OrderPlaced) { return this.shipping.createShipment(e); }
Das Hinzufügen von Loyalitätspunkten bedeutet derzeit, einen Zuhörer innerhalb des Loyalitätsmoduls hinzuzufügen; orders bleibt unberührt. In separaten Diensten gilt die gleiche Idee über einen Nachrichtenbroker (zum Beispiel RabbitMQ), wobei eine zusätzliche Sicherheitsmaßnahme vorhanden ist: ein transaktionaler Outbox. Sie schreiben das Ereignis in eine outbox-Tabelle innerhalb derselben Transaktion, in der die Bestellung gespeichert wird, und ein separater Worker veröffentlicht es anschließend. Ohne diesen Schritt führt ein Absturz zwischen dem Speichern der Bestellung und der Veröffentlichung des Ereignisses dazu, dass das Ereignis unauffällig verloren geht – in der Produktion ist das der Unterschied zwischen einem zuverlässigen System und einem problembehafteten.
Eine Vorsichtsmaßnahme: Ereignisse verschleiern den Gesamtfluss, da kein einziger Ort die vollständige Abfolge der Ereignisse anzeigt. Verwenden Sie sie für Reaktionen, die Kontextgrenzen überschreiten, nicht für Schritte, die zu einer zusammenhängenden Aufgabe gehören.
Der Vorteil
Die selektive Anwendung von DDD geht nicht darum, weitere Architekturebenen hinzuzufügen. Es geht vielmehr darum, jeden logischen Bestandteil an den richtigen Platz zu setzen: Modelle enthalten die Regeln, Module verwalten ihre Domänen, Repositorien kümmern sich um Abfragen und Ereignisse verbinden einen Kontext mit einem anderen. Halten Sie sich an diese Grenzen, dann bleibt Ihre NestJS-Anwendung im Laufe der Zeit leichter verständlich, modifizierbar und erweiterbar.
Verwandte Artikel
- Warum NestJS für wachsende Backend-Teams und Codebasen vorteilhaft ist — Erfahren Sie, wie NestJS’ Struktur, die Abhängigkeitsinjektion sowie ein auf TypeScript ausgerichtetes Design Ingenieurteams dabei helfen, skalierbar zu bleiben, ohne in Chaos abzurutschen.