Skip to content

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 MartenWarum
Drei Projection-Lifecycles (Inline, Async, Live)Deckt alle realistischen Use-Cases ab; Inline als Default löst Eventual-Consistency-Surprise
Stream = Aggregate ConventionEin Stream pro Aggregate-ID — simpel und universell
Expected-Version Optimistic ConcurrencyAtomic via Unique-Constraint auf (aggregate_id, version)
Snapshot als spezielle ProjectionKein Extra-Konzept — Snapshot ist nur eine Projection mit Materialisierung
Projection-Rebuild als First-Class-OperationCLI, API, Monitoring — nicht nachträglich aufgesattelt
Versionierte EventsVersion-Tag am Event für Upcaster-Runtime
Storage-Model in Postgres-TabellenKeine Black-Box-Event-DB — User kann immer SELECT * FROM events

2.2 Was wir bewusst nicht übernehmen

Marten-AnsatzWarum nicht
OO-Klassen mit Apply()-MethodenTS-Idiome bevorzugen Plain-Objects + Functions
Reflection-basierte RegistrationExplizite 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 RelationsWir haben r.relation bereits
ES-Wissen als Voraussetzung in den DocsProgressive Disclosure überschreibt das
Imperative IEventUpcasterDeklarative 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:

RolleMechanik
Fanout-BusOutbox-Poller liest PG, publisht Pub/Sub für SSE/Search/Jobs/Notifications
Real-time Event-SubscriptionsRedis-Streams mit XREAD BLOCK + Backfill
SSE-BrokerCross-Server-Fanout
Idempotency-CacheRequest-ID mit TTL (wie heute)
Distributed LocksProjection-Rebuild-Koordination, Snapshot-Writes
Optional: Projection-CacheRead-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 Projection

Warum 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, kein r.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 BLOCK

Was 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:

  1. Snapshot-Invalidierung bei Reducer-Code-Change — Hash über Reducer? Manual bump via snapshotVersion?
  2. Event-Archivierung / TTL — ab wann darf ein Aggregate in Cold-Storage? Policy-driven, später.
  3. GDPR-Shredding-Strategie — Crypto-Shredding per Tenant-Key? Tombstone-Events? Hybrid? → ADR in W1 parallel zum Spike.
  4. Cross-Aggregate-Transactions — atomic oder Saga? Default atomic, Saga als Pattern.
  5. Multi-Tenant Events-Table — eine Tabelle mit Partitioning, oder eine pro Tenant? → eine, Partitioning ab Skalierungsbedarf.
  6. Default-Reducer bei r.crud — wie weit gehen Konventionen? Bei field.type === "reference" wie mit Loading umgehen?
  7. 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:

  1. Innovate packaging, keep physics — ES-Konzepte sind etabliert, wir bauen Ergonomie darauf.
  2. Progressive Disclosure — Level 1 = CRUD-Feeling, Level 4 = ES-Power.
  3. Postgres = Wahrheit, Redis = Speed. Keine Vermischung.
  4. Inline Projections als Default — Read-after-Write-UX ist nicht verhandelbar.
  5. Marten-Architektur + TS-Ergonomie — keine Theorien-Innovation.
  6. Benefits-first Marketing, ES-Jargon second. Buzzwords killen Adoption.
  7. Nur ES. Alle Postgres-Entities sind event-sourced. Projection = relationale Sicht, SQL-queryable. Ephemeres (Sessions, Counter) → Redis, außerhalb vom Framework.
  8. Declarative über imperative für Upcaster, Reducer-Definitions, Projection-Maps.
  9. Real-time Event-Subscriptions als USP-Feature — nicht nur Nebenprodukt.
  10. 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 → raus
  • db/pg-adapter.ts → umbenennen zu db/postgres.ts, kein Adapter-Pattern mehr
  • SQLite bleibt nur für Unit-Test-Convenience (in-memory), nicht als produktiver Dialekt

10.2 Weg damit

RausGrund
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-MechanismusErsetzt durch Event-Level expected_version
pipeline/cascade-handler.tsCascade-Logik wird Event-getrieben, nicht Row-Operation

10.3 Bleibt mit neuer Rolle

HeuteNeue Rolle
pipeline/outbox-table.tsFanout-Queue für Events, nicht mehr generischer Event-Dispatch
pipeline/outbox-poller.tsLiest Events aus PG → publisht Redis-Pub/Sub
pipeline/event-broker.tsRedis-Pub/Sub-Layer
pipeline/idempotency.tsRequest-Level bleibt, Event-Level (expected_version) kommt dazu
pipeline/entity-cache.tsOptional für hot Aggregates — Phase 3+
Validation-HooksLaufen 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", ...) / postSaveSemantisch umbenannt zu preEvent/postEvent/postProjection

10.4 Neu dazu

NeuZweck
db/event-store.tsCore Append/Load/Snapshot
db/projection-runtime.tsApply + Rebuild
db/upcaster.tsEvent-Schema-Evolution
db/snapshot-store.tsSnapshot-Persistenz
engine/reducer-builder.tsAuto-Default-Reducer aus Fields
engine/projection-shape-builder.tsAuto-Default-Projection
r.event(), r.projection(), r.apply(), r.eventMigration()Level-2-4-API
CLI: inspect, project rebuild/list/status, events tailPower-Tools

10.5 Core-Features — Speicher-Zuordnung

Alle Postgres-Entities sind ES. Ephemeres wandert nach Redis (kein Framework-Konstrukt).

FeatureSpeicherBegründung
Domain-Entities (Task, Order, Invoice, Payment, Ticket)ESAudit + Time-Travel by default
deliveryESZustellungs-Historie = Events
notificationESDelivery-Attempt-History wertvoll
userESEmail-/Password-Change-Audit automatisch
tenantESBilling- und Config-Änderungen automatisch auditiert
Session-Tokens, Auth-TokensRedis (nicht Framework-Entity)Ephemer, TTL-basiert, keine Domain-Daten
Rate-Limit-Counter, Presence-HeartbeatsRedisEphemer, high-volume, kein Audit-Wert
channel-*, renderer-*StatelessKeine Entity
Reference-Datar.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-trailevent-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”.