Event Sourcing — Design-Notes & Entscheidungs-Log
Status: Lebendiges Dokument — ergänzt Sprint E (2026-04-17) mit Marten-Gold-Standard-Entscheidungen. Datum: 2026-04-16 (erstellt), 2026-04-17 (Sprint E ergänzt) Zweck: Prinzipien, Entscheidungen und Haltungen, die in den Gesprächen vor dem Spike entstanden sind. Soll verhindern, dass wir uns während der Implementierung fragen “was war nochmal unsere Haltung zu X?”
Sprint-E-Update: Nach Architektur-Audit wurde der Stack auf Marten’s Gold-Standard gezogen. Konkrete APIs und Migrations-Status stehen in event-sourcing-pivot.md §13.
Ergänzt:
event-sourcing-pivot.md(WAS wir bauen)event-sourcing-spike-1.md(WIE wir den Spike fahren)
Dieses Dokument sammelt WARUM — die Prinzipien hinter beiden Plänen.
1. Guiding Principles
1.1 Innovate the packaging, keep the physics
Wir erfinden kein neues Speicher-Paradigma. Event Sourcing ist seit Jahrzehnten etabliert (Buchhaltung: 500+ Jahre, Fowler-Artikel: ~2005, Greg Young / Udi Dahan: ~2010). Die Physik ist bekannt: Events sind immutable, Aggregates sind Event-Streams, Projections sind materialisierte Read-Models.
Unsere Innovation liegt ausschließlich auf der Ergonomie-Schicht:
- Wie sich die API anfühlt
- Welche Defaults wir committen
- Wie wir Konzepte einführen (Didaktik)
- Welche Tools wir bauen (CLI, Inspect, Rebuild)
Merksatz: Wir sind der Rails für TypeScript-ES, nicht der nächste Event-Sourcing-Theoretiker.
1.2 Progressive Disclosure
Die Level 1-4 sind nicht Marketing — sie sind Didaktik. Die meisten User starten Level 1 und bleiben dort. Die, die weitergehen, tun das weil ihr Domain-Problem es fordert, nicht weil das Framework sie zwingt.
Konsequenzen:
- Level-1-API muss identisch zu CRUD-Ergonomie funktionieren (
r.entity,r.crud) - ES-Jargon (Events, Aggregates, Projections, Upcaster) kommt erst ab Level 2
- Docs-Landing-Page spricht Benefits, nicht ES
- Samples beginnen mit Level 1, zeigen Level 2-4 als expliziten Aufstieg
1.3 Don’t invent, simplify
Bei jeder Design-Entscheidung: Macht dieses System ein bekanntes ES-Konzept einfacher? Oder erfindet es was Neues?
- Einfacher → ja
- Neues → fast immer nein
Ausnahmen (wo wir erfinden dürfen, weil die Ergonomie sonst leidet):
- Deklarative Upcaster statt imperativer Code-Hooks
- Auto-Generation von Default-Projections aus Entity-Definitions
- Inline-Projection als unumstösslicher Default
Nicht-Ausnahmen (wo wir nicht erfinden):
- Storage-Modell
- Konsistenz-Garantien
- Aggregate-Konzepte
- Event-Schema-Evolution (Upcaster-Prinzip selbst)
2. Architectural Inheritance — von Marten lernen
Marten (C#, Postgres-ES) ist unser architektonisches Vorbild. Battle-tested, produktiv, liefert Beweise dass Postgres-basiertes ES bei realistischer Last funktioniert.
2.1 Was wir übernehmen
| Von Marten | Warum |
|---|---|
| Drei Projection-Lifecycles (Inline, Async, Live) | Deckt alle realistischen Use-Cases ab; Inline als Default löst Eventual-Consistency-Surprise |
| Stream = Aggregate Convention | Ein Stream pro Aggregate-ID — simpel und universell |
| Expected-Version Optimistic Concurrency | Atomic via Unique-Constraint auf (aggregate_id, version) |
| Snapshot als spezielle Projection | Kein Extra-Konzept — Snapshot ist nur eine Projection mit Materialisierung |
| Projection-Rebuild als First-Class-Operation | CLI, API, Monitoring — nicht nachträglich aufgesattelt |
| Versionierte Events | Version-Tag am Event für Upcaster-Runtime |
| Storage-Model in Postgres-Tabellen | Keine Black-Box-Event-DB — User kann immer SELECT * FROM events |
2.2 Was wir bewusst nicht übernehmen
| Marten-Ansatz | Warum nicht |
|---|---|
OO-Klassen mit Apply()-Methoden | TS-Idiome bevorzugen Plain-Objects + Functions |
| Reflection-basierte Registration | Explizite DSL (r.projection(...)) ist typsicherer + lesbarer |
| Document-Store-Konflation (Marten ist beides) | Kumiko ist fokussiert auf ES — Document-Store ist nicht unser Auftrag |
ForeignKey-Attributes für Relations | Wir haben r.relation bereits |
| ES-Wissen als Voraussetzung in den Docs | Progressive Disclosure überschreibt das |
Imperative IEventUpcaster | Deklarative Transforms (rename, default, map) sind lesbarer |
2.3 Wo wir über Marten hinausgehen
- Default-Projection auto-generiert aus Entity-Fields — Marten lässt dich die immer selbst schreiben
- Type-Inferenz durch TypeScript — bei Marten musst du Generics manuell setzen
- Zod als Event-Schema — Validation + Types in einer Quelle
- Progressive Disclosure als didaktisches Framework — Marten setzt ES-Wissen voraus
- CLI mit Inspect + Timeline — Marten hat das auch, aber wir können’s klarer bauen
- Observability-Metrics out-of-the-box — Projection-Lag, Rebuild-Duration, Events/s als Default
2.4 Positionierung in der Landscape
- Marten: der Standard für C#
- Rails Event Store: der Standard für Ruby
- Axon: der Standard für Java
- EventStoreDB: der Purpose-Built Store (sprachen-agnostisch)
- TypeScript: leeres Feld
Kumikos Positionierung: “The Marten for TypeScript” — übernimmt Architektur, fügt TS-native Ergonomie + Progressive Disclosure hinzu.
3. Storage-Architektur — Entscheidungen
3.1 Event-Store = Postgres. Kein Mongo, kein EventStoreDB.
Entscheidung: Postgres ist der Event-Store. Alle anderen Optionen wurden evaluiert und abgelehnt.
Gründe gegen MongoDB:
- Multi-Document-TX erst seit 4.0, Restriktionen bei Sharding
- Atomic Append + Projection-Update in einer TX → bei PG trivial, bei Mongo komplizierter
- Zusätzlicher Docker-Service für User = mehr Install-Komplexität
- JSONB in PG deckt 90% der Mongo-Schema-Flexibilität ab
Gründe gegen EventStoreDB / Kafka:
- Extra Service mit eigener Ops-Komplexität
- Framework-User müssten das zusätzlich lernen/betreiben
- Overkill für realistische SaaS-Scale (< 100k Events/s)
- Verwässert die “nur Postgres”-Simplizität-Zusage
Referenzen die uns bestärken:
- Marten (Postgres, produktiv in vielen SaaS)
- Rails Event Store (Postgres)
- Greg Young selbst: “For most cases, a SQL database is fine.”
3.2 Redis-Rollen — komplementär zu PG, nicht konkurrierend
Redis ist kein Event-Store. Redis ist der Speed-Layer für:
| Rolle | Mechanik |
|---|---|
| Fanout-Bus | Outbox-Poller liest PG, publisht Pub/Sub für SSE/Search/Jobs/Notifications |
| Real-time Event-Subscriptions | Redis-Streams mit XREAD BLOCK + Backfill |
| SSE-Broker | Cross-Server-Fanout |
| Idempotency-Cache | Request-ID mit TTL (wie heute) |
| Distributed Locks | Projection-Rebuild-Koordination, Snapshot-Writes |
| Optional: Projection-Cache | Read-Through-Cache für hot Projections (Phase 3+) |
Klare Arbeitsteilung:
- Postgres = Wahrheit (ACID, TX, Langzeit)
- Redis = Speed (Ephemer, Fanout, Coordination)
Kein Overlap. Kein doppeltes Source-of-Truth.
3.3 Default Synchronous Projections
Das ist die einzelne wichtigste Architektur-Entscheidung.
Projection-Updates passieren in derselben TX wie der Event-Append. Konsequenz: Read-after-Write funktioniert ohne Überraschungen.
- Async-Projections als opt-in für Analytics / High-Volume
- Live-Projections als opt-in für compute-on-read (z.B. counts, sums)
- Default Inline. Das ist die UX-Zusage.
Wenn Gate A im Spike (Performance) kippt → wir müssen Async-Default machen → das bricht die Level-1-”fühlt-sich-wie-CRUD-an”-Zusage → Plan neu denken.
3.4 Events-Table Schema (Minimum)
events ( id BIGSERIAL PRIMARY KEY, aggregate_id UUID NOT NULL, aggregate_type TEXT NOT NULL, tenant_id UUID NOT NULL, version INTEGER NOT NULL, type TEXT NOT NULL, event_version INTEGER NOT NULL DEFAULT 1, -- für Upcaster payload JSONB NOT NULL, metadata JSONB NOT NULL, -- userId, requestId, timestamp, etc. created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (aggregate_id, version) -- optimistic concurrency)
CREATE INDEX ON events (aggregate_type, tenant_id, created_at);CREATE INDEX ON events (aggregate_id, version);Partitionierung nach tenant_id ab Skalierungsbedarf. Nicht Tag 1.
4. Positioning & Naming — Entscheidungen
4.1 Kein Buzzword-Rebranding
Entschieden gegen:
- “Progressive Event Sourcing™”
- “Invisible Event Sourcing”
- “Kumiko Pattern™”
- “Adaptive Storage Model”
Entschieden für:
- Klare Beschreibung in Dokumentation
- ES heißt ES — weil es ES ist
- Positionierung über Ergonomie-Versprechen, nicht Marketing-Vokabular
4.2 Landing-Page-Strategie: Benefits first
Die Marketing-Hierarchie:
Level 1 (für alle Dev-Leser): Benefits
- “Never lose data.”
- “Complete audit trail by default.”
- “Time-travel debugging built-in.”
- “Rebuild any view in seconds.”
Level 2 (technisch interessiert): Was macht’s
- “Built on Event Sourcing.”
- “Postgres-native. No extra services.”
- “Synchronous projections — read-after-write just works.”
Level 3 (tiefe Docs): ES-Konzepte
- Aggregates, Projections, Upcasters, Snapshots
- Hier setzt der User ES-Kenntnisse voraus
4.3 Zielgruppen
Primär:
- Compliance-getriebene SaaS: FinTech, Healthcare, B2B-Audit-pflichtig
- Domain-Driven-Design-Anwender: Leute die DDD-Taktisch schon denken
- Ex-Marten/Ex-Axon-Devs: die wollen das in TypeScript
Sekundär:
- TypeScript-Devs die über ES gelesen haben und’s ausprobieren wollen, aber keinen Einstieg gefunden haben
Nicht primär:
- Rapid-Prototyping-User (die nehmen Supabase, tRPC, etc.)
- Frontend-first-Teams (die haben andere Frameworks)
5. Design Patterns — Was wir committen
5.1 Progressive Disclosure (siehe 1.2)
5.2 Transactional Outbox für Fanout
Der bestehende Outbox-Mechanismus bleibt, wird aber umdefiniert:
- Outbox-Row wird in selber TX wie Event-Append geschrieben
- Outbox-Poller liest und publisht Redis-Pub/Sub
- Guarantee: At-least-once Delivery (nicht exactly-once — das ist ein Distributed-System-Unmöglichkeit)
- Consumer müssen idempotent sein (SSE ist’s natürlich, Search-Indexer auch, Notifications via Idempotency-Keys)
5.3 Aggregate-Boundary = eine Entity-Instanz
Default: Ein Aggregat = ein aggregate_id = eine Entity-Instanz.
Cross-Aggregate-Transactions sind möglich (mehrere Events in selber TX), aber:
- Kein Cross-Aggregate-Reducer (keine geteilten State-Maschinen)
- Cross-Aggregate-Coordination über Events (anderes Aggregat reagiert auf Event)
Saga-Pattern wird als Sample später demonstriert, nicht im Kern-Framework verdrahtet.
5.4 Ein Modus — Event Sourcing überall, Projection als relationale Sicht
Jede Entity, die im Postgres lebt, ist event-sourced. Keine Opt-Out, kein CRUD-Modus, kein Mutable-Table als Primary-Storage.
r.entity("user", { fields: { email, passwordHash } });r.crud("user");// → Events: user.created / user.updated / user.deleted// → Projection: users-Tabelle (auto-generiert, sieht aus wie CRUD-Tabelle)// → Audit: die Events selbst// → SELECT * FROM users funktioniert ganz normal — users IST die ProjectionWarum nur ein Modus:
- Relationale DB = Projection + Aggregation auf Events zum Jetzt-Zeitpunkt. Das ist ohnehin die technische Wahrheit jedes CRUD-Systems. Wir machen’s explizit, nicht implizit.
- Projection ist Read-Model. Sieht aus wie eine normale SQL-Tabelle, ist SQL-queryable, indexierbar, via Drizzle zugreifbar. Der User kriegt CRUD-Ergonomie weil die Projection eine CRUD-Tabelle ist — nur eben aus Events materialisiert.
- Doppelte Write-Pfade (Mutable-Table + Events) bringen nichts. Entweder man schreibt Events → dann kann die Projection daraus entstehen. Oder man schreibt keine Events → kein Audit → gehört nicht ins Framework.
- Einfachheit gewinnt. Ein Modell, ein Code-Pfad, eine Doku-Story, eine Mental-Model-Last.
Ephemere Daten sind nicht Framework-Entities:
Sessions, Rate-Limit-Counter, Live-Presence-Heartbeats, Cache-Entries → gehören in Redis, nicht als Kumiko-Entity. Das sind keine Domain-Daten, sondern Infrastruktur-Zustand. Redis ist dafür das richtige Werkzeug.
- Kein
r.infraTable, keinr.ephemeralTable— wir bauen das nicht auf Vorrat. - Falls der Bedarf real wird (z.B. große Feature-Flag-Tabellen, die nicht nach Redis passen), bauen wir’s gezielt. YAGNI bis zum Beweis des Gegenteils.
Reference-Data bleibt separat (r.referenceData() — Länder, Währungen, Sprachen). Seed-Daten-Mechanismus, kein Entity-Lifecycle, kein ES — das ist unverändert und orthogonal.
Compliance-Versprechen: “Every state change in Kumiko is an event. Audit by default. Time-travel by default. Rebuild by default. No configuration.”
6. Non-Goals (explizit ausgeschlossen)
Damit wir wissen, was nicht auf den Tisch kommt:
- Opt-In-ES per Entity (
createEntity({ eventSourced: true })) — zwei Systeme zu pflegen ist schlechter als eins - Custom Event-Store-Backend-Option (Kafka, EventStoreDB als Alternative) — Postgres-only
- Event-Upcaster mit beliebiger Logik — nur deklarative Transforms; imperative Escape-Hatch nur für Edge-Cases
- Domain-Specific-Languages für Aggregate-Reducer — Plain Functions reichen
- Distributed Event-Processing (Akka-style) — overkill
- GraphQL-Layer über Projections — ausserhalb vom Framework-Scope
- Neue Event-Serialization-Formate — JSONB via Zod ist genug
- Native Admin-UI (eigenes Framework-Feature) — CLI +
inspect-Route sind die Bausteine
7. Real-time Event-Subscriptions als USP
Eins der spezifischen Features, das wir als echten Differentiator positionieren:
GET /api/events/subscribe?type=task.completed&since=<event_id>
→ SSE-Stream mit Backfill-Capability→ Client kann Reconnect und ab verpasstem Event weiterhören→ Redis-Streams liefern's mit XREAD BLOCKWas andere machen:
- Supabase: über Postgres-Replication — mächtig, aber nicht Event-native
- tRPC: Polling oder WS — keine Event-Stream-Semantik
- Firebase: proprietär, Vendor-Lock-in
Was Kumiko macht:
- Native Event-Stream-Abo
- Event-ID als Resume-Point
- Tenant-Isolation eingebaut
- Client kann Event-Types filtern
Das ist ein echtes Killer-Feature für Realtime-Dashboards, Live-Analytics, Multi-User-Kollaboration. Sollte prominente Stelle in den Docs bekommen.
8. Offene Design-Fragen (vor Phase 2 klären)
Dinge die beim Spike oder parallel beantwortet werden sollten:
- Snapshot-Invalidierung bei Reducer-Code-Change — Hash über Reducer? Manual bump via
snapshotVersion? - Event-Archivierung / TTL — ab wann darf ein Aggregate in Cold-Storage? Policy-driven, später.
- GDPR-Shredding-Strategie — Crypto-Shredding per Tenant-Key? Tombstone-Events? Hybrid? → ADR in W1 parallel zum Spike.
- Cross-Aggregate-Transactions — atomic oder Saga? Default atomic, Saga als Pattern.
- Multi-Tenant Events-Table — eine Tabelle mit Partitioning, oder eine pro Tenant? → eine, Partitioning ab Skalierungsbedarf.
- Default-Reducer bei
r.crud— wie weit gehen Konventionen? Beifield.type === "reference"wie mit Loading umgehen? - Tenant-Isolation-Layer im Event-Store — Row-Level-Security mit Postgres-RLS oder Application-Level-Filter? Entscheidung im Spike.
9. Prinzipien-Zusammenfassung (One-Pager)
Für schnellen Lookup während der Implementierung:
- Innovate packaging, keep physics — ES-Konzepte sind etabliert, wir bauen Ergonomie darauf.
- Progressive Disclosure — Level 1 = CRUD-Feeling, Level 4 = ES-Power.
- Postgres = Wahrheit, Redis = Speed. Keine Vermischung.
- Inline Projections als Default — Read-after-Write-UX ist nicht verhandelbar.
- Marten-Architektur + TS-Ergonomie — keine Theorien-Innovation.
- Benefits-first Marketing, ES-Jargon second. Buzzwords killen Adoption.
- Nur ES. Alle Postgres-Entities sind event-sourced. Projection = relationale Sicht, SQL-queryable. Ephemeres (Sessions, Counter) → Redis, außerhalb vom Framework.
- Declarative über imperative für Upcaster, Reducer-Definitions, Projection-Maps.
- Real-time Event-Subscriptions als USP-Feature — nicht nur Nebenprodukt.
- Bei Unsicherheit: was würde Marten machen? Als Fallback-Heuristik.
10. Aufräum-Inventur — Was ändert sich im bestehenden Code
Mit ES-Commitment + Stack-Festlegung auf Postgres wird folgendes im bestehenden Code fällig.
10.1 Stack-Festlegung
Festgezurrt auf Postgres + Redis + Meilisearch. Keine Alternativen, keine Dialekt-Abstraktion.
db/dialect.ts→ rausdb/pg-adapter.ts→ umbenennen zudb/postgres.ts, kein Adapter-Pattern mehr- SQLite bleibt nur für Unit-Test-Convenience (in-memory), nicht als produktiver Dialekt
10.2 Weg damit
| Raus | Grund |
|---|---|
Multi-DB-Abstraction (db/dialect.ts) | Stack-Commitment |
| Audit-System-Hook (Priority 1002 in der Pipeline) | Events sind Audit — separates System redundant |
Core-Feature audit-trail (als eigenständiges Feature) | Ersetzt durch Events-als-Audit, global |
| Entity-Version-Column als Optimistic-Locking-Mechanismus | Ersetzt durch Event-Level expected_version |
pipeline/cascade-handler.ts | Cascade-Logik wird Event-getrieben, nicht Row-Operation |
10.3 Bleibt mit neuer Rolle
| Heute | Neue Rolle |
|---|---|
pipeline/outbox-table.ts | Fanout-Queue für Events, nicht mehr generischer Event-Dispatch |
pipeline/outbox-poller.ts | Liest Events aus PG → publisht Redis-Pub/Sub |
pipeline/event-broker.ts | Redis-Pub/Sub-Layer |
pipeline/idempotency.ts | Request-Level bleibt, Event-Level (expected_version) kommt dazu |
pipeline/entity-cache.ts | Optional für hot Aggregates — Phase 3+ |
| Validation-Hooks | Laufen auf Commands (vor Event-Append), Position identisch |
| Field-Level-Access (Write) | Beim Event-Authoring geprüft |
| Field-Level-Access (Read) | Beim Projection-Read gefiltert |
r.hook("preSave", ...) / postSave | Semantisch umbenannt zu preEvent/postEvent/postProjection |
10.4 Neu dazu
| Neu | Zweck |
|---|---|
db/event-store.ts | Core Append/Load/Snapshot |
db/projection-runtime.ts | Apply + Rebuild |
db/upcaster.ts | Event-Schema-Evolution |
db/snapshot-store.ts | Snapshot-Persistenz |
engine/reducer-builder.ts | Auto-Default-Reducer aus Fields |
engine/projection-shape-builder.ts | Auto-Default-Projection |
r.event(), r.projection(), r.apply(), r.eventMigration() | Level-2-4-API |
CLI: inspect, project rebuild/list/status, events tail | Power-Tools |
10.5 Core-Features — Speicher-Zuordnung
Alle Postgres-Entities sind ES. Ephemeres wandert nach Redis (kein Framework-Konstrukt).
| Feature | Speicher | Begründung |
|---|---|---|
| Domain-Entities (Task, Order, Invoice, Payment, Ticket) | ES | Audit + Time-Travel by default |
delivery | ES | Zustellungs-Historie = Events |
notification | ES | Delivery-Attempt-History wertvoll |
user | ES | Email-/Password-Change-Audit automatisch |
tenant | ES | Billing- und Config-Änderungen automatisch auditiert |
| Session-Tokens, Auth-Tokens | Redis (nicht Framework-Entity) | Ephemer, TTL-basiert, keine Domain-Daten |
| Rate-Limit-Counter, Presence-Heartbeats | Redis | Ephemer, high-volume, kein Audit-Wert |
channel-*, renderer-* | Stateless | Keine Entity |
| Reference-Data | r.referenceData() | Seed-Daten-Mechanismus, orthogonal zu ES |
10.6 Operations-Konsequenzen (für Phase 5 / Launch)
- Backup-Strategie: Events sind der kritischste Teil — Projections sind rebuildable
- PG-Events-Table wächst monoton: Archivierung-Policy nach ~1 Jahr als Cold-Storage (Thema für später)
- Neue Observability-Metrics: Projection-Lag, Event-Rate, Snapshot-Frequency — passt in die laufende Observability-Arbeit ohne Reibung
- Migrations: Drizzle nur für Events-Table + Projections. Event-Schema-Evolution via Upcaster, nicht ALTER TABLE
- Installationsanleitung: vereinfacht sich zu “Kumiko läuft auf Postgres + Redis + Meilisearch.”
10.7 Samples — Auswirkung
Kurzüberblick (Details in event-sourcing-pivot.md § 5.11):
- Meiste Samples unverändert syntaktisch (Level-1-API ist identisch zu CRUD)
audit-trail→event-timeline(renamed, Konzept entfällt als separates Feature)- Neue Samples:
time-travel,projections,schema-evolution,domain-events,event-subscriptions - Full-App-Samples (mietnomade, beammycar): werden Level-1-ES-Demos
Dieses Dokument wird während Spike + Phase 2 lebendig gehalten. Neue Entscheidungen kommen dazu, alte werden verfeinert. Ziel: am Launch-Tag ist das die Referenz für jeden Contributor und neugierigen User “warum so und nicht anders”.