Technischer Leitfaden

Domain-Driven Design in der Praxis: Wie wir Modulgrenzen ziehen

Praktische DDD-Muster für TypeScript und NestJS. Modulgrenzen ziehen, EventBus als Anti-Corruption Layer, wann DDD übertrieben ist, Shared Kernels und Refactoring in Richtung Grenzen.

6. Februar 202614 Min. LesezeitOronts Engineering Team

DDD ist nicht das Blue Book

Die meisten DDD-Artikel starten mit Aggregates, Value Objects und Domain Events aus Eric Evans' Buch. Das ist Theorie. In der Praxis ist das wertvollste Konzept aus DDD genau eines: Grenzen.

Wo hört Modul A auf und wo fängt Modul B an? Welche Daten gehören welchem Modul? Wie kommunizieren sie? Wenn du die Grenzen richtig setzt, bleibt die Codebasis sauber, während sie wächst. Wenn du sie falsch setzt, berührt jedes Feature jedes Modul, und Refactoring wird unmöglich.

Wir haben Modulgrenzen in mehreren Produktionssystemen gezogen: einem 6-Modul Vendure Plugin, einer 8-Service Ticketing-Plattform und einem PIM mit 13 Event-Subscribern und komplexer Worker-Orchestrierung. Dieser Artikel behandelt die praktischen Muster. Für Event-basierte Kommunikation über Grenzen hinweg schau dir unseren Event-Driven-Architektur-Guide an. Für Vendure-spezifische Muster den Vendure-Plugin-Architektur-Guide.

Die Dependency-Regel

Der einfachste Weg, Modulgrenzen zu identifizieren: Zeichne einen Dependency-Graphen. Wenn Modul A etwas aus Modul B importiert und Modul B etwas aus Modul A, hast du eine zirkuläre Abhängigkeit. Die Grenze ist falsch.

// SCHLECHT: zirkuläre Abhängigkeiten
WishlistService importiert LoyaltyService   (um Punkte zu vergeben)
LoyaltyService importiert WishlistService   (um Wishlist für Bonus zu prüfen)

// GUT: Event-basierte Kommunikation
WishlistService emitiert WishlistItemAddedEvent
LoyaltyService abonniert WishlistItemAddedEvent (vergibt Punkte)
LoyaltyService emitiert PointsAwardedEvent
WishlistService ist das egal (kein Abonnement nötig)

Die Regel: Abhängigkeiten fließen in eine Richtung. Wenn zwei Module bidirektional kommunizieren müssen, verwende Events. Events sind per Design unidirektional. Der Emitter weiß nicht (und es ist ihm egal), wer abonniert.

Wie prüfen

# Zirkuläre Imports in einem TypeScript-Projekt finden
npx madge --circular src/

# Cross-Modul-Imports finden
grep -rn "from '.*wishlist.*'" src/modules/loyalty/
grep -rn "from '.*loyalty.*'" src/modules/wishlist/

Wenn diese Befehle Ergebnisse liefern, hast du Grenzverletzungen. Behebe sie, indem du den gemeinsamen Concern in ein separates Modul extrahierst oder den Import durch ein Event ersetzt.

EventBus als Anti-Corruption Layer

Der EventBus ist nicht nur ein Messaging-System. Er ist die Anti-Corruption-Schicht zwischen Modulen. Jedes Modul veröffentlicht Events, die beschreiben, was in seiner Domäne passiert ist. Andere Module abonnieren und übersetzen diese Events in ihre eigenen Domänenkonzepte.

// Wishlist-Modul: veröffentlicht Domain Event
export class WishlistItemAddedEvent extends VendureEvent {
    constructor(
        public ctx: RequestContext,
        public wishlistId: string,
        public productVariantId: string,
        public customerId: string,
    ) {
        super();
    }
}

// Wishlist-Service: emitiert Event nach Business-Logik
@Injectable()
export class WishlistService {
    constructor(private eventBus: EventBus) {}

    async addItem(ctx: RequestContext, input: AddItemInput): Promise<WishlistItem> {
        // Business-Logik: validieren, Duplikate prüfen, speichern
        const item = await this.saveItem(ctx, input);

        // Event veröffentlichen: "etwas ist in meiner Domäne passiert"
        await this.eventBus.publish(
            new WishlistItemAddedEvent(ctx, input.wishlistId, input.productVariantId, ctx.activeUserId)
        );

        return item;
    }
}

// Loyalty-Modul: abonniert und übersetzt in eigene Domäne
@Injectable()
export class LoyaltyEventHandler {
    constructor(
        private eventBus: EventBus,
        private loyaltyService: LoyaltyService,
    ) {
        // Abonnieren: Wishlist-Event in Loyalty-Konzept übersetzen
        this.eventBus.ofType(WishlistItemAddedEvent).subscribe(async event => {
            await this.loyaltyService.awardPoints(
                event.ctx,
                event.customerId,
                'wishlist_add',  // Loyalty-eigenes Konzept, nicht Wishlist's
                10,              // Punkteanzahl (Loyalty-Entscheidung)
            );
        });
    }
}

Was das zum Anti-Corruption Layer macht

  • Das Wishlist-Modul weiß nicht, dass Loyalty existiert. Es veröffentlicht WishlistItemAddedEvent unabhängig von Abonnenten.
  • Das Loyalty-Modul importiert keine Wishlist-Services. Es hängt nur von der Event-Klasse ab.
  • Die Punkteanzahl (10) ist eine Loyalty-Domänen-Entscheidung, kein Wishlist-Anliegen.
  • Wenn Loyalty entfernt wird, funktioniert Wishlist unverändert weiter.
  • Wenn ein neues Modul (Analytics) auf Wishlist-Hinzufügungen reagieren will, abonniert es dasselbe Event. Null Änderungen an Wishlist.

Modulstruktur in TypeScript/NestJS

Ein gut strukturiertes Modul hat eine klare interne Organisation:

src/modules/wishlist/
├── entities/
│   ├── wishlist.entity.ts
│   └── wishlist-item.entity.ts
├── services/
│   └── wishlist.service.ts
├── resolvers/
│   ├── wishlist-shop.resolver.ts
│   └── wishlist-admin.resolver.ts
├── events/
│   └── wishlist.events.ts
├── schemas/
│   ├── wishlist-shop.schema.ts
│   └── wishlist-admin.schema.ts
├── types/
│   └── wishlist.types.ts
└── wishlist.module.ts

Modul-Registrierung

// wishlist.module.ts
@Module({
    imports: [TypeOrmModule.forFeature([CiWishlist, CiWishlistItem])],
    providers: [WishlistService, WishlistShopResolver, WishlistAdminResolver],
    exports: [WishlistService], // Nur wenn andere Module es wirklich brauchen
})
export class WishlistModule {}

Das exports-Array ist die öffentliche API des Moduls. Exportiere nur, was andere Module wirklich brauchen. Die meisten Module sollten nichts exportieren (stattdessen über Events kommunizieren).

Was Modulgrenzen überschreitet

Überschreitet die GrenzeWie
EventsÜber EventBus veröffentlicht, von anderen Modulen abonniert
Entity IDsAls primitive Werte (string/number) übergeben, nicht als Entity-Referenzen
DTOs/InterfacesGemeinsame Typen für Event-Payloads
Überschreitet die Grenze NICHTWarum
Service-InstanzenErzeugt Kopplung. Verwende stattdessen Events.
Entity-ReferenzenModul A sollte nicht die Tabellen von Modul B abfragen.
Repository-ZugriffJedes Modul ownt seine eigenen Daten.
Interner ZustandPrivat für das Modul.

Wann DDD übertrieben ist

DDD-Konzepte bringen Mehrwert, wenn die Domäne komplex ist und das Team groß. Für viele Anwendungen funktionieren einfachere Muster besser.

SituationDDD?Besserer Ansatz
CRUD-Anwendung mit einfachen GeschäftsregelnNeinStandard MVC/Service-Repository-Muster
2-3 Ingenieure, ein Bounded ContextNeinGut organisierter Monolith mit klaren Ordnern
Startup MVP (Product-Market Fit unbekannt)NeinSchnell umsetzen, später refactoren wenn Domäne stabil ist
Enterprise-System mit 6+ unterschiedlichen Business-DomänenJaBounded Contexts mit klaren Modulgrenzen
Mehrere Teams auf derselben CodebasisJaModul-Ownership deckt sich mit Team-Ownership
Komplexe Geschäftsregeln, die je nach Kontext variierenJaDomain Models erfassen die Regeln explizit

Der pragmatische Mittelweg

Du brauchst keine Aggregates, Domain Events und Anti-Corruption Layers für einen Blog mit Kommentaren. Du brauchst Modulgrenzen und Event-basierte Kommunikation für eine Commerce-Plattform mit Wishlist, Reviews, Loyalty, Cart Recovery und Back-in-Stock-Alerts.

Der Mittelweg: Code in Module mit klaren Grenzen und EventBus-Kommunikation organisieren. Implementiere keine vollständigen DDD-Muster, außer die Domänenkomplexität rechtfertigt es. Drei ähnliche Codezeilen sind besser als eine voreilige Abstraktion.

Shared Kernel: Der Code, der niemandem gehört

Mancher Code wird wirklich zwischen Modulen geteilt. Konstanten, Utility-Funktionen, Basistypen und gemeinsame Interfaces. Das ist der Shared Kernel.

src/shared/
├── constants/
│   └── index.ts         # TABLE_NAMES, Berechtigungen, etc.
├── types/
│   └── common.types.ts  # Gemeinsame DTOs, Paginierungs-Typen
├── utils/
│   └── date.utils.ts    # Datumsformatierung, Zeitzonen-Helfer
└── errors/
    └── common.errors.ts # Basis-Fehlerklassen

Regeln für den Shared Kernel:

  • Minimal: Nur was wirklich geteilt werden muss. Wenn es von einem Modul verwendet wird, gehört es in dieses Modul.
  • Stabil: Änderungen am Shared Kernel betreffen alle Module. Er sollte sich selten ändern.
  • Keine Business-Logik: Nur Utilities, Typen und Konstanten. Geschäftsregeln gehören in Domänenmodule.
  • Im Besitz des Plattform-Teams (oder explizit niemandem). Wenn der Shared Kernel keinen Owner hat, wird er zur Abladestelle.

Refactoring in Richtung Grenzen

Die meisten Codebasen starten nicht mit sauberen Modulgrenzen. Sie entstehen aus einem Monolithen, in dem alles alles importiert. Refactoring in Richtung Grenzen ist ein schrittweiser Prozess.

Schritt 1: Aktuelle Abhängigkeiten abbilden

# Dependency-Graph generieren
npx madge --image dependency-graph.svg src/

# Die am häufigsten importierten Dateien finden (Kopplungs-Hotspots)
grep -rn "import.*from" src/ --include="*.ts" | awk -F"from " '{print $2}' | sort | uniq -c | sort -rn | head 20

Schritt 2: Natürliche Grenzen identifizieren

Suche nach Dateiclustern, die sich gemeinsam ändern. Wenn wishlist.service.ts, wishlist.entity.ts und wishlist.resolver.ts immer im selben PR geändert werden, sind sie ein natürliches Modul.

Schritt 3: Ein Modul nach dem anderen extrahieren

Iteration 1: Wishlist-Dateien nach src/modules/wishlist/ verschieben
Iteration 2: Direkte Imports durch Events ersetzen
Iteration 3: Loyalty-Dateien nach src/modules/loyalty/ verschieben
Iteration 4: Direkte Imports durch Events ersetzen
...

Versuche nicht, alle Module auf einmal zu extrahieren. Extrahiere eines, verifiziere, dass es funktioniert, dann extrahiere das nächste. Jede Extraktion sollte ein separater PR mit eigenen Tests sein.

Schritt 4: Grenzen durchsetzen

// ESLint-Regel zur Verhinderung von Cross-Modul-Imports
// .eslintrc.js
module.exports = {
    rules: {
        'no-restricted-imports': ['error', {
            patterns: [
                {
                    group: ['*/modules/loyalty/*'],
                    message: 'Wishlist-Modul darf nicht aus Loyalty importieren. Verwende EventBus.',
                },
            ],
        }],
    },
};

Setze Grenzen mit Linting durch, nicht mit Dokumentation. Dokumentation wird ignoriert. Lint-Fehler blockieren PRs.

Häufige Fehler

  1. Mit DDD starten statt sich dorthin zu refactoren. Fang bei einem neuen Projekt einfach an. Füge Modulgrenzen hinzu, wenn die Domänenkomplexität es erfordert. Entwirf keine Aggregates für eine CRUD-App.

  2. Alles exportieren. Wenn ein Modul alle seine Services exportiert, werden andere Module sie importieren. Exportiere standardmäßig nichts. Verwende Events für die Cross-Modul-Kommunikation.

  3. Gemeinsame Datenbankabfragen über Modulgrenzen. Modul A sollte kein SQL schreiben, das die Tabellen von Modul B joint. Jedes Modul ownt seine Daten. Wenn A Daten von B braucht, stellt B sie über ein Event oder eine öffentliche Service-Methode bereit.

  4. Events mit zu vielen Daten. Ein Event sollte IDs und minimalen Kontext tragen, nicht ganze Entities. Der Subscriber holt sich, was er braucht, aus seinem eigenen Datenspeicher.

  5. Keine Durchsetzung. Modulgrenzen ohne Lint-Regeln sind Vorschläge. Vorschläge werden unter Zeitdruck verletzt. Lint-Regeln blockieren Verletzungen.

  6. Voreilige Abstraktion. Drei ähnliche Funktionen in drei Modulen sind okay. Eine gemeinsame Abstraktion zu extrahieren, bevor du das Muster verstehst, erzeugt die falsche Abstraktion. Warte bis zum dritten Mal.

  7. DDD-Vokabular ohne DDD-Verständnis. Dinge "Aggregate" und "Value Object" zu nennen, ohne den Zweck zu verstehen, ist Cargo-Cult-Architektur. Der Zweck ist, Domänenregeln zu erfassen, nicht Code-Reviewer zu beeindrucken.

Zentrale Erkenntnisse

  • DDD dreht sich um Grenzen, nicht um Muster. Wo hört ein Modul auf und wo fängt ein anderes an? Was ownt jedes Modul? Wie kommunizieren sie? Wenn du das richtig machst, bleibt die Codebasis sauber.

  • Events sind der Anti-Corruption Layer. Der EventBus verhindert zirkuläre Abhängigkeiten und hält Module unabhängig. Emitter wissen nichts von Subscribern. Kommunikation ist unidirektional.

  • Exportiere standardmäßig nichts. Modul-Services sind privat, es sei denn, es gibt einen echten Bedarf, sie zu exportieren. Die meiste Inter-Modul-Kommunikation sollte über Events laufen.

  • Fang einfach an, refactore in Richtung Grenzen. Entwirf keine Aggregates am ersten Tag. Bau das Feature, beobachte die natürlichen Grenzen, dann extrahiere Module.

  • Setze mit Linting durch, nicht mit Dokumentation. ESLint-Regeln, die Cross-Modul-Imports verhindern, sind effektiver als Wiki-Seiten, die die Modulstruktur beschreiben.

  • Der Shared Kernel ist minimal und stabil. Nur Konstanten, Typen und Utilities. Keine Business-Logik. Änderungen am Shared Kernel betreffen jedes Modul.

Wir wenden diese Muster in unseren Custom-Software-Projekten und der Vendure-Plugin-Entwicklung an. Wenn du Hilfe mit Codebasis-Architektur brauchst, sprich mit unserem Team oder fordere ein Angebot an. Schau dir auch unseren Software-Engineering-Guide für unsere breiteren Engineering-Prinzipien an.

Behandelte Themen

DDD PraxisDomain-Driven Design realBounded ContextsModularchitekturDDD TypeScriptDDD NestJSModulgrenzenAnti-Corruption Layer

Bereit, produktionsreife KI-Systeme zu bauen?

Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.

Gespräch starten