flowchart LR
B["Buchung"] -->|"Customer/Supplier"| V["Vertrieb"]
B -->|"Domain Events"| F["Buchhaltung"]
B -->|"Domain Events"| S["Support"]
X["Bank-Altsystem (extern)"] -->|"Anti-Corruption Layer"| F
Domain-Driven Design Teil 1: Bounded Context und Aggregate
Track Konzepte · Architektur und Fachlichkeit · ca. 45 Min.
Worum es geht
Das Reisebüro Nordlicht Reisen (fiktiv) hat eine Software mit einer einzigen Klasse Kunde. Der Vertrieb hängt einen Lead-Status daran, der Support Tickets, die Buchhaltung eine Steuernummer. Nach zwei Jahren hat Kunde 80 Felder, jede Änderung bricht irgendwo etwas, und niemand traut sich, Felder zu löschen. Zwei Mitarbeiter, die nie dasselbe tun, blockieren sich beim Speichern gegenseitig. Das ist kein Programmierfehler, sondern ein Schnittfehler: Die Grenzen des Modells passen nicht zu den Grenzen der Fachlichkeit.
Domain-Driven Design (DDD, fachgetriebener Entwurf) gibt dir dafür Werkzeuge. In diesem ersten Teil lernst du die Grundlagen: Bounded Context (abgegrenzter Fachkontext) und Ubiquitous Language (gemeinsame Fachsprache), Aggregate (Konsistenzgrenze mit Invarianten) und die Prüfstein-Frage der Quelle: Woran erkennst du, dass ein Aggregate zu groß oder zu klein geschnitten ist? Teil 2 (Domain Events, Anti-Corruption Layer und Conway’s Law) führt die Kontexte zusammen.
Alles läuft im Browser. Nebenläufigkeit simulieren wir wie in konzepte/01 mit Generatoren (jedes yield ist ein möglicher Wechsel zwischen zwei Mitarbeitern). Die Zahlen im Text stammen aus echten Läufen des Codes.
Plane ehrlich 45 Minuten ein: etwa 20 Minuten Lesen, etwa 25 Minuten für drei Übungen. Eine gute Pause ist nach Übung 2, danach kommt das zu große Aggregate.
Von JS/TS her gedacht
Du kennst die Bausteine schon, nur nicht unter diesen Namen.
| DDD-Begriff | JS/TS-Welt | Python (hier) |
|---|---|---|
| Aggregate Root (Wurzel) | Klasse mit privaten Feldern (#feld), nur Methoden ändern den Zustand |
Klasse mit _feld (Konvention) und @property |
| Unveränderliche Sicht nach außen | readonly string[], Object.freeze, Kopie mit [...x] |
tuple(...) statt der internen Liste |
| Bounded Context | ein Package oder Modul mit eigenem Typ Customer pro Feature |
ein Modul pro Kontext, je eine eigene Klasse |
Dasselbe Aggregate in TypeScript. Die Klasse wurde ohne Typannotationen als JavaScript mit Node ausgeführt, die Ausgabe steht darunter:
class Gruppenreise {
#teilnehmer: string[] = [];
constructor(readonly reiseId: string, readonly plaetze: number) {}
get teilnehmer(): readonly string[] { return [...this.#teilnehmer]; }
anmelden(name: string): void {
if (this.#teilnehmer.includes(name)) throw new Error(`${name} ist schon angemeldet`);
if (this.#teilnehmer.length >= this.plaetze) throw new Error("Reise ist ausgebucht");
this.#teilnehmer.push(name);
}
}
const r = new Gruppenreise("R1", 2);
r.anmelden("Aylin"); r.anmelden("Ben");
try { r.anmelden("Cem"); } catch (e) { console.log("Fehler:", e.message); }
r.teilnehmer.push("Cem"); // in TS ein Compilerfehler (readonly), als JS ändert es nur die Kopie
console.log(r.teilnehmer);Fehler: Reise ist ausgebucht
[ 'Aylin', 'Ben' ]
Die Regel “höchstens plaetze Teilnehmer” steht an einer Stelle, in anmelden. Von außen kommt niemand an die Liste. Das ist die Idee eines Aggregates.
Konzept
Schritt 1: Bounded Context und Ubiquitous Language
Ein Wort bedeutet in verschiedenen Abteilungen verschiedene Dinge. Bei Nordlicht ist “Kunde” für drei Abteilungen drei Modelle:
| Kontext | Wie heißt das Modell dort | Welche Entscheidungen hängen daran |
|---|---|---|
| Vertrieb | Interessent |
“Wen rufe ich diese Woche an?” und “Wen darf ich anschreiben?” |
| Support | Kontakt |
“Welches Ticket zuerst?” und “In welcher Sprache rufe ich zurück?” |
| Buchhaltung | Rechnungsempfaenger |
“Wohin geht die Rechnung?” und “Mit welcher Steuer rechne ich?” |
Eine Entscheidung, die alle drei treffen müssen, ist: “Wer ist gemeint?” Dafür braucht jeder Kontext dieselbe Kunden-ID, mehr nicht.
Ein Bounded Context ist die Grenze, innerhalb der ein Modell und seine Begriffe eindeutig sind. Innerhalb gilt eine Ubiquitous Language: Entwickler und Fachleute verwenden dieselben Wörter, im Gespräch und im Code. Ein “Lead” im Vertrieb heißt dann auch im Code Lead, nicht CustomerRecordType2.
Das Gegenteil, ein Modell für alles, nennt man umgangssprachlich Big Ball of Mud. Statt eines Kunde mit 80 Feldern gibt es drei kleine Modelle, die nur über die ID zusammenhängen.
Wie die Kontexte miteinander reden, beschreibt die Context Map. Der Pfeil zeigt von der liefernden Seite (Upstream) zur abhängigen Seite (Downstream):
Die wichtigsten Beziehungsmuster (strategisches DDD):
- Customer/Supplier: Upstream liefert, Downstream darf Wünsche anmelden, und beide Teams planen die Reihenfolge gemeinsam. Setzt voraus, dass das Upstream-Team kooperiert.
- Shared Kernel: Zwei Kontexte teilen sich einen kleinen Teil des Modells (Code oder Schema). Jede Änderung dort muss abgestimmt werden. Teuer in der Koordination, darum nur für wenig und nur zwischen eng verbundenen Teams.
- Anti-Corruption Layer (ACL): Eine Schicht an der Grenze, die ein fremdes Modell in das eigene übersetzt, damit fremde Namen und Eigenheiten nicht in den eigenen Code lecken. Der Gegenpol ist Conformist: Du übernimmst das fremde Modell einfach.
Schritt 2: Aggregate und Invarianten, durchgerechnet
Eine Invariante ist eine Regel, die nach jeder Operation gelten muss, zum Beispiel “nie mehr Teilnehmer als Plätze”. Ein Aggregate ist eine Gruppe von Objekten, die zusammen eine solche Regel garantiert. Es hat genau eine Wurzel (Aggregate Root). Alles Ändern geht über die Wurzel, die vor jeder Änderung prüft. Außerdem gilt:
- Eine Transaktion pro Aggregate. Alles in einem Aggregate wird in einer Transaktion gespeichert, die Invarianten gelten sofort.
- Verweis auf andere Aggregates nur per ID, nie per Objekt. Die Buchung kennt die
kunden_id, nicht das Kunden-Objekt. - Zwischen Aggregates gilt Eventual Consistency (letztlich konsistent), vermittelt über Events (siehe Schritt 4 in Teil 2 und konzepte/06a).
Hier das Beispiel von oben in Python, mit echter Ausgabe:
Ausgabe:
Fehler: Reise ist ausgebucht
('Aylin', 'Ben')
Fehler: 'tuple' object has no attribute 'append'
('Aylin', 'Ben', 'Cem')
Gehe es Zeile für Zeile durch: Die Reise hat 2 Plätze. Aylin und Ben sind drin, Cem scheitert an der Invariante (Zeile 1 der Ausgabe). reise.teilnehmer ist ein Tupel, ein append von außen scheitert. Die letzte Zeile zeigt die Grenze von Python: _teilnehmer ist nur eine Konvention (Unterstrich), kein echter Schutz wie #teilnehmer in JS. Dein Team muss sich daran halten. Die Prüfungen in den Übungen testen darum nur die öffentliche Schnittstelle.
Schritt 3: Zu groß oder zu klein?
Die Faustregel: Ein Aggregate ist die kleinste Gruppe von Daten, deren Invarianten sofort gelten müssen. Daran misst du beide Fehler.
Zu groß: Du packst Dinge zusammen, die keine gemeinsame Invariante haben. Dann sperren sich Leute gegenseitig, die an Unabhängigem arbeiten, und das ganze Ding wird bei jeder Änderung geladen und geschrieben. Symptome: häufige Speicherkonflikte, lange Transaktionen, Änderungen, die “nichts miteinander zu tun haben” und trotzdem kollidieren.
Zu klein: Du trennst Dinge, die eine gemeinsame Invariante haben. Dann kann keine einzelne Transaktion die Regel durchsetzen. Beispiel: Die Positionen einer Buchung sind einzeln gespeichert, die Regel lautet “Gesamtbetrag höchstens 200”. Aktuell sind 100 gebucht, zwei Nutzer fügen gleichzeitig je 80 hinzu. Beide sehen “100 plus 80 ist höchstens 200” und speichern:
260
Die Invariante ist verletzt, obwohl beide Prüfungen für sich stimmten. Das Limit gehört in ein Aggregate, das alle Positionen enthält.
Jetzt der umgekehrte Fall, mit dem du in Übung 3 arbeitest. Bei Nordlicht ist eine ganze Reise (Zimmerplan, Teilnehmer mit Notizen, Tagesprogramm) ein einziger Datensatz. Der Speicher ist eine Fake-Datenbank mit optimistischem Locking (optimistic locking): Beim Schreiben gibt man die Version mit, die man gelesen hat. Hat jemand anders inzwischen gespeichert, ist die Version veraltet und das Schreiben wird als Konflikt abgelehnt. In HTTP kennst du das als ETag mit If-Match. Der Mitarbeiter lädt die Seite dann neu und macht seine Änderung noch einmal.
Ausgabe:
Konflikte: 6
zimmerplan ({'101': 'Meyer', '102': 'Kaya'}, 4)
teilnehmer ({'Kaya': 'vegetarisch'}, 4)
programm (['Stadtführung'], 4)
Vier Mitarbeiter ändern vier verschiedene Dinge: zwei tragen Zimmer ein, einer eine Allergie, einer ein Programmpunkt. Alles kommt am Ende an, aber mit 6 Konflikten. Rechne mit: In Runde 1 laden alle vier die Version 0. Mitarbeiter 1 speichert (Version 1), die drei anderen scheitern (3 Konflikte). Alle drei laden neu, Mitarbeiter 2 speichert, zwei scheitern (2 Konflikte). Dann einer (1 Konflikt). Zusammen 3 plus 2 plus 1 gleich 6. Die Version 4 am Ende bestätigt: vier Schreibvorgänge, alle auf demselben Datensatz.
Dabei haben die Allergie-Notiz und das Programm keine gemeinsame Invariante mit dem Zimmerplan. Nur “Zimmer 101 darf nicht doppelt vergeben werden” ist eine echte Regel, und sie betrifft nur den Zimmerplan.
Falle
- Ein Modell für alle Abteilungen. Der
Kundemit 80 Feldern. Jede Abteilung bekommt ihr eigenes Modell, verbunden über die ID. - Ein Riesen-Aggregate. “Reise enthält alles” ist bequem beim Laden und teuer bei jedem gleichzeitigen Schreiben.
- Ein zu kleines Aggregate. Wenn eine Regel mehrere Datensätze betrifft, kann sie keine Einzel-Transaktion mehr garantieren (die 260 aus Schritt 3).
- Verweis per Objekt statt per ID. Dann hängt beim Laden des einen Aggregates das nächste mit dran, und die Transaktionsgrenze verschwimmt.
Übungen
Übung 1: Begriffe den Kontexten zuordnen (ca. 5 Min.)
Du siehst die Tabelle aus Schritt 1. Zu jedem Feld gehört die Entscheidung, für die es gebraucht wird:
| Feld | gebraucht für die Entscheidung |
|---|---|
lead_status |
“Wen rufe ich diese Woche an?” |
newsletter_einwilligung |
“Wen darf ich anschreiben?” |
ticket_prioritaet |
“Welches Ticket zuerst?” |
rueckruf_sprache |
“In welcher Sprache rufe ich zurück?” |
rechnungsadresse |
“Wohin geht die Rechnung?” |
umsatzsteuer_id |
“Mit welcher Steuer rechne ich?” |
kunden_id |
“Wer ist gemeint?” |
Ein Feld gehört in den Kontext, der diese Entscheidung trifft. Wird die Entscheidung in allen drei Kontexten getroffen, trägst du "alle" ein. Gültige Werte: "vertrieb", "support", "buchhaltung", "alle".
Suche zu jeder Entscheidung die Zeile in der Tabelle aus Schritt 1. Welche Entscheidung steht laut Text unter der Tabelle in jedem der drei Kontexte an?
zuordnung = {
"lead_status": "vertrieb",
"newsletter_einwilligung": "vertrieb",
"ticket_prioritaet": "support",
"rueckruf_sprache": "support",
"rechnungsadresse": "buchhaltung",
"umsatzsteuer_id": "buchhaltung",
"kunden_id": "alle",
}
zuordnungJedes fachliche Feld liegt in dem einen Kontext, der die Entscheidung trifft. Nur die ID wird überall gebraucht, sie ist der Faden zwischen den Kontexten.
Übung 2: Das Aggregate Reisebuchung (ca. 12 Min.)
Baue die Wurzel Reisebuchung. Beträge sind ganze Cent (Integer), das Limit gilt pro Buchung. Die Klasse muss folgende Invarianten garantieren:
- Der Preis einer Position ist größer als 0.
- Die Anzahl ist mindestens 1.
- Eine Bezeichnung kommt höchstens einmal vor.
- Der Gesamtbetrag (Summe aus
preis_cent * anzahl) ist nie größer als das Limit. Genau das Limit ist erlaubt. - Von außen ist nichts direkt änderbar:
positionenliefert eine unveränderliche Sicht (Tupel aus(bezeichnung, preis_cent, anzahl), in Einfügereihenfolge),gesamt_centwird immer aus den Positionen berechnet und lässt sich nicht setzen.
Verletzt ein Aufruf eine Regel, wirft die Methode einen ValueError, und der Zustand bleibt unverändert (nichts ist halb eingefügt). Rechne ein Beispiel mit Limit 200000 (2000 Euro): Hotel 80000 mal 1 und Flug 45000 mal 2 ergeben 170000. Ein Transfer 40000 mal 1 ergäbe 210000 und wird abgelehnt. Eine Versicherung 30000 mal 1 ergibt genau 200000 und ist erlaubt.
Der Check versucht es von außen kaputtzumachen: ungültige Aufrufe, positionen.append(...), Zuweisungen an gesamt_cent und positionen. Ergänze die Methoden:
Prüfe zuerst alle Regeln, ändere erst danach den Zustand. Was gibst du nach außen, wenn niemand den inneren Zustand anfassen soll? Und muss der Gesamtbetrag überhaupt gespeichert werden?
class Reisebuchung:
def __init__(self, buchung_id, limit_cent):
self.buchung_id = buchung_id
self._limit = limit_cent
self._positionen = {} # bezeichnung -> (preis_cent, anzahl)
@property
def positionen(self):
return tuple((b, p, a) for b, (p, a) in self._positionen.items())
@property
def gesamt_cent(self):
return sum(p * a for p, a in self._positionen.values())
def position_hinzufuegen(self, bezeichnung, preis_cent, anzahl):
if preis_cent <= 0:
raise ValueError("Preis muss größer als 0 sein")
if anzahl < 1:
raise ValueError("Anzahl muss mindestens 1 sein")
if bezeichnung in self._positionen:
raise ValueError("Position gibt es schon")
if self.gesamt_cent + preis_cent * anzahl > self._limit:
raise ValueError("Limit überschritten")
self._positionen[bezeichnung] = (preis_cent, anzahl)
def position_entfernen(self, bezeichnung):
if bezeichnung not in self._positionen:
raise ValueError("unbekannte Position")
del self._positionen[bezeichnung]
ReisebuchungAlle Prüfungen laufen vor der Änderung, darum ist nach einem abgelehnten Aufruf nichts halb gespeichert. gesamt_cent wird berechnet statt gespeichert, so kann es nie von den Positionen abweichen. positionen gibt ein neues Tupel aus Tupeln zurück, damit bleibt der innere Zustand unerreichbar.
Übung 3: Das zu große Aggregate aufteilen (ca. 9 Min.)
Oben hast du den ReiseRepository gesehen: Die ganze Reise liegt unter einem Schlüssel, und vier Mitarbeiter erzeugen 6 Konflikte. Repariere ihn so, dass Zimmerplan, Teilnehmer und Programm eigene Aggregates sind. Sie sind nur über die Reise-ID verbunden, die im Schlüssel steckt. Die Schnittstelle (anlegen, lade, speichere) und die Reihenfolge der Mitarbeiter bleiben gleich.
Der Check lässt dasselbe Szenario mit deinem Repository laufen. Er verlangt zwei Dinge: Alle vier Änderungen kommen am Ende an (nichts geht verloren), und es bleibt genau ein Konflikt übrig. Warum ausgerechnet einer, überlege selbst (Hinweis: Wer ändert dasselbe?).
Was muss ein Aggregate haben, damit es seine eigene Version bekommt? Und woran erkennt das Repository, welcher Teil gemeint ist, wenn nur noch ein Teil gespeichert wird?
class ReiseRepository:
def __init__(self, speicher):
self.speicher = speicher
def anlegen(self, reise_id, teile):
for bereich, wert in teile.items():
self.speicher.neu(f"{bereich}:{reise_id}", wert)
def lade(self, reise_id, bereich):
return self.speicher.lade(f"{bereich}:{reise_id}")
def speichere(self, reise_id, bereich, teil, version):
return self.speicher.speichere(f"{bereich}:{reise_id}", teil, version)
ReiseRepositoryJeder Bereich hat jetzt einen eigenen Schlüssel und damit eine eigene Version, die Reise-ID im Schlüssel ist der Verweis per ID. Teilnehmer und Programm kollidieren mit niemandem mehr. Der eine verbleibende Konflikt ist gewollt: Die beiden Zimmer-Änderungen betreffen dieselbe Invariante (Zimmer nicht doppelt vergeben), sie müssen sich gegenseitig absichern.
Merksatz
Schneide Modelle entlang der Fachlichkeit: ein Bounded Context pro Sprache und ein Aggregate pro Gruppe von Regeln, die sofort gelten müssen, mit Verweis per ID und Änderung nur über die Wurzel.
Prüfstein
- Woran erkennst du, dass ein Aggregate zu groß oder zu klein geschnitten ist? (Denke an Konflikte zwischen Nutzern, die an Unabhängigem arbeiten, und an Regeln, die über mehrere Datensätze gehen.)
- Warum verweist ein Aggregate auf ein anderes nur per ID, und was gilt zwischen zwei Aggregates statt einer gemeinsamen Transaktion?
Weiter geht es in Teil 2: Domain Events, Anti-Corruption Layer und Conway’s Law.
Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “6. Design und Architektur” (Domain-Driven Design: Bounded Context, Aggregate, Ubiquitous Language); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 6 (Taktisches DDD: Aggregate und ihre Invarianten, Transaktionsgrenzen; Prüfstein zur Aggregate-Größe).
Über die Quelle hinaus (allgemeines Fachwissen): die Erklärung der einzelnen Begriffe, die Regeln für Aggregates (eine Transaktion pro Aggregate, Verweis per ID, Eventual Consistency dazwischen), Big Ball of Mud, optimistisches Locking mit Versionsnummer (ETag mit If-Match). Das Reisebüro Nordlicht und alle Zahlen sind erfunden bzw. stammen aus dem Ausführen des Codes dieser Lektion, nicht aus Messungen realer Systeme.