Skip to content

Modul 11: Event Sourcing & CQRS

Event Store, Append-only Streams, Replay, Snapshots, Optimistic Concurrency, CQRS und Outbox. Wenn der Zustand aus Ereignissen entsteht — und wann das zu weit geht.

Fortschritt0/7 Lektionen
51

Event Sourcing & CQRS

Lektion 51 von 7

Vom Zustand zum Ereignis

CRUD speichert den aktuellen Zustand: UPDATE orders SET status = 'SHIPPED' — und der vorherige Zustand ist weg. Event Sourcing dreht das um: Der Event Store speichert jede Zustandsänderung als unveränderliches Ereignis, und der aktuelle Zustand ist nur das Ergebnis (die Faltung) aller Ereignisse. Die Bestellung wurde angelegt, bezahlt, versendet — jede dieser Fakten liegt einzeln und unveränderlich im Store. Das gibt vollständige Historie, Audit-Trail und Zeitreise umsonst — bezahlt wird mit Komplexität. Dieses Modul erklärt beide Seiten, plus CQRS als natürliche Begleiterscheinung.

Lesson 1: Event Store statt CRUD-State

Ein CRUD-System speichert eine Zeile pro Bestellung und überschreibt sie. Ein Event Store speichert einen Stream pro Aggregat: order-{id} enthält alle Ereignisse in Reihenfolge, mit fortlaufender Versionsnummer. Ereignisse sind Fakten aus der Vergangenheit — OrderPlaced, PaymentReceived — nicht Befehle (PlaceOrder). Sie sind immutable: kein UPDATE, kein DELETE, nur Append.

// Stream: order-2091
[
  { "version": 1, "type": "OrderPlaced",     "at": "2026-03-20T14:00:02Z", "data": { "customerId": "u-8123", "items": 3, "total": 84.50 } },
  { "version": 2, "type": "PaymentReceived", "at": "2026-03-20T14:00:41Z", "data": { "transactionId": "tx-991", "amount": 84.50 } },
  { "version": 3, "type": "OrderShipped",    "at": "2026-03-20T16:12:07Z", "data": { "trackingId": "AT-55812" } }
]

Der aktuelle Zustand ("shipped, bezahlt, 84.50") steht nirgends als Zeile — er ergibt sich aus dem Stream. Genau das ist der Denkwechsel: nicht "Wie ist der Zustand?", sondern "Welche Ereignisse sind passiert?".

Lesson 2: Append-only Streams und das Aggregat Bestellung

Das Aggregat (Modul 4) ist die Konsistenzgrenze — im Event Sourcing ist es auch die Stream-Einheit. Alle Ereignisse einer Bestellung landen in order-2091, nie in einem globalen Strom. Um den Zustand zu laden, replayt man den Stream: mit leerem Aggregat starten, jedes Ereignis anwenden.

function rebuild(events: DomainEvent[]): Order {
  return events.reduce(applyEvent, Order.empty());
}
// applyEvent switcht über den Typ: OrderPlaced legt an,
// PaymentReceived setzt status = PAID, OrderShipped setzt status = SHIPPED.

Append-only ist keine Stilfrage, sondern die Garantie, die alles andere trägt: Historie ist unverfälschbar, Auditing ist gratis, Concurrent Writes werden über die Stream-Version erkennbar (Lesson 5).

Lesson 3: Replay, Projektionen und Snapshots

Der Stream ist die Wahrheit — aber niemand fragt "Zeig mir alle Events von Bestellung 2091", man will den Zustand. Projektionen (Read Models) faltet man aus Events: eine OrderSummary-Tabelle für die UI, ein Statistik-Index, ein Suchdokument. Projektionen sind abgeleitet und damit wegwerfbar: Jederzeit löschbar und aus dem Event Store neu aufbaubar. Genau das macht Schema-Änderungen im Nachhinein möglich — neues Read Model, einmal komplett replayen, fertig.

flowchart LR
  ES[Event Store] -->|subscribe| P1[OrderSummary-Projektion]
  ES -->|subscribe| P2[Statistik-Projektion]
  P1 --> DB[(Read DB)]
  P2 --> DB

Replay wird mit wachsenden Streams teuer — eine Bestellung mit 10.000 Änderungen will man nicht bei jedem Request falten. Snapshots begrenzen die Kosten: Alle N Events speichert man den Zustands-Snapshot, beim Laden beginnt man beim Snapshot und replayt nur den Rest.

Lesson 4: CQRS — getrennte Lese- und Schreibseite

Event Sourcing zieht Command Query Responsibility Separation fast zwangsläufig nach sich: Die Schreibseite nimmt Commands entgegen, lädt das Aggregat (Replay/Snapshot), prüft Invarianten und appendet Events. Die Leseseite fragt Projektionen ab — optimiert fürs Query, nicht für Invarianten. Ein Modell für beides ("Save und Query in einer Tabelle") ist der Kompromiss, den CQRS aufgibt.

Command ──▶ [Write Side]            [Read Side] ◀── Query
             Aggregat laden            Projektionen
             Invarianten prüfen        denormalisiert,
             Append Event ─────────▶   alsynchron aktualisiert
             (gleiche Wahrheit)        ("stale reads möglich")

Konsequenz: eventual consistency. Die Leseseite hinkt der Schreibseite um Millisekunden bis Sekunden hinterher — direkt nach "Bestellung gespeichert" kann die Liste sie noch nicht zeigen. Das ist kein Bug, sondern das Konsistenz-Modell; UI und Tests müssen damit rechnen (Bestätigungsseiten aus dem Command-Resultat bauen, nicht aus dem Read Model).

Lesson 5: Stream-Versioning und Optimistic Concurrency

Zwei Nutzer ändern dieselbe Bestellung gleichzeitig — ohne Locks entscheidet die Reihenfolge der Appends. Event Stores lösen das mit Optimistic Concurrency: Der Client nennt die Stream-Version, auf der er gearbeitet hat (expectedVersion); der Store nimmt den Append nur an, wenn der Stream genau so weit reicht, sonst ConcurrencyException.

// Erst lesen (Stream-Version 3), Invarianten prüfen, dann appenden:
await store.appendToStream("order-2091", [event], { expectedVersion: 3 });
// Ein zweiter Writer hat inzwischen Version 4 angehängt → 409/ConcurrencyException,
// Aggregat neu laden, Entscheidung auf frischem Zustand wiederholen.

Das ersetzt verteilte Locks durch einen atomaren Vergleich im Store — und bleibt nebenläufigkeitssicher, ohne dass die Write Side blockiert.

Lesson 6: Outbox-Pattern — die Brücke zur Außenwelt

Der Event Store lebt in der Datenbank des Aggregats — aber Newsletter, Shipping-Service und Projektionen in anderen Services brauchen das Event auch. Zwei Writes (Aggregat speichern UND Broker publishen) ohne gemeinsame Transaktion = Dual-Write-Problem: Einer klappt, der andere nicht. Das Outbox-Pattern löst es: Events werden in derselben DB-Transaktion wie das Aggregat in eine Outbox-Tabelle geschrieben; ein Relais (Poller/CDC) liest die Outbox und publiziert mindestens einmal an den Broker. Konsumenten müssen idempotent sein (Event-ID deduplizieren) — dafür ist kein Event verloren.

// Gleiche Transaktion wie der Aggregat-Append:
{ "outboxId": "ob-7734", "eventId": "e-41007", "type": "PaymentReceived",
  "stream": "order-2091", "publishedAt": null }

Lesson 7: Wann Event Sourcing NICHT sinnvoll ist

Event Sourcing ist kein Statussymbol. Es kostet Infrastruktur (Event Store, Projektionen, Replay-Jobs), Denkumstellung (jede Änderung ist ein Event mit Namen und Schema) und Betrieb (Monitoring von Lag und Replays). Gegenargumente: (1) Einfache CRUD-Domänen — ein Adressbuch braucht keine Historie jeder Änderung; der Event-Sourcing-Overhead bringt null Geschäftswert. (2) Compliance/DSGVO: Das Recht auf Vergessenwerden kollidiert mit immutable Events — personenbezogene Daten müssen aus den Events heraus (nur Referenzen im Store, Löschen per Crypto-Shredding des Schlüssels) oder die Domäne gehört gar nicht in einen Event Store. (3) Teams ohne Event-Driven-Erfahrung: Eventual Consistency falsch kommuniziert erzeugt Bugs im Produkt. Faustregel: Event Sourcing dort, wo die Historie selbst Geschäftswert ist (Audit, Regulatorik, komplexe Domänen mit analysierbaren Abläufen) — nicht überall.

Events sind Wahrheit, Zustand ist Sicht

Der Event Store ist die single source of truth; Datenbanktabellen, Suchindizes und Caches sind nur Projektionen — abgeleitet, löschbar, neu aufbaubar. Diese Trennung macht CQRS natürlich: Die Write Side denkt in Invarianten und Events, die Read Side denkt in Queries. Wer den Zustand braucht, faltet ihn aus den Events — oder nutzt eine Projektion, die das schon getan hat.

Praxis-Szenario: Die Bestellung, die zweimal bezahlt wurde

Ein Kunde klickt zweimal auf 'Bezahlen' (Double-Click). Zwei Commands treffen die Write Side fast gleichzeitig. Beide laden das Aggregat, beide sehen status = NEW. Ohne Schutz werden zweimal 84.50 abgebucht. Wie verhindert du das mit Event-Sourcing-Mitteln?

✅ Wichtige Erkenntnisse

Haken setzen, um deinen Lernfortschritt zu markieren:

    This site uses essential cookies for Stripe payments. No tracking cookies.