Domain-Driven Design Teil 2: Events, Anti-Corruption Layer und Conway’s Law

Track Konzepte · Architektur und Fachlichkeit · ca. 35 Min.

Worum es geht

In Teil 1 hast du Bounded Contexts und Aggregates geschnitten. Jetzt geht es darum, wie die Kontexte miteinander reden, ohne ihre Modelle zu vermischen: Ein Aggregate meldet gültige Änderungen als Domain Event (Fachereignis), und an der Grenze zu fremden Systemen übersetzt ein Anti-Corruption Layer (ACL, Übersetzungsschicht). Zum Schluss schaust du auf die Organisation dahinter: Conway’s Law, Inverse Conway Maneuver und Cognitive Load.

Du baust einen ACL, ein Aggregate mit Events samt Handler und entscheidest zwei Praxisfälle. Wie in Teil 1 läuft alles im Browser.

Was du aus Teil 1 brauchst: dass jeder Bounded Context sein eigenes Modell hat, dass ein Aggregate Invarianten schützt und nur über die Wurzel geändert wird, und dass Aggregates einander nur per ID kennen. Events sind genau der Weg, auf dem sie sich dennoch Fakten mitteilen.

Plane ehrlich 35 Minuten ein: etwa 10 Minuten Lesen, etwa 25 Minuten für drei Übungen.

Von JS/TS her gedacht

Du kennst die Bausteine schon, nur nicht unter diesen Namen.

DDD-Begriff JS/TS-Welt Python (hier)
Domain Event EventEmitter, Action in Redux Liste von Event-Dicts, die das Aggregate sammelt
Anti-Corruption Layer Mapper oder Adapter-Funktion an der API-Grenze (wie ein DTO-Mapper) Funktion uebersetze(fremd)

Konzept

Schritt 4: Domain Events und die Übersetzung an der Grenze

Ein Domain Event ist eine Tatsache aus der Vergangenheit, benannt in der Fachsprache: TeilnehmerAngemeldet, nicht UpdateReise. Das Aggregate erzeugt das Event, aber erst nachdem die Änderung gültig durchgegangen ist. Andere Kontexte verarbeiten es und lösen ihre eigene Arbeit aus. So bleiben Aggregates getrennt (Eventual Consistency statt einer riesigen Transaktion).

['Willkommensmail an Aylin (Reise R1)']
[]

Drei Dinge zum Mitnehmen: Das gescheiterte anmelden von Ben erzeugt kein Event. ereignisse_abholen leert die Liste, ein zweites Abholen liefert nichts. Und der Handler des Supports sieht nur das Event (ein Dict mit Fakten), nie das Gruppenreise-Objekt. Weil Events mehrfach ankommen können, muss ein Handler in der Praxis idempotent sein (siehe konzepte/06).

Beim Anti-Corruption Layer (ACL) geht es um den umgekehrten Weg: Fremde Daten kommen herein. Die Buchhaltung bekommt Zahlungen vom externen Bank-Altsystem, mit kryptischen Namen und deutschem Zahlenformat. Eine einzige Funktion an der Grenze übersetzt in das eigene Modell, danach kommen die fremden Namen im Code nicht mehr vor:

Zahlung(rechnung_nr='R-2041', betrag_cent=123450, status='eingegangen')

Ändert der Anbieter sein Format, passt du eine Funktion an, nicht die ganze Buchhaltung.

Schritt 5: Conway’s Law und Teamzuschnitt

Conway’s Law: Die Struktur eines Systems spiegelt die Kommunikationsstruktur der Organisation wider, die es baut. Hast du drei Teams, entstehen drei Teile mit Schnittstellen dazwischen, auch wenn die Fachlichkeit anders gegliedert wäre. Hast du ein Team für Frontend, eins für Backend und eins für Datenbank, geht jedes Feature durch drei Teams und drei Übergaben.

Der Inverse Conway Maneuver dreht das um: Du schneidest die Teams so, wie die gewünschte Architektur aussehen soll, nämlich entlang der Bounded Contexts. Jedes Team besitzt dann einen Kontext von der Oberfläche bis zur Datenbank. Team Topologies beschreibt dafür Teamtypen, vor allem das Stream-aligned Team, das einen fachlichen Wertstrom von Anfang bis Ende verantwortet (Details und die weiteren Typen: bitte prüfen, allgemeines Fachwissen).

Der Grund für die Obergrenze der Teamgröße ist die Cognitive Load (kognitive Last): Ein Team kann nur eine begrenzte Menge an Code und Fachlichkeit im Kopf halten. Ein Bounded Context sollte so groß sein, dass ein Team ihn versteht. Gehört ihm alles, versteht ihn niemand mehr.

Falle

  1. Event vor der Prüfung erzeugt. Ein Event, das etwas meldet, was nie passiert ist, lässt alle anderen Kontexte auf falschen Fakten arbeiten.
  2. Fremdes Modell durchreichen. Ohne Übersetzung an der Grenze steckt das fremde Format überall im eigenen Code, und jede Änderung des Anbieters trifft alles (Schritt 4).
  3. Conway ignorieren. Eine saubere Architektur auf dem Papier, aber Teams nach Schichten: Die Realität zieht den Code zurück in die Teamstruktur.

Übungen

Übung 1: Einen Anti-Corruption Layer schreiben (ca. 8 Min.)

Die Reiseplanung von Nordlicht liest Hotelangebote von einem externen Buchungsportal. Dessen Dicts sehen so aus: {"BEZ": "Hotel H-17 Lissabon", "PREIS": "1,089.90 EUR", "ST": "F"}. BEZ enthält irgendwo die Hotelnummer im Format H- plus Ziffern. PREIS hat Komma als Tausendertrenner, Punkt vor den zwei Cent-Stellen und das Suffix EUR. ST ist "F" (frei) oder "B" (belegt).

Schreibe uebersetze_hotel(fremd). Sie liefert das eigene Modell Angebot(hotel_nr, preis_cent, frei) (steht schon bereit): die Hotelnummer als Text, den Preis als ganze Cent (int), frei als bool. Fehlt die Hotelnummer oder ist ST etwas anderes als "F" oder "B", wirf einen ValueError. Das übergebene Dict darfst du nicht verändern.

Beispiel: {"BEZ": "H-9 Porto", "PREIS": "75.05 EUR", "ST": "B"} wird zu Angebot("H-9", 7505, False).

Zerlege den Preis als Text, bevor du ihn in eine Zahl umwandelst. Was passiert bei Kommazahlen, die du mit 100 multiplizierst, und wie vermeidest du das?

import re

def uebersetze_hotel(fremd):
    nr = re.search(r"H-\d+", fremd["BEZ"])
    if nr is None:
        raise ValueError("keine Hotelnummer")
    if fremd["ST"] not in ("F", "B"):
        raise ValueError("unbekannter Status")
    text = fremd["PREIS"].replace(" EUR", "").replace(",", "")
    euro, cent = text.split(".")
    return Angebot(nr.group(), int(euro) * 100 + int(cent), fremd["ST"] == "F")

uebersetze_hotel

Der Preis wird als Text zerlegt und aus ganzen Zahlen zusammengesetzt. Ein float mal 100 rundet bei Werten wie 1.15 falsch (114,99999…). Alle fremden Namen und Formate stehen in dieser einen Funktion, im Rest des Codes kommt nur Angebot vor.

Übung 2: Domain Event erzeugen und im anderen Kontext verarbeiten (ca. 10 Min.)

Die Buchung ist ein Aggregate mit den Status "offen", "bestaetigt" und "storniert". Du schreibst zwei Methoden und den Handler der Buchhaltung. __init__ und ereignisse_abholen sind schon da.

  • bestaetigen(): nur aus "offen" erlaubt (sonst ValueError). Danach Status "bestaetigt" und ein Event {"typ": "BuchungBestaetigt", "buchung_id": ..., "kunden_id": ..., "betrag_cent": ...}.
  • stornieren(): erlaubt aus "offen" und "bestaetigt" (sonst ValueError). Danach Status "storniert" und ein Event {"typ": "BuchungStorniert", "buchung_id": ...}.
  • Wirft eine Methode, entsteht kein Event.
  • buchhaltung_handler(ereignis, rechnungen): rechnungen ist ein Dict buchung_id -> {"empfaenger_id": ..., "betrag_cent": ..., "status": "offen"}. Bei BuchungBestaetigt legt er die Rechnung an, bei BuchungStorniert setzt er den Status einer vorhandenen Rechnung auf "storniert". Alle anderen Ereignisse ignoriert er. Er arbeitet nur mit dem Event, nicht mit dem Buchungs-Objekt, und er muss idempotent sein: dasselbe Event zweimal ändert nichts gegenüber einmal.

Das Event beschreibt etwas, das schon passiert ist. Wann im Ablauf der Methode darf es also erst entstehen? Und was muss der Handler tun, damit ein zweites Eintreffen nichts verändert?

class Buchung:
    def __init__(self, buchung_id, kunden_id, betrag_cent):
        self.buchung_id = buchung_id
        self.kunden_id = kunden_id
        self.betrag_cent = betrag_cent
        self.status = "offen"
        self._ereignisse = []

    def bestaetigen(self):
        if self.status != "offen":
            raise ValueError("nur offene Buchungen lassen sich bestätigen")
        self.status = "bestaetigt"
        self._ereignisse.append({"typ": "BuchungBestaetigt", "buchung_id": self.buchung_id,
                                 "kunden_id": self.kunden_id, "betrag_cent": self.betrag_cent})

    def stornieren(self):
        if self.status == "storniert":
            raise ValueError("schon storniert")
        self.status = "storniert"
        self._ereignisse.append({"typ": "BuchungStorniert", "buchung_id": self.buchung_id})

    def ereignisse_abholen(self):
        ereignisse, self._ereignisse = self._ereignisse, []
        return ereignisse

def buchhaltung_handler(ereignis, rechnungen):
    if ereignis["typ"] == "BuchungBestaetigt":
        rechnungen[ereignis["buchung_id"]] = {
            "empfaenger_id": ereignis["kunden_id"],
            "betrag_cent": ereignis["betrag_cent"],
            "status": "offen",
        }
    elif ereignis["typ"] == "BuchungStorniert":
        if ereignis["buchung_id"] in rechnungen:
            rechnungen[ereignis["buchung_id"]]["status"] = "storniert"

Buchung, buchhaltung_handler

Die Prüfung steht vor der Statusänderung und dem Event, darum erzeugt ein gescheiterter Aufruf kein Event. Der Handler schreibt feste Werte statt zu addieren, so ist er idempotent. Er kennt nur das Event, nicht die Buchung.

Übung 3: Zwei Entscheidungen aus der Praxis (Trade-off, ca. 6 Min.)

Zwei unabhängige Fälle. Trage ein Tupel mit zwei Buchstaben ein, z. B. ("A", "B").

Fall 1. Bei Nordlicht gibt es drei Teams nach Schichten: Web-Team, API-Team und Datenbank-Team, je 5 Personen. Jede neue Funktion, etwa eine weitere Zahlungsart, braucht alle drei Teams nacheinander, Tickets wandern hin und her, ein Release dauert Wochen. Die Firma wächst im nächsten Jahr auf 40 Personen. Kein Team soll das ganze System im Kopf haben müssen, und Funktionen sollen ein Team allein bis zum Release bringen können. Was löst das am direktesten?

  • A Jede Schicht als eigenen Service betreiben, damit die drei Teams unabhängig voneinander ausliefern können.
  • B Neue Teams entlang der Fachkontexte bilden (Buchung, Zahlung, Support), jedes mit Web-, API- und Datenbankwissen.
  • C Einmal pro Woche ein gemeinsames Planungstreffen aller drei Teams, in dem die Tickets verteilt werden.
  • D Alle 15 Personen zu einem Team mit einem gemeinsamen Backlog zusammenlegen, damit Übergaben entfallen.

Fall 2. Das Buchhaltungs-Team von Nordlicht liest Zahlungen von einem externen Bank-Anbieter. Dessen Datenformat hat kryptische Feldnamen und deutsche Zahlenformate. Der Anbieter hat sehr viele Kunden und ändert sein Format für niemanden, ein gemeinsames Planen mit ihm ist nicht möglich. Das Team will sein eigenes Fachmodell sauber halten. Was passt?

  • A Mit dem Anbieter ein gemeinsames Modell-Paket pflegen, das beide Seiten einbinden und nur gemeinsam ändern.
  • B Dem Anbieter eine Anforderungsliste schicken, damit er sein Format nach euren Bedürfnissen liefert.
  • C Das fremde Modell überall im eigenen Code übernehmen, damit keine Umrechnung nötig ist.
  • D An der Grenze eine Übersetzungsfunktion bauen, die fremde Daten in euer Modell wandelt.

Fall 1: Welche Struktur erzeugt die vielen Übergaben, und was müsste sich an ihr ändern? Fall 2: Welche Beziehungsmuster setzen voraus, dass die Gegenseite mitspielt?

antwort = ("B", "D")
antwort

Fall 1, B (Inverse Conway Maneuver). Die Übergaben entstehen aus dem Teamzuschnitt nach Schichten. Teams entlang der Kontexte besitzen eine Funktion von der Oberfläche bis zur Datenbank, jedes Team versteht nur seinen Kontext (Cognitive Load). Eigene Services pro Schicht (A) würden die Schichtstruktur nur in den Code übertragen, das Feature braucht weiter alle drei. Ein Planungstreffen (C) verwaltet die Übergaben, statt sie abzuschaffen. Ein einziges Team (D) beseitigt die Übergaben, überfordert bei 40 Personen aber jede einzelne.

Fall 2, D (Anti-Corruption Layer). Der Anbieter kooperiert nicht und ändert nichts. Die Übersetzungsschicht hält fremde Namen und Formate aus dem eigenen Modell heraus und ist die einzige Stelle, die sich bei einer Formatänderung anpasst. Ein Shared Kernel (A) und Customer/Supplier (B) setzen ein kooperierendes Gegenüber voraus. Das fremde Modell zu übernehmen (C, Conformist) ist die Gegenentscheidung: Das eigene Modell wird vom Fremden bestimmt.

Merksatz

Erzeuge Domain Events erst nach gültiger Änderung und lass andere Kontexte sie idempotent verarbeiten, schütze dein Modell mit einem Anti-Corruption Layer an der Grenze zu Fremdem und schneide die Teams entlang der Bounded Contexts (Conway’s Law).

Prüfstein

  1. Wann erzeugt ein Aggregate ein Domain Event, und warum muss der Handler im anderen Kontext idempotent sein?
  2. Ein Team ist nach Schichten geschnitten, und jede Funktion braucht drei Teams. Was sagt Conway’s Law dazu, und was änderst du?

Quelle: quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 6 (Strategisches DDD: Context Mapping, Anti-Corruption Layer, Shared Kernel, Customer/Supplier; Taktisches DDD: Domain Events; Organisation und Architektur: Conway’s Law, Team Topologies, Inverse Conway Maneuver, Cognitive Load).

Über die Quelle hinaus (allgemeines Fachwissen): die Erklärung der einzelnen Begriffe, Conformist, die Erklärung von Upstream und Downstream, Events als Tatsachen der Vergangenheit und die Anforderung an idempotente Handler, die Beschreibung von Team Topologies als Stream-aligned Team (bitte prüfen), der Zusammenhang von Cognitive Load und Teamgröße, der Preisparser als Beispiel für einen ACL (Format ist erfunden). Das Reisebüro Nordlicht und alle Zahlen sind erfunden bzw. stammen aus dem Ausführen des Codes dieser Lektion, nicht aus Messungen realer Systeme.

Zurück zu Teil 1: Bounded Context und Aggregate.