Skip to content

Event Sourcing — Spike 1 Plan

Status: Entwurf Datum: 2026-04-15 Author: Marc Frost Ziel: Technischer + strategischer Proof-of-Concept vor Full-Pivot-Commitment Zeit-Budget: 5 Arbeitstage (Spike-Code + Parallel-Workstreams) Gate: Nach Spike-Abschluss klare Go/No-Go-Entscheidung für Phase 2

Begleitet den großen Plan in event-sourcing-pivot.md.


1. Warum dieser Spike?

Der Full-Pivot-Plan behauptet viele Dinge, die erst durch Code bewiesen werden müssen:

  • Synchrone Projection in selber TX ist performant genug
  • Event-Store-Ergonomie in Postgres/Drizzle ist sauber
  • Die “CRUD-fühlt-sich-wie-CRUD-an”-Illusion funktioniert für Framework-Autoren
  • Cross-Tenant-Isolation im Event-Store ist hieb- und stichfest
  • Projection-Rebuild aus 100k Events ist realistisch schnell

Ohne Beweise = Vermutungen. Mit Beweisen = fundierte Entscheidung.


2. Die drei Gating-Fragen

Der Spike muss diese drei Fragen beantworten. Jede einzelne kann das Projekt stoppen.

Gate A: Performance

Frage: Hält synchrone Projection-in-TX die Latenz-Ziele für einen Multi-Tenant-SaaS?

Messbare Kriterien:

  • Write-Latency (p99): < 30ms für normale Commands (1 Event, kleine Payload)
  • Read-Latency (p99): < 10ms für Projection-Reads
  • Projection-Rebuild: > 10.000 Events/Sekunde
  • Snapshot-Load: < 50ms für Aggregate mit 1000 Events

Wenn nicht erreicht: Default-Async-Projections oder anderer Architektur-Ansatz nötig → kippt die “fühlt sich wie CRUD an”-Zusage → Plan muss überarbeitet werden.

Gate B: Ergonomie für Framework-Autoren

Frage: Können wir aus der Entity-Definition automatisch Events, Reducer und Projections generieren, ohne dass es wehtut?

Messbare Kriterien:

  • Default-Event-Shape-Generator aus Fields funktioniert für alle aktuellen Field-Types (text, number, boolean, date, embedded, reference, enum)
  • Default-Reducer kann create/update/delete ohne Custom-Code abbilden
  • Projection-Materializer schreibt in exakt dieselbe Drizzle-Tabelle wie aktuell
  • Framework-Code für diese Auto-Generierung < 800 LOC

Wenn nicht erreicht: Die “Level-1-User merken nichts”-Zusage bricht zusammen → User müssten doch ES-Konzepte lernen → USP weg.

Gate C: Cross-Cutting-Concerns

Frage: Wie zahlen Tenant-Isolation, Optimistic Concurrency und Idempotency auf Event-Level ein — funktioniert das alles zusammen?

Messbare Kriterien:

  • 100 gleichzeitige Writes auf dasselbe Aggregat → keine Lost Updates, keine Phantom Events
  • Cross-Tenant-Write auf fremdes Aggregat schlägt fehl mit klarer Fehlermeldung
  • Duplicate Request (gleicher Idempotency-Key) erzeugt keine Doppel-Events
  • Projection-Read ist Tenant-sauber (kein Leak)

Wenn nicht erreicht: Grundlegende Framework-Garantien nicht haltbar → Design der Invarianten muss neu.


3. Scope des Spikes

3.1 Im Scope — Code

Eine Entity (task), alles hardcoded, kein Framework-API:

samples/spike-event-sourced/
├── package.json
├── README.md ← Erkenntnisse + Go/No-Go-Fazit
├── src/
│ ├── event-store.ts ← append, load, snapshot (Postgres)
│ ├── projection-runtime.ts ← apply events → Drizzle-Table
│ ├── task-aggregate.ts ← Reducer (handgeschrieben)
│ ├── task-events.ts ← Event-Typen (handgeschrieben)
│ ├── task-projection.ts ← Projector (handgeschrieben)
│ ├── task-commands.ts ← Command-Handler (handgeschrieben)
│ └── feature.ts ← defineFeature — nutzt obige Bausteine
├── benchmarks/
│ ├── write-latency.bench.ts
│ ├── read-latency.bench.ts
│ ├── projection-rebuild.bench.ts
│ └── concurrent-writes.bench.ts
└── src/__tests__/
├── happy-path.integration.ts ← Gate C Basis
├── concurrency.integration.ts ← Gate C Optimistic Locking
├── tenant-isolation.integration.ts← Gate C Tenant-Check
├── idempotency.integration.ts ← Gate C Dedup
├── projection-rebuild.integration.ts ← Gate A Rebuild-Perf
├── snapshot-loading.integration.ts ← Gate A Load-Perf
└── time-travel.integration.ts ← asOf-Query

3.2 Im Scope — Parallel-Workstreams

Damit der Go/No-Go echt informiert ist, brauchen wir parallel zum Spike-Code:

W1 — GDPR-Strategie-ADR (docs/plans/architecture/es-gdpr-strategy.md)

  • Welche Events enthalten PII?
  • Crypto-Shredding vs. Tombstone + Masking vs. Hybrid
  • Entscheidung dokumentiert vor Launch-Zeitpunkt
  • Owner: du, Research + Entscheidung
  • Zeit: ~1 Tag

W2 — Competitor-Landscape (docs/plans/architecture/es-competitor-scan.md)

  • TypeScript ES-Libraries (Eventicle, Castore, EventStoreDB-Client)
  • Cross-Language-Referenzen (Marten C#, Axon Java, Commanded Elixir)
  • Welche sind Frameworks vs. Libraries? Lücke validieren
  • Owner: du + Web-Research
  • Zeit: ~0.5 Tage

W3 — Positioning-Validierung (docs/plans/marketing/es-positioning.md)

  • Ist “first TypeScript ES framework” faktisch haltbar?
  • Ziel-Persona: welcher Dev leidet genug um zu wechseln?
  • 3 hypothetische Kunden-Stories (FinTech-SaaS, Healthcare, B2B-Audit-Pflicht)
  • Owner: du, produkt-denken
  • Zeit: ~0.5 Tage

W4 — Full-Stack-Integration-Check (Code)

  • Spike muss gegen das aktuelle Full-Stack-Harness laufen (setupTestStack, HTTP, Dispatcher)
  • Zeigen dass Write-Path vom HTTP-Request bis Event-in-DB ohne Kollateralschäden funktioniert
  • Zeit: im Spike-Code enthalten

3.3 Explizit NICHT im Scope

  • Framework-API (r.crud generiert alles) — das kommt in Phase 2
  • Custom-Event-DSL (r.event) — Phase 4
  • Upcaster-Runtime — Phase 4
  • CLI inspect/project rebuild — Phase 3+
  • Mehrere Custom-Projections — Phase 3
  • Alle anderen Samples umbauen — Phase 5
  • Schema-Evolution-Tests — Phase 4
  • Observability-Metrics für ES-Operations — Phase 4

Begründung: Der Spike soll Gates A+B+C beantworten, nicht das ganze Framework vorbauen.


4. Konkreter Test-Plan

Jeder Test hat explizites Gate und Kriterium.

Test 1 — Happy Path (Gate C)

Create, Update, Delete eines Tasks via HTTP.

  • Events in DB vorhanden, korrekt versioniert
  • Projection spiegelt finalen State
  • Timeline via Event-Load ist lückenlos

Test 2 — Concurrent Writes (Gate C)

100 parallele Updates desselben Tasks.

  • Maximal 1 Event pro Version-Increment (keine Lücken, keine Duplikate)
  • Conflicts werden mit event_version_conflict geworfen
  • Optimistic-Retry-Pattern funktioniert

Test 3 — Tenant-Isolation (Gate C)

Tenant-A erzeugt Task, Tenant-B versucht Read/Write.

  • Write schlägt fehl mit access_denied
  • Read liefert not_found (nicht access_denied, um Enumeration zu verhindern)
  • Keine Tenant-A-Events im Event-Load von Tenant-B sichtbar

Test 4 — Idempotency (Gate C)

Gleicher Request 5x mit selbem Idempotency-Key.

  • Exakt 1 Event in DB
  • Alle 5 Responses identisch
  • Response-Status korrekt für dupes (bleibt 200, kein 409)

Test 5 — Projection-Rebuild (Gate A)

Seed 100.000 Events, lösche Projection-Table, rebuild.

  • Rebuild < 10 Sekunden (≥ 10k Events/s)
  • Projection nach Rebuild identisch zu Projection vor Drop (hash-Vergleich)
  • Während Rebuild: Reads liefern projection_rebuild_in_progress

Test 6 — Snapshot-Loading (Gate A)

Aggregate mit 1000 Events. Load ohne und mit Snapshot.

  • Ohne Snapshot: Load-Time messen (soll “merkbar” langsam sein)
  • Mit Snapshot (bei 100, 500, 1000): Load-Time < 50ms (p99)
  • Snapshot-Write-Frequency konfigurierbar

Test 7 — Time-Travel (Gate A+B)

Query task.detail mit asOf: <timestamp> für historischen Zustand.

  • Liefert korrekten State zu jedem Zeitpunkt
  • Löschung respektiert (pre-delete-timestamp = existiert, post = not_found)
  • Latenz messbar schlechter als Projection-Read, aber < 200ms für 1000-Event-Aggregat

Test 8 — Full-Stack (Gate A+B+C)

Echter HTTP-Client → /api/write {task.create} → Event in echte Postgres → /api/query {task.list} → Ergebnis enthält Task.

  • Kein Mock
  • Kein Override
  • Default-System-Hooks (falls wir welche behalten) feuern korrekt

5. Benchmark-Setup

Reproduzierbare Benchmarks, in samples/spike-event-sourced/benchmarks/.

write-latency.bench.ts
// 10.000 sequenzielle Commands, Percentile-Messung
// Erwartung: p50 < 10ms, p99 < 30ms
// read-latency.bench.ts
// 100.000 Projection-Reads
// Erwartung: p50 < 2ms, p99 < 10ms
// projection-rebuild.bench.ts
// Seed 100k Events, Rebuild from scratch
// Erwartung: > 10k Events/s
// concurrent-writes.bench.ts
// 50 parallele Writer auf gleichem Aggregat
// Erwartung: Alle serialisiert, keine Phantom-Events
// Retry-Overhead messen (wie viele Retries durchschnittlich bis Success)

Wichtig: Benchmarks laufen gegen realistischen Docker-Postgres (der bestehende yarn kumiko dev Stack), nicht gegen in-memory.


6. Tages-Gates (Early-Exit-Struktur)

Damit wir nicht 5 Tage in eine Sackgasse rennen:

Tag 1 — Primitiven

Ziel: event-store.ts + projection-runtime.ts laufen. Einzelnes Event appenden + reduzieren. Gate-Ende-Tag-1: Kann ich Event schreiben + lesen + State berechnen?

  • Ja → weiter
  • Nein → Stop. Back to drawing board. Event-Store-Design überdenken.

Tag 2 — Happy Path + Concurrency

Ziel: Tests 1 + 2 grün. Gate-Ende-Tag-2: Sind Concurrency-Garantien hart?

  • Ja → weiter
  • Nein → Stop. Fundamental — ohne das ist ES wertlos.

Tag 3 — Tenant + Idempotency + Time-Travel

Ziel: Tests 3 + 4 + 7 grün. Gate-Ende-Tag-3: Cross-Cutting-Concerns sauber integriert?

  • Ja → weiter
  • Nein → Stop oder Design-Session einlegen

Tag 4 — Performance & Rebuild

Ziel: Tests 5 + 6 grün, Benchmarks gelaufen. Gate-Ende-Tag-4: Hält Gate A (Perf)?

  • Ja → weiter
  • Nein → Stop. Fundamental — kippt die UX-Zusage.

Tag 5 — Full-Stack + Dokumentation

Ziel: Test 8 grün, README.md im Spike mit Erkenntnissen gefüllt, Parallel-Workstreams (W1-W3) fertig. Gate-Ende-Tag-5: Finales Go/No-Go mit allen Daten auf dem Tisch.


7. Go- und No-Go-Kriterien

Unambiguous GO

Alle drei Gates (A, B, C) grün.

  • Performance-Ziele gehalten
  • Auto-Generation-Code überschaubar (< 800 LOC fürs Minimum)
  • Invarianten (Tenant, Concurrency, Idempotency) beweisbar sauber
  • Keine überraschenden Failure Modes

Unambiguous NO-GO

Eines der drei Gates klar verfehlt:

  • Performance > 2x schlechter als heute → Level-1-UX tot
  • Auto-Generation wird > 2000 LOC → Framework-Code nicht wartbar
  • Concurrency/Tenant-Bugs nicht eliminierbar → Kern-Invarianten kaputt

Gray Zone (Rückfrage nötig)

  • Performance grenzwertig aber nicht disqualifizierend
  • Ergonomie funktioniert, aber Framework-Code gefühlt zu komplex
  • GDPR-Strategie (W1) stellt sich als Blocker heraus
  • Competitor-Scan (W2) zeigt: Nische ist bereits belegt

Bei Gray Zone: gemeinsam entscheiden, nicht im Alleingang.


8. Arbeits-Philosophie während des Spikes

Hart:

  • Jeder Test ist Full-Stack-Integration (HTTP → DB), kein Mock
  • Jede Performance-Aussage hat eine Benchmark (keine “fühlt sich schnell an”-Behauptungen)
  • Keine “ich fix das später”-Kommentare — entweder es ist grün oder es ist nicht fertig
  • Code ist hardcoded, aber sauber strukturiert (Phase 2 soll daraus extrahieren, nicht umschreiben)

Weich:

  • TypeScript-strict, aber Type-Ergonomie steht im Level-1-Test nicht im Fokus
  • Fehlermeldungen können rough sein — Polish kommt in Phase 2
  • Keine Docs außer dem README im Spike-Sample
  • Keine UI, keine Admin-Tools — Backend-only

9. Artefakte am Spike-Ende

Auf dem Tisch müssen liegen:

  1. samples/spike-event-sourced/ — vollständiger Code, alle 8 Tests grün, Benchmarks reproducible
  2. samples/spike-event-sourced/README.md — Erkenntnisse, Überraschungen, Messwerte, Empfehlung
  3. docs/plans/architecture/es-gdpr-strategy.md — GDPR-ADR mit Entscheidung
  4. docs/plans/architecture/es-competitor-scan.md — Competitor-Landscape + Lücken-Validierung
  5. docs/plans/marketing/es-positioning.md — Positioning-Check + 3 Persona-Stories
  6. Abschluss-Commit mit Message spike: event-sourcing proof — N tests, N benchmarks, X events/s, decision: GO/NO-GO

10. Nach dem Spike

Wenn GO

  • event-sourcing-pivot.md als aktiven Plan bestätigen
  • Phase 2 starten: event-store-executor in Framework, r.crud generiert ES-Runtime
  • CLAUDE.md ergänzen um ES-Patterns (für künftige Sessions)
  • samples/spike-event-sourced/ bleibt als Referenz, wird aber nicht zum normalen Sample — es ist unser Reifezeugnis

Wenn NO-GO

  • Entscheidung dokumentieren in docs/plans/architecture/es-pivot-decision.md mit Grund
  • Optionen erwägen:
    • Insert-only + Commands: Light-Weight-Audit-Variante, Kumiko bleibt “besseres CRUD”
    • Outbox bleibt: Status Quo, Fokus auf andere Differenzierung (Realtime? Compliance-Tooling?)
    • ES im nächsten Anlauf: nicht jetzt, vielleicht nach v1.0-CRUD wenn Performance-/Budget-Bedingungen anders sind
  • Kein Beinbruch. Der Spike hat exakt den Zweck erfüllt: verhindern dass wir 8 Wochen in eine Falle rennen.

11. Risiken während des Spikes

RisikoMitigation
Benchmarks zu laut/unreliableMehrfache Runs, Warm-Up-Phase, lokale DB ohne Load
”Ich fix schnell noch X” — Scope-CreepTages-Gates diszipliniert fahren. Was nicht im Plan ist, kommt in ein “Phase 2”-TODO
Perfomance-Optimierungen scheitern an Drizzle-LimitsAlternatives Storage-API prüfen (Postgres-Client direkt?). Würde als Finding in den Spike-Bericht einfließen
GDPR-Research (W1) wird größer als erwartetScope limitieren auf: Strategie-Empfehlung, nicht Implementation
Competitor-Scan offenbart existierendes TS-ES-Framework das wir übersehen habenDas ist ein valides No-Go-Signal — dokumentieren, Positioning überdenken

12. Entscheidung jetzt

Was du jetzt bestätigen musst:

  1. Spike-Budget: 5 Arbeitstage konzentriert akzeptiert
  2. Scope-Grenzen: Nur 1 Entity hardcoded, kein Framework-API akzeptiert
  3. Parallele Workstreams (W1-W3) akzeptiert
  4. Go-No-Go nach Tag 5 respektiert — kein “eigentlich ist’s doch ok” wenn Gates fehlschlagen
  5. Bei NO-GO: Insert-only als Plan B akzeptiert

Startpunkt: Wenn alles bestätigt ist, beginne ich mit Tag 1 — event-store.ts + projection-runtime.ts als Minimum-Primitiven gegen lokale Postgres-DB.