Event Sourcing vs. klassisches CRUD
Eine klassische CRUD-Tabelle sagt dir den aktuellen Zustand einer Zeile und nichts darüber, wie sie dorthin gekommen ist. Event Sourcing kehrt das um: Es speichert jede Änderung, die je passiert ist, und der aktuelle Zustand wird zu etwas, das man berechnet, nicht speichert. Diese Umkehrung löst echte Probleme und schafft ein paar neue – beides sollte man verstehen, bevor man dazu greift.
CRUD: Zustand an Ort und Stelle, Historie nirgends
Ein normales Update einer orders-Tabelle überschreibt die Zeile:
UPDATE orders SET status = 'shipped', shipped_at = now() WHERE id = 4821;
Nachdem das gelaufen ist, ist der vorherige Status weg, außer etwas anderes – eine Audit-Log-Tabelle, Logging auf Anwendungsebene, ein Trigger – hat ihn separat festgehalten, als nachträglich ans eigentliche Datenmodell angeflanschte Ergänzung. Fragt ein Kunde „warum steht meine Bestellung als versendet da, obwohl ich nie eine Versandbenachrichtigung bekommen habe", und die Antwort erfordert zu wissen, dass sie wegen eines Bugs vor drei Tagen kurz auf shipped und zurück gesprungen ist, dann hat eine CRUD-Tabelle diese Information schlicht nicht mehr.
Event Sourcing: Das Log ist die Quelle der Wahrheit
Statt des aktuellen Zustands speichert man eine nur anhängbare Folge von Ereignissen, und der aktuelle Zustand ergibt sich durch ihr Abspielen:
// events, in order, for order 4821
[
{ type: 'OrderPlaced', orderId: 4821, items: [...], at: '2026-03-20T10:00:00Z' },
{ type: 'PaymentReceived', orderId: 4821, amount: 4999, at: '2026-03-20T10:01:12Z' },
{ type: 'OrderShipped', orderId: 4821, carrier: 'ups', at: '2026-03-22T14:30:00Z' },
]
Der aktuelle Zustand ist ein Fold über diese Liste:
function projectOrder(events) {
return events.reduce((state, event) => {
switch (event.type) {
case 'OrderPlaced':
return { ...state, id: event.orderId, items: event.items, status: 'placed' };
case 'PaymentReceived':
return { ...state, status: 'paid', paidAmount: event.amount };
case 'OrderShipped':
return { ...state, status: 'shipped', carrier: event.carrier };
default:
return state;
}
}, {});
}
const currentOrder = projectOrder(eventsForOrder(4821));
Nichts davon wird gelöscht oder überschrieben. Die vollständige Historie – jeder Zustand, den die Bestellung je durchlaufen hat, und genau wann – ist dem Modell inhärent, kein separates System, das man nicht zu pflegen vergessen darf.
Was man tatsächlich gewinnt
- Ein echter Audit-Trail, gratis. „Was ist passiert, und in welcher Reihenfolge" sind die Primärdaten, nicht ein abgeleitetes Log, das man mit der „echten" Tabelle synchron halten muss.
- Zeitreisen. Man kann den Zustand zu jedem Zeitpunkt der Vergangenheit projizieren, indem man die Ereignisse nur bis zu diesem Zeitstempel abspielt – wirklich nützlich zum Debuggen („wie sah das kurz vor der Fehlermeldung aus") und für Compliance-Anforderungen, die genau das fragen.
- Neue Lesemodelle, ohne Schreiblogik anzufassen. Man braucht eine neue Sicht auf die Daten – Gesamtumsatz pro Tag, Bestellungen pro Versanddienstleister – für ein Dashboard, das es beim Bau des Systems noch nicht gab? Man schreibt eine neue Projektionsfunktion über den bestehenden Event-Strom. Die Ereignisse selbst ändern sich nie.
- Entkoppelte Schreib- und Lesepfade. Die Ereignisse sind das Schreibmodell; Projektionen sind daraus gebaute Lesemodelle, oft asynchron. Das führt natürlich zu CQRS (Command Query Responsibility Segregation), bei dem Schreib- und Lesezugriffe unabhängig skalieren und sich entwickeln können.
Was es tatsächlich kostet
- Jede Abfrage braucht eine Projektion. „Wie ist der aktuelle Status von Bestellung 4821" ist kein Zeilen-Lookup mehr – es ist das Abspielen aller Ereignisse, die diese Bestellung angesammelt hat, es sei denn, man cacht den projizierten Zustand (einen „Snapshot") und spielt nur Ereignisse seit dem letzten Snapshot ab.
// snapshot every 50 events instead of replaying from the start each time
function projectWithSnapshot(orderId, snapshot, eventsSinceSnapshot) {
return eventsSinceSnapshot.reduce(applyEvent, snapshot ?? initialState());
}
- Schema-Evolution wird schwerer, nicht leichter. Man kann nie ändern, was ein altes Ereignis bedeutete – heute geschriebener Code muss ein
OrderPlaced-Ereignis von vor drei Jahren weiterhin korrekt interpretieren, in der Form, die es damals hatte. Einen neuen Ereignistyp hinzuzufügen ist einfach; die Struktur eines bestehenden zu ändern bedeutet, ihn zu versionieren (OrderPlacedV2) und den alten Handler unbegrenzt zu behalten. - Löschen ist absichtlich umständlich. „Lösche die Daten dieses Kunden" (eine echte Anforderung unter der DSGVO) steht direkt im Widerspruch zu „Ereignisse sind unveränderlich und werden nie gelöscht". Echte Systeme lösen das mit Crypto-Shredding (personenbezogene Felder mit einem Schlüssel pro Kunde verschlüsseln, dann den Schlüssel löschen, um die Daten unwiederherstellbar zu machen), statt Ereignisse tatsächlich aus dem Log zu entfernen.
- Debuggen erfordert, in Sequenzen zu denken, nicht in Snapshots. „Was ist mit dieser Zeile falsch" wird zu „was ist mit dieser Folge von 40 Ereignissen und der Projektionslogik, die sie faltet, falsch" – eine wirklich andere Debugging-Denkweise, an die sich ein Team erst gewöhnen muss.
Wo es sich lohnt
Event Sourcing zahlt sich dort aus, wo die Historie für sich genommen echten geschäftlichen Wert hat – Finanzbuchhaltung, Bestelllebenszyklen, alles mit Audit- oder Compliance-Anforderungen, oder Systeme, in denen „was hat sich geändert und warum" eine erstklassige Frage ist, die Nutzer tatsächlich stellen. Eine users-Tabelle mit display_name und avatar_url braucht das fast nie; niemand fragt nach dem Audit-Trail eines Profilbilds.
Ein Mittelweg: Outbox-/Audit-Tabellen ohne vollständiges Event Sourcing
Man muss nicht alles oder nichts wählen. Ein gängiges praktisches Muster ist eine normale CRUD-Tabelle für den aktuellen Zustand, plus eine nur anhängbare order_events-Tabelle, die in derselben Transaktion wie jeder Schreibvorgang befüllt wird:
BEGIN;
UPDATE orders SET status = 'shipped' WHERE id = 4821;
INSERT INTO order_events (order_id, type, payload, at)
VALUES (4821, 'OrderShipped', '{"carrier":"ups"}', now());
COMMIT;
Das bringt den Audit-Trail und die Vorteile der Zeitreise zum Debuggen, ohne jedes Lesen zu einem Replay zu machen – der aktuelle Zustand bleibt ein direkter Zeilen-Lookup. Man verliert die Flexibilität „Lesemodelle sind nur neue Projektionen", aber für die meisten Anwendungen ist das ein fairer Tausch gegen die deutlich einfachere Umsetzung und Nachvollziehbarkeit.
Fazit
CRUD speichert, wo man steht; Event Sourcing speichert, wie man dorthin gekommen ist, und leitet daraus ab, wo man steht. Audit-Trail, Zeitreisen und flexible Lesemodelle sind real und wertvoll – kommen aber mit schwererer Schema-Evolution, umständlichem Löschen und einem grundlegend anderen Debugging-Modell. Greife zu vollständigem Event Sourcing, wenn die Historie der Sinn des Systems ist, nicht nur ein nettes Extra; sonst bringt eine Audit-Tabelle neben normalem CRUD meist den Großteil des Nutzens für einen Bruchteil der Komplexität.