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/deleteohne 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-Query3.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.crudgeneriert 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_conflictgeworfen - 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(nichtaccess_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/.
// 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:
samples/spike-event-sourced/— vollständiger Code, alle 8 Tests grün, Benchmarks reproduciblesamples/spike-event-sourced/README.md— Erkenntnisse, Überraschungen, Messwerte, Empfehlungdocs/plans/architecture/es-gdpr-strategy.md— GDPR-ADR mit Entscheidungdocs/plans/architecture/es-competitor-scan.md— Competitor-Landscape + Lücken-Validierungdocs/plans/marketing/es-positioning.md— Positioning-Check + 3 Persona-Stories- 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.mdals aktiven Plan bestätigen- Phase 2 starten:
event-store-executorin Framework,r.crudgeneriert 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.mdmit 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
| Risiko | Mitigation |
|---|---|
| Benchmarks zu laut/unreliable | Mehrfache Runs, Warm-Up-Phase, lokale DB ohne Load |
| ”Ich fix schnell noch X” — Scope-Creep | Tages-Gates diszipliniert fahren. Was nicht im Plan ist, kommt in ein “Phase 2”-TODO |
| Perfomance-Optimierungen scheitern an Drizzle-Limits | Alternatives Storage-API prüfen (Postgres-Client direkt?). Würde als Finding in den Spike-Bericht einfließen |
| GDPR-Research (W1) wird größer als erwartet | Scope limitieren auf: Strategie-Empfehlung, nicht Implementation |
| Competitor-Scan offenbart existierendes TS-ES-Framework das wir übersehen haben | Das ist ein valides No-Go-Signal — dokumentieren, Positioning überdenken |
12. Entscheidung jetzt
Was du jetzt bestätigen musst:
- Spike-Budget: 5 Arbeitstage konzentriert akzeptiert
- Scope-Grenzen: Nur 1 Entity hardcoded, kein Framework-API akzeptiert
- Parallele Workstreams (W1-W3) akzeptiert
- Go-No-Go nach Tag 5 respektiert — kein “eigentlich ist’s doch ok” wenn Gates fehlschlagen
- 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.