Domain-Driven Design System-Design

Domain-Driven Design für komplexe Systeme: Wann es hilft – und wann nicht

Carola Schulte
Carola Schulte 1. Oktober 2025 21 min Lesezeit

Domain-Driven Design ist entweder die Lösung für Ihre Komplexitätsprobleme oder eine weitere Schicht davon. Der Unterschied liegt nicht im Pattern, sondern im Kontext. Dieser Artikel erklärt DDD pragmatisch: Was es ist, wann es hilft, und wann Sie es besser lassen.

DDD ist kein Framework. Es ist eine Denkweise – und die muss zum Problem passen.

Kurz gesagt: DDD lohnt sich, wenn Ihre Domäne komplex ist und Business-Regeln das System dominieren. Für CRUD-Anwendungen, Reports oder reine Integrationen ist DDD Overkill. Die Kunst ist zu erkennen, wo die Grenze liegt.

Was ist DDD eigentlich?

Domain-Driven Design, geprägt von Eric Evans in seinem Blue Book (2003), ist ein Ansatz zur Software-Entwicklung, der die Fachdomäne ins Zentrum stellt. Nicht die Datenbank. Nicht das Framework. Die Domäne.

DDD besteht aus zwei Teilen:

  • Strategic Design: Wie teilen wir ein großes System in handhabbare Teile? (Bounded Contexts, Context Maps)
  • Tactical Design: Wie modellieren wir innerhalb eines Teils? (Entities, Value Objects, Aggregates, Repositories)

Die meisten Teams springen direkt zum Tactical Design – Entities, Repositories, das Übliche. Das ist ein Fehler. Der strategische Teil ist wichtiger.

Reality-Check: Wenn Sie DDD einführen, indem Sie zuerst Ihre Entities umbenennen und Repository-Interfaces erstellen, haben Sie DDD nicht verstanden. Sie haben nur Ihre Ordnerstruktur geändert.

Strategic Design: Das Große Bild

Ubiquitous Language

Die Grundidee: Entwickler und Fachexperten sprechen dieselbe Sprache. Nicht „User" im Code und „Kunde" im Meeting. Nicht „Order" hier und „Bestellung" dort.

// Schlecht: Technische Sprache, die niemand im Business versteht
class User {
    private String userId;
    private List<Transaction> transactions;
    public void processTransaction(Transaction t) { ... }
}

// Besser: Domänensprache, die der Fachbereich versteht
class Kunde {
    private Kundennummer kundennummer;
    private List<Bestellung> bestellungen;
    public Bestellung bestellungAufgeben(Warenkorb warenkorb) { ... }
}

Ubiquitous Language ist nicht nur Übersetzung – es ist ein gemeinsames Modell. Wenn der Fachbereich sagt „Kunde storniert Bestellung", muss es im Code eine Methode kunde.bestellungStornieren() geben, keine orderService.cancelOrder().

Praxis-Test: Können Sie Ihren Code einem Fachexperten zeigen, und er versteht, was passiert? Wenn ja, haben Sie eine Ubiquitous Language. Wenn nein, haben Sie nur Code.
Stolperstein: Wenn der Fachbereich seine Begriffe selbst uneinheitlich verwendet, müssen diese Unterschiede zuerst geklärt werden – sonst modelliert DDD Chaos.

Bounded Context

Ein Bounded Context ist eine Grenze, innerhalb derer ein bestimmtes Modell gilt. Dieselben Begriffe können in verschiedenen Contexts unterschiedliche Bedeutungen haben.

Beispiel E-Commerce:

Begriff Sales Context Shipping Context Accounting Context
Kunde Hat Warenkorb, Wunschliste, Kaufhistorie Hat Lieferadresse, Zustellpräferenzen Hat Zahlungshistorie, Kreditlimit
Produkt Hat Preis, Beschreibung, Bilder Hat Gewicht, Maße, Gefahrgut-Flag Hat Einkaufspreis, Marge, Steuerklasse
Bestellung Warenkorb → Bestellung → Bezahlt Zu versenden → Versendet → Zugestellt Rechnung → Bezahlt → Gebucht

Der Fehler: Ein einziges Customer-Objekt mit allen Attributen aus allen Contexts. Das Ergebnis ist ein God Object mit 50 Feldern, von denen jeder Service nur 10 braucht.

// Anti-Pattern: Ein Modell für alles
class Customer {
    // Sales
    private ShoppingCart cart;
    private List<WishlistItem> wishlist;

    // Shipping
    private Address shippingAddress;
    private DeliveryPreferences deliveryPrefs;

    // Accounting
    private PaymentHistory payments;
    private BigDecimal creditLimit;

    // ... 40 weitere Felder
}
// DDD: Separate Modelle pro Context
// Sales Context
class Kunde {
    private Kundennummer id;
    private Warenkorb warenkorb;
    private List<Wunschartikel> wunschliste;
}

// Shipping Context
class Empfaenger {
    private EmpfaengerId id;  // Kann dieselbe Kundennummer sein
    private Lieferadresse adresse;
    private Zustellpraeferenzen praeferenzen;
}

// Accounting Context
class Debitor {
    private DebitorNummer id;
    private Zahlungshistorie zahlungen;
    private Kreditlimit limit;
}
Der häufigste Fehler: Bounded Contexts werden als technische Module missverstanden. Ein Context ist keine Schicht, kein Package, kein Microservice. Ein Context ist eine fachliche Grenze. Manchmal ist ein Context ein Microservice, manchmal sind mehrere Contexts in einem Monolith, manchmal teilen sich zwei Services einen Context.

Wichtig: Ein Bounded Context ist ein Sprachraum. Ob er als Microservice, Modul oder Teil eines Monolithen umgesetzt wird, ist eine technische Entscheidung – keine fachliche.

Context Map

Wie kommunizieren Bounded Contexts miteinander? Eine Context Map visualisiert die Beziehungen:

Beziehung Bedeutung Beispiel
Shared Kernel Beide Teams teilen Code/Modell Gemeinsame Domain-Objekte in einer Library
Customer-Supplier Upstream liefert, Downstream konsumiert Inventory liefert Bestand an Sales
Conformist Downstream übernimmt Modell von Upstream Wir passen uns an externe API an
Anti-Corruption Layer Übersetzungsschicht zwischen Modellen Legacy-System wird übersetzt
Open Host Service Öffentliche API für mehrere Clients REST-API für Partner
Published Language Dokumentiertes, stabiles Format JSON-Schema, Protobuf
Anti-Corruption Layer (ACL): Der wichtigste Pattern für Legacy-Integration. Ein ACL übersetzt zwischen Ihrem sauberen Domänenmodell und dem Chaos des Altsystems. So bleibt Ihre Domäne sauber, auch wenn Sie mit Legacy sprechen müssen.
// Anti-Corruption Layer Beispiel
class LegacyOrderAdapter implements OrderRepository {
    private final LegacyErpClient erpClient;

    @Override
    public Bestellung findById(BestellungsId id) {
        // Legacy-System hat völlig andere Struktur
        ErpOrderDto legacy = erpClient.getOrder(id.getValue());

        // Übersetzung in unser Domänenmodell
        return new Bestellung(
            new BestellungsId(legacy.orderNumber),
            mapToKunde(legacy.customerData),
            mapToPositionen(legacy.lineItems),
            mapToStatus(legacy.statusCode)
        );
    }

    private BestellStatus mapToStatus(String erpStatus) {
        // ERP hat Codes wie "10", "20", "30"
        return switch(erpStatus) {
            case "10" -> BestellStatus.NEU;
            case "20" -> BestellStatus.IN_BEARBEITUNG;
            case "30" -> BestellStatus.VERSENDET;
            default -> BestellStatus.UNBEKANNT;
        };
    }
}

Tactical Design: Die Bausteine

Entity vs. Value Object

Der fundamentale Unterschied:

  • Entity: Hat Identität. Zwei Objekte mit gleichen Attributen sind unterschiedlich, wenn ihre IDs unterschiedlich sind.
  • Value Object: Hat keine Identität. Zwei Objekte mit gleichen Attributen sind gleich.
// Entity: Identität zählt
class Kunde {
    private final KundenId id;  // Das macht ihn zur Entity
    private String name;
    private Adresse adresse;

    // Zwei Kunden mit gleichem Namen sind verschiedene Kunden
    @Override
    public boolean equals(Object o) {
        return id.equals(((Kunde) o).id);
    }
}

// Value Object: Werte zählen
class Adresse {
    private final String strasse;
    private final String plz;
    private final String ort;

    // Zwei identische Adressen sind gleich
    @Override
    public boolean equals(Object o) {
        Adresse other = (Adresse) o;
        return strasse.equals(other.strasse)
            && plz.equals(other.plz)
            && ort.equals(other.ort);
    }
}
Faustregel: Wenn Sie fragen „Ist das derselbe X?" und die Antwort von einer ID abhängt → Entity. Wenn die Antwort von den Werten abhängt → Value Object. Geld ist ein Value Object (10 € = 10 €). Ein Bankkonto ist eine Entity (auch wenn zwei den gleichen Stand haben).

Aggregate

Ein Aggregate ist eine Gruppe von Objekten, die zusammen eine Konsistenzgrenze bilden. Änderungen gehen immer durch die Aggregate Root.

// Aggregate: Bestellung
class Bestellung {  // Aggregate Root
    private final BestellungsId id;
    private final KundenId kundenId;
    private List<Bestellposition> positionen;  // Teil des Aggregates
    private BestellStatus status;
    private Geld gesamtbetrag;

    // Änderungen nur durch die Root
    public void positionHinzufuegen(Artikel artikel, int menge) {
        if (status != BestellStatus.OFFEN) {
            throw new BestellungNichtAenderbarException();
        }
        positionen.add(new Bestellposition(artikel, menge));
        gesamtbetragNeuBerechnen();
    }

    public void abschliessen() {
        if (positionen.isEmpty()) {
            throw new LeereBestellungException();
        }
        this.status = BestellStatus.ABGESCHLOSSEN;
        // Domain Event publizieren
        DomainEvents.publish(new BestellungAbgeschlossen(this.id));
    }

    private void gesamtbetragNeuBerechnen() {
        this.gesamtbetrag = positionen.stream()
            .map(Bestellposition::getPositionspreis)
            .reduce(Geld.ZERO, Geld::plus);
    }
}

// Teil des Aggregates, keine eigene Identität außerhalb
class Bestellposition {  // Nicht direkt zugreifbar
    private final Artikel artikel;
    private final int menge;
    private final Geld einzelpreis;

    public Geld getPositionspreis() {
        return einzelpreis.mal(menge);
    }
}
Aggregate-Größe: Kleine Aggregates sind besser. Ein Aggregate sollte in einer Transaktion geladen, geändert und gespeichert werden. Große Aggregates führen zu Lock-Contention und Performance-Problemen.

Warnsignal: Wenn ein Aggregate mehr als ein Repository braucht oder regelmäßig über mehrere Transaktionen geändert wird, ist es zu groß.

Repository

Ein Repository ist eine Abstraktion über die Persistenz. Es gibt Aggregates rein und raus – als wäre es eine Collection.

// Repository Interface (in der Domain)
interface BestellungRepository {
    Bestellung findById(BestellungsId id);
    List<Bestellung> findByKunde(KundenId kundenId);
    void save(Bestellung bestellung);
}

// Implementierung (in der Infrastruktur)
class JpaBestellungRepository implements BestellungRepository {
    private final EntityManager em;

    @Override
    public Bestellung findById(BestellungsId id) {
        BestellungEntity entity = em.find(BestellungEntity.class, id.getValue());
        return mapToDomain(entity);  // ORM-Entity → Domain-Objekt
    }

    @Override
    public void save(Bestellung bestellung) {
        BestellungEntity entity = mapToEntity(bestellung);
        em.merge(entity);
    }
}

Der Punkt: Die Domäne weiß nichts von JPA, Hibernate oder SQL. Sie kennt nur das Repository-Interface.

Domain Service

Wenn eine Logik zu keiner Entity gehört, aber trotzdem Domänenlogik ist:

// Domain Service: Preisberechnung über mehrere Aggregates
class Preisberechnung {
    public Geld berechneGesamtpreis(
            Warenkorb warenkorb,
            Kunde kunde,
            Gutschein gutschein) {

        Geld basis = warenkorb.getWarenwert();
        Geld mitKundenrabatt = kunde.getRabattstufe().anwenden(basis);
        Geld mitGutschein = gutschein.anwenden(mitKundenrabatt);

        return mitGutschein;
    }
}
Vorsicht: Domain Services sind kein Dumping Ground für Logik. Wenn Sie mehr Domain Services als Aggregates haben, stimmt etwas nicht. Logik gehört primär in Aggregates.

Domain Events

Wenn etwas Wichtiges in der Domäne passiert, publizieren Sie ein Event:

// Domain Event
record BestellungAbgeschlossen(
    BestellungsId bestellungsId,
    KundenId kundenId,
    Geld gesamtbetrag,
    Instant zeitpunkt
) implements DomainEvent {}

// Im Aggregate
class Bestellung {
    public void abschliessen() {
        // ... Validierung ...
        this.status = BestellStatus.ABGESCHLOSSEN;

        DomainEvents.publish(new BestellungAbgeschlossen(
            this.id,
            this.kundenId,
            this.gesamtbetrag,
            Instant.now()
        ));
    }
}

// Event Handler (anderer Bounded Context)
class LagerReservierung {
    @EventHandler
    public void onBestellungAbgeschlossen(BestellungAbgeschlossen event) {
        // Lager reservieren
    }
}

class EmailVersand {
    @EventHandler
    public void onBestellungAbgeschlossen(BestellungAbgeschlossen event) {
        // Bestätigung senden
    }
}
Sync vs. Async: Domain Events sind zunächst ein Modellierungswerkzeug. Ob sie synchron (im selben Thread) oder asynchron (via Message Broker wie Kafka) verarbeitet werden, ist eine separate Architekturentscheidung. DDD bedeutet nicht automatisch Event-Driven Architecture.

Wann DDD – und wann nicht?

DDD lohnt sich wenn:

  • Komplexe Domänenlogik: Viele Geschäftsregeln, Validierungen, Zustandsübergänge
  • Langlebige Software: System wird über Jahre weiterentwickelt
  • Mehrere Teams: Klare Grenzen (Bounded Contexts) helfen bei der Koordination
  • Enge Business-Zusammenarbeit: Fachexperten sind verfügbar und involviert
  • Unklare/sich ändernde Anforderungen: Flexibles Modell nötig

DDD ist Overkill wenn:

  • CRUD-Anwendungen: Daten rein, Daten raus, fertig
  • Reine Integrationen: Daten von A nach B schieben
  • Reports/Analytics: Lesen, nicht schreiben
  • Prototypen: Schnelligkeit > Struktur
  • Kleine Teams ohne Fachexperten: Ubiquitous Language mit wem?
Der Litmus-Test: Wenn Sie die Geschäftslogik in 10 Minuten einem Entwickler erklären können, brauchen Sie kein DDD. Wenn Sie 2 Stunden brauchen und trotzdem Fälle vergessen – dann schon.

Häufige Fehler

1. DDD als Technologie verstehen

DDD ist kein Framework. Sie können DDD nicht installieren. Wenn Ihr „DDD-Projekt" damit beginnt, dass jemand eine Library evaluiert, sind Sie auf dem falschen Weg.

2. Anemic Domain Model

Entities, die nur Getter/Setter haben, und Services, die alle Logik enthalten:

// Anti-Pattern: Anemic Domain Model
class Order {
    private String status;
    public String getStatus() { return status; }
    public void setStatus(String s) { this.status = s; }
}

class OrderService {
    public void completeOrder(Order order) {
        if (order.getStatus().equals("OPEN")) {
            order.setStatus("COMPLETED");
            // ... 100 Zeilen Logik ...
        }
    }
}

// Besser: Rich Domain Model
class Order {
    private OrderStatus status;

    public void complete() {
        if (this.status != OrderStatus.OPEN) {
            throw new InvalidOrderStateException();
        }
        this.status = OrderStatus.COMPLETED;
        // Logik gehört hierhin
    }
}

3. Falsche Aggregate-Grenzen

Zu große Aggregates (alles ist verbunden) oder zu kleine (jede Tabelle ist ein Aggregate). Die richtige Größe orientiert sich an Transaktionsgrenzen und Invarianten.

4. Ubiquitous Language ignorieren

Code in Englisch, Fachbereich spricht Deutsch, Meetings sind ein Übersetzungs-Marathon. Entweder Sie committen zur gemeinsamen Sprache, oder Sie lassen es.

5. DDD überall anwenden

Nicht jeder Bounded Context braucht taktisches DDD. Ein Reporting-Context kann ein einfaches Query-Modell sein. Ein Notification-Context braucht keine Aggregates.

Die Realität: In den meisten Systemen sind 20% der Domäne komplex genug für DDD. Die anderen 80% sind CRUD. Wenden Sie DDD dort an, wo es hilft, nicht überall.

Praktischer Einstieg

Schritt 1: Event Storming

Bevor Sie Code schreiben: Event Storming mit dem Fachbereich. Auf einem großen Whiteboard (oder Miro) sammeln Sie:

  • Orange: Domain Events (Was passiert?)
  • Blau: Commands (Was löst es aus?)
  • Gelb: Aggregates (Wer ist verantwortlich?)
  • Pink: External Systems
  • Rot: Pain Points, offene Fragen

Nach 2-4 Stunden haben Sie ein Modell, das vom Business verstanden wird – und Sie sehen Bounded Contexts entstehen.

Schritt 2: Core Domain identifizieren

Nicht alles ist gleich wichtig. Identifizieren Sie:

  • Core Domain: Das, was Ihr Unternehmen einzigartig macht. Hier investieren Sie.
  • Supporting Subdomain: Wichtig, aber nicht differenzierend. Kann Standard sein.
  • Generic Subdomain: Commodity. Kaufen oder minimaler Aufwand.

Schritt 3: Bounded Contexts definieren

Basierend auf Event Storming: Wo sind die sprachlichen Grenzen? Wo ändern sich Begriffe? Dort liegen Context-Grenzen.

Schritt 4: Context Map erstellen

Wie sprechen die Contexts miteinander? Wer ist upstream, wer downstream? Wo brauchen Sie Anti-Corruption Layer?

Schritt 5: Tactical Design für Core Domain

Erst jetzt: Aggregates, Entities, Value Objects – aber nur für die Core Domain. Der Rest kann einfacher sein.

Fazit

Domain-Driven Design ist mächtig, aber nicht universell. Es ist ein Werkzeug für komplexe Domänen, nicht ein Architektur-Standard für jedes Projekt.

Die wichtigsten Punkte:

  • Strategic vor Tactical: Bounded Contexts sind wichtiger als Aggregate-Patterns
  • Ubiquitous Language: Der unterschätzte Kern von DDD
  • Richtige Grenzen: Nicht zu groß, nicht zu klein
  • Nicht überall: DDD für Core Domain, simpler für den Rest
  • Mit dem Business: DDD ohne Fachexperten ist Theater

DDD ist keine Architektur – es ist eine Konversation zwischen Code und Business. Wenn diese Konversation nicht stattfindet, ist DDD nur ein teures Labeling-Projekt.

DDD scheitert selten an Code – sondern an Organisationen, die keine fachlichen Entscheidungen treffen wollen.

Nächster Schritt: Nehmen Sie Ihr komplexestes Modul. Laden Sie 2-3 Fachexperten ein. Machen Sie 2 Stunden Event Storming. Schauen Sie, welche Begriffe unterschiedlich verwendet werden. Dort beginnt DDD – nicht im Code.

Carola Schulte

Carola Schulte

Software-Architektin mit Erfahrung in Domain-Driven Design für Enterprise-Systeme – und in der Erkenntnis, wann es nicht passt.

Beratung anfragen

Domain Modeling Workshop?

Ich moderiere Event Storming Sessions, helfe bei der Definition von Bounded Contexts und begleite Teams bei der Einführung von DDD – pragmatisch, nicht dogmatisch.

Workshop anfragen