Event Sourcing Pivot für Kumiko
Status: Entwurf Datum: 2026-04-15 Author: Marc Frost Scope: Framework-Architektur-Shift von “CRUD + Outbox” zu “Event Sourcing mit CRUD-Ergonomie”
1. Kontext & Entscheidung
1.1 Status Quo (heute, Commit c24faf1)
Kumiko ist aktuell ein CRUD-Framework mit Transactional Outbox Pattern:
crud-executor.tsschreibt Rows direkt in Entity-Tabellen- Outbox-Table bekommt Events in derselben TX
outbox-poller.tspubliziert Events viaevent-broker(Redis Pub/Sub)event-log.tsappendet in einen Redis-Stream (append + recent — kein Replay)- DB-Tabellen sind Source of Truth, Events sind Nebenprodukt
1.2 Entscheidung
Pivot zu Event Sourcing:
- Events werden Source of Truth (Postgres-Tabelle
events) - Entity-Tabellen werden zu Read-Models (Projections) — immutable auf Event-Basis, rebuildable
crud-executor.tswird zuevent-store-executor.ts— schreibt Events, keine Rows- Default synchronous Projection (selbe TX) für konsistentes Read-after-Write
- Progressive Disclosure: User schreibt CRUD-Code, Framework generiert ES-Runtime
1.3 Warum jetzt?
- Pre-Publish, v0.0.1, keine User-Daten
- Kein externes Commitment — API-Breakage noch billig
- USP für OSS-Publish: ES-Framework in TypeScript = weitgehend leere Nische
- Fundamentale Architekturentscheidung — später nicht mehr reversibel
1.4 Nicht-Ziele
- Kein CQRS-Overkill mit separatem Read-/Write-Model-Prozess
- Kein Distributed Event Store (EventStoreDB, Kafka als Primary). Postgres reicht für 99% der Fälle
- Kein Zwang zu Event-First-Denken: Level-1-User merken nicht, dass sie ES nutzen
- Keine Opt-In-Hybrid-Architektur (weder CRUD noch ES pro Entity wählbar — das wird zwei Systeme mit halber Qualität)
2. Zielbild — User-Seite
2.1 Progressive Disclosure in vier Leveln
Level 1 — 95% der Entities: Auto-ES hinter CRUD-API
r.entity("task", { fields: { title: textField(), done: boolField() } });r.crud("task");Framework generiert automatisch:
- Event-Typen:
task.created,task.updated,task.deleted - Default-Reducer: current-state aus Events
- Default-Projection:
tasks-Tabelle mit allen Feldern (wie bisher) - Handler:
task.create/update/delete→ schreiben Events in selber TX wie Projection-Update - Query-Handler:
task.list/detail→ lesen aus Projection (direkt, wie bisher) - Tenant-Scoping in jedem Event Pflicht, vom Framework injiziert
- Snapshots ab Aggregate-Größe N (default 100 Events)
User-Erfahrung: identisch zu bisher. Er merkt ES nicht.
Level 2 — Domain-Events
r.event("task", "completed", z.object({ completedAt: z.date(), by: z.string() }));r.command("task.complete", { access: { roles: ["User"] }, emits: "completed", validate: ({ task }) => task.done === false || err("already_done"),});Semantisch reiche Events statt UpdateTask({done:true}). Für Reports und Business-Logik unverzichtbar.
Level 3 — Custom Projections
r.projection("tasks_by_month", { source: "task", schema: { month: textField(), count: numberField() }, apply: { "task.created": (state, event) => bumpMonth(state, event.createdAt), "task.deleted": (state, event) => decMonth(state, event.createdAt), },});Zweites Read-Model für Reports. CLI yarn kumiko project rebuild tasks_by_month baut es aus Events neu.
Level 4 — Schema-Evolution (Upcaster)
r.eventMigration("task.created", { from: 1, to: 2, transform: { rename: { "name": "title" }, default: { "priority": "normal" }, },});Deklarativ, nicht imperativ. Framework generiert den Upcaster aus der Declaration.
2.2 Framework-verwaltete Magie
Für Level-1-User macht das Framework unsichtbar:
- Event-Version-Conflict-Detection (optimistic locking via
expected_version) - Projection in same-TX als Write (kein Eventual-Consistency-Surprise)
- Snapshot-Writing bei Schwellwert
- Tenant-Isolation in Event-Store + Projections
- Idempotency (request-id-basiert, Event-level deduping)
2.3 Power-Tools (CLI)
yarn kumiko inspect task <id> # Event-Timeline eines Aggregatsyarn kumiko project list # Alle registrierten Projectionsyarn kumiko project rebuild <name> # Projection aus Events neu aufbauenyarn kumiko project status # Lag + Health aller Projectionsyarn kumiko events tail # Live-Event-Streamyarn kumiko events asOf <timestamp> # Zustand zu einem Zeitpunkt3. Kernkonzepte (Framework-intern)
3.1 Event-Store
- Table:
events (id, aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_at, created_by) - Append-Only. Keine Updates, keine Deletes (außer via TTL-Archivierung, separates Thema)
- Version-Check via Unique-Constraint
(aggregate_id, version)— atomic optimistic concurrency - Postgres als Primary. Redis-Streams nur noch für Real-Time-Fanout (SSE, Pub/Sub) — nicht mehr als Log
3.2 Aggregates
- Ein Aggregate = ein Aggregate-ID + Event-Stream
- Framework lädt Events beim Command-Handling, reduziert zu State, validiert Command gegen State, appendet neue Events
- Reducer-Funktion ist auto-generiert aus Entity-Fields + Event-Types — User muss nichts schreiben
- Snapshot-Pattern: ab N Events wird Snapshot gespeichert, Load startet vom letzten Snapshot + delta-Events
3.3 Projections
- Normale Drizzle-Tabelle (kein Black-Box-Store)
- Gefüttert aus Event-Stream via Projector-Functions
- Synchron by default: Framework updated Projection in derselben TX wie Event-Append (=Read-after-Write garantiert)
- Async opt-in:
{ mode: "async" }für Analytics-Projections mit hohem Volumen - Wegwerfbar. DB drop → Projection rebuild aus Events. Events sind die Wahrheit.
3.4 Upcaster
- Events haben
versionim Schema - Beim Laden: Framework prüft Version, wendet registrierte Transforms an, liefert normalisierte Event-Shape an Reducer
- Deklarative Transforms:
rename,default,map(fn),drop - Imperative Fallback (Funktion) für Edge-Cases
3.5 Snapshots
- Ab Konfig-Schwellwert (
snapshotEvery: 100) persistiert Framework Snapshot des reduzierten States - Load-Path: letzter Snapshot + alle Events seither
- Snapshots sind Cache, nicht Wahrheit — können neu erstellt werden
- Invalidierung bei Schema-Change (neue Event-Version) optional
4. Was wird umgebaut — Datei für Datei
4.1 Kern-Storage (komplett neu)
| Alt | Neu | Änderung |
|---|---|---|
db/crud-executor.ts | db/event-store-executor.ts | Writes werden Events + Projection-Update in selber TX |
pipeline/outbox-table.ts | bleibt | Wird zu “pending-events-for-fanout” (SSE, Search, Notifications) |
pipeline/outbox-poller.ts | bleibt | Liest aus events-table mit last_processed_id-Cursor |
pipeline/event-log.ts | entfernt | Ersetzt durch events-Table in Postgres |
| (neu) | db/event-store.ts | Low-level Append/Load/Snapshot-API |
| (neu) | db/projection-runtime.ts | Projection-Apply-Runtime |
| (neu) | db/upcaster.ts | Upcaster-Registry + Transform-Runtime |
| (neu) | db/snapshot-store.ts | Snapshot-Persistierung |
4.2 Engine
| Alt | Neu | Änderung |
|---|---|---|
engine/registry.ts | erweitert | Event-Schema-Registry, Projection-Registry, Upcaster-Registry |
engine/define-feature.ts | erweitert | Neue Registrar-Methoden: r.event, r.projection, r.eventMigration |
engine/types/*.ts | erweitert | Event-Def, Projection-Def, Aggregate-Def Types |
| (neu) | engine/event-shape-builder.ts | Auto-generiert Default-Event-Shapes aus Entity-Fields |
| (neu) | engine/projection-shape-builder.ts | Auto-generiert Default-Projection aus Entity-Fields |
| (neu) | engine/reducer-builder.ts | Auto-generiert Default-Reducer aus Field-Types |
4.3 Pipeline / Dispatcher
| Alt | Neu | Änderung |
|---|---|---|
pipeline/dispatcher.ts | erweitert | Write-Path: Command → Validate → Load-Aggregate → Validate-Against-State → Append-Events → Update-Projection (in TX) |
pipeline/lifecycle-pipeline.ts | erweitert | Hooks werden auf Event-Level umgeformt: preSave → preEvent, postSave → postEvent + postProjection |
pipeline/idempotency.ts | erweitert | Request-Level + Event-Level (via expected_version) |
4.4 Commands
| Alt | Neu | Änderung |
|---|---|---|
engine/types/handlers.ts | WriteHandler emit-basiert | handler erhält State, liefert Events zurück (statt DB-Writes) |
| (neu) | engine/command-handler.ts | Auto-Command-Handler für Create/Update/Delete aus Entity-Def |
4.5 Queries
| Alt | Neu | Änderung |
|---|---|---|
engine/types/handlers.ts | QueryHandler liest Projection | Default-Queries (list, detail) lesen aus Projection — identisch zu bisheriger CRUD-Logik |
| (neu) | engine/query-asof.ts | asOf-Parameter: rekonstruiert Projection-State zum Timestamp via Event-Replay |
4.6 System-Hooks
| Alt | Neu | Änderung |
|---|---|---|
pipeline/system-hooks.ts — Search | reagiert auf postEvent + postProjection statt postSave | Indexing-Logik bleibt, Trigger wechselt |
pipeline/system-hooks.ts — SSE | sendet Event-Delta statt Row-Snapshot | Clients kriegen Events, reduzieren clientseitig (oder kriegen Projection-Updates, je nach Channel-Typ) |
pipeline/system-hooks.ts — Audit | entfällt | Events sind der Audit — keine separate Audit-Tabelle nötig |
4.7 Core-Features
Alle Postgres-Entities sind ES. Ephemeres wandert nach Redis.
| Entity | Speicher | Grund |
|---|---|---|
user | ES | Email-/Password-Change-Audit automatisch |
tenant | ES | Billing-/Config-Änderungen automatisch auditiert |
delivery | ES | Status-Übergänge sind Events |
notification | ES | Zustellungs-Historie = Events |
session, Auth-Tokens | Redis (nicht Framework-Entity) | Ephemer, TTL-basiert |
Framework bietet kein CRUD-ohne-Events. Ephemeres lebt in Redis, außerhalb des Framework-Entity-Modells. Kein r.infraTable / r.ephemeralTable auf Vorrat — erst wenn echte Lücke beweisbar wird.
4.8 API
| Alt | Neu | Änderung |
|---|---|---|
api/server.ts | bleibt | HTTP-Shape unverändert |
api/observability-middleware.ts | bleibt | Span-Naming: cmd.task.create statt write.task.create (kosmetisch) |
| (neu) | api/inspect-route.ts | GET /api/inspect/:type/:id — Event-Timeline (Admin-Only) |
| (neu) | api/projections-route.ts | GET /api/projections/status — Projection-Health (Admin-Only) |
4.9 CLI
| Neu | Zweck |
|---|---|
yarn kumiko inspect <type> <id> | Event-Timeline eines Aggregats |
yarn kumiko project list | Alle Projections + Status |
yarn kumiko project rebuild <name> | Projection aus Events neu bauen |
yarn kumiko project status | Lag-Monitoring |
yarn kumiko events tail | Live-Stream aller Events |
yarn kumiko snapshot <type> <id> | Manueller Snapshot |
5. Verwandte Systeme (parallel zu planen)
5.1 Access Control
- Entity-Level: unverändert — auf Command-Handler-Ebene (wer darf
task.createaufrufen) - Field-Level Write-Access: beim Event-Authoring geprüft (welche Felder darf der User ändern)
- Field-Level Read-Access: beim Projection-Read gefiltert (welche Felder darf der User sehen)
- Neuer Aspekt: Historische Events bleiben sichtbar auch wenn Field-Access später restriktiver wird — Ausnahme beim
inspect-Tool (Admin-Override)
5.2 Search
- Meilisearch-Indexer reagiert auf
postEvent-Hook (nicht mehrpostSave) - Indexing kann aus Projection oder Events erfolgen — meistens Projection, da bereits materialisiert
- Rebuild-Pfad: bei Event-Schema-Change → Index drop + Projection-Rebuild → Reindex
5.3 SSE
- Channel-Typen:
- Event-Channel: Client abonniert Event-Stream (z.B.
task.*für Live-Debug) - Projection-Channel: Client abonniert Projection-Updates (default für UI)
- Event-Channel: Client abonniert Event-Stream (z.B.
- Delta-Updates statt kompletter Snapshots → Bandbreiten-Ersparnis
- Resume-Capability: Client schickt
last-event-id, Server replayed verpasste Events
5.4 Audit
- Entfällt als separates System — Events sind der Audit
inspect-Route liefert das, was bisher das Audit-Trail-System tat- Core-Feature
audit-trailwird umgebaut / abgeschafft - Sample
samples/audit-trail/wird zum Demonstrator für “default audit via events”
5.5 Jobs
- Job-Trigger:
on: "task.completed"(Event-basiert, war schon angepeilt) - Job-Runner abonniert Event-Stream via Outbox-Poller
- At-least-once-Delivery mit Idempotency-Keys
5.6 Delivery / Notifications
- Notifications triggern auf Events (passt natürlich)
- Zustellungs-Status ist eigenes Aggregat:
delivery.attempted,delivery.succeeded,delivery.failed,delivery.retried - Retry-Logic baut auf Event-History auf
5.7 Idempotency
- Request-Level: bisher (Redis-basiert mit TTL) — bleibt
- Event-Level: neu —
expected_versionim Command verhindert Double-Writes auch bei gleichzeitigen Requests mit unterschiedlichen Request-IDs - Beide zusammen = bulletproof
5.8 Error Contract
Neue Reason-Codes:
event_version_conflict(optimistic concurrency — jetzt auf Event-Ebene)projection_rebuild_in_progress(Read ging gegen Projection die gerade rebuildet wird)event_schema_unknown_version(Upcaster fehlt für alte Event-Version)aggregate_not_found(ersetztentity_not_foundkonzeptuell, aber beide behalten)
5.9 Observability
Neue Metrics:
kumiko_events_appended_total{tenant, aggregate_type, event_type}kumiko_events_appended_duration_secondskumiko_projection_lag_seconds{projection_name}kumiko_projection_rebuild_duration_seconds{projection_name}kumiko_snapshot_write_total{aggregate_type}kumiko_aggregate_load_events{aggregate_type}(Histogram — wie viele Events pro Load)
Traces:
- Span
cmd.<command>umfasst: validate → loadAggregate → validateAgainstState → appendEvents → updateProjection - Child-Span
event.appendpro Event
5.10 Testing
TestStacklädt weiterhin kompletten Registry + DB + Redis- Event-Builder:
mockStack.appendEvent("task.created", {...})— direkt in Event-Store einfügen für Test-Setup - Projection-Assertions:
expectProjection("tasks").toContain(...) - Full-Stack-Integration-Test bleibt Leitstern — echte Commands via HTTP, Events in echte DB, Projections echt materialisiert
- Fake-Tests-Guard wird erweitert: keine
mockEventStore, echter Store muss laufen
5.11 Samples
Alle Samples werden neu durchdacht, beginnend mit dem Level-1-Bild:
| Sample | Level | Zweck |
|---|---|---|
basic-crud | Level 1 | Zeigt: User schreibt CRUD-Code, kriegt ES-Vorteile (kann inspect aufrufen und Timeline sehen) |
custom-handlers | Level 2 | Zeigt: Domain-Events (task.completed etc.) |
audit-trail (renamed → event-timeline) | Level 1+inspect | Zeigt: Audit ist Default, keine extra Config |
time-travel (neu) | Level 1+asOf | Zeigt: Zustand zu beliebigem Timestamp |
projections (neu, renamed von search?) | Level 3 | Zeigt: Custom Read-Model für Report |
schema-evolution (neu) | Level 4 | Zeigt: Upcaster in Action |
state-machine | Level 2 | Zeigt: Events als Zustandsübergänge |
relations | Level 1/2 | Zeigt: Cross-Aggregate-Events |
| Übrige Samples | divers | Anpassen je nach Pattern |
5.12 Docs
- Landing-Page: Benefits zuerst (audit, time-travel, never-lose-data), ES-Wort in 2nd-Level-Doku
- Quickstart: Level 1 komplett ohne ES-Jargon — “define entity, get CRUD, boom it’s ES under the hood”
- Concepts-Docs: Level 2-4 mit expliziter ES-Terminologie für Power-User
- Migration-Guide von CRUD-Frameworks: gezielt Rails/Django/Nest-Entwickler abholen
- Anti-Pattern-Docs: “Wann ist ES nicht das Richtige” (Honesty-Move)
5.13 Migration-Story
- Drizzle-Migrations: nur für Events-Table + Projection-Tables
- Projections sind rebuildable → Schema-Changes an Projections = DB-drop + rebuild (aus Events)
- Events-Table selbst bleibt stabil; Event-Schema-Evolution über Upcaster, nicht ALTER TABLE
6. Implementierungs-Reihenfolge
Phase 1 — Spike (1 Woche)
Ziel: Eine einzige Entity (task) komplett in ES. Proof-of-Concept für Framework-Autoren, keine User-API.
db/event-store.ts— Append/Load-Primitives mit Postgres- Hardcoded Reducer + Projection für
task - Full-Stack-Test: HTTP
POST /api/write {task.create}→ Event in DB → Projection aktualisiert →GETliest aus Projection →inspectzeigt Timeline - Kein Framework-API, kein
r.crud, alles manuell
Gate: Wenn die Ergonomie sich bescheiden anfühlt → zurück zum Zeichenbrett. Sonst: Phase 2.
Phase 2 — Core ES im Framework (2-3 Wochen)
Ziel: r.entity() + r.crud() generieren Auto-ES.
- Event-Shape-Builder + Projection-Shape-Builder + Reducer-Builder
event-store-executor.tsersetztcrud-executor.ts- Dispatcher nutzt neuen Executor
- Ein Sample (
basic-crud) funktioniert end-to-end - Alle bisherigen Integration-Tests grün
Gate: Full-Stack-Integration-Test läuft. Andere Samples können noch rot sein.
Phase 3 — Projections-Engine (1-2 Wochen)
Ziel: Custom Projections, Rebuild-CLI, Projection-Status-API
r.projection()-Registrar- CLI
yarn kumiko project rebuild/list/status - Projection-Runtime mit Lag-Tracking
- Observability-Metrics
- Sample
projectionszeigt zweites Read-Model
Phase 4 — Power-Features (2 Wochen)
Ziel: Upcaster, Snapshots, Time-Travel, Domain-Events
r.event()für Custom Domain-Eventsr.eventMigration()+ Upcaster-Runtime- Snapshot-Store + Auto-Snapshot-Policy
asOf-Query-Parameter- CLI
yarn kumiko inspect/events - Samples
custom-handlers,schema-evolution,time-travel
Phase 5 — Polish & Docs (1 Woche)
- Alle Samples auf ES umgestellt
- Migration-Guide für CRUD-Entwickler
- Landing-Page überarbeitet (Benefits first)
- Anti-Pattern-Docs
- Docs-Level 1-4 Progressive Disclosure
- Marketing-Copy fürs OSS-Publish
Gesamtlaufzeit: ~7-9 Wochen konzentrierte Arbeit.
7. Risiken & Mitigations
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|---|---|---|---|
| Synchrone Projection-Writes werden zu langsam bei großen Aggregaten | Mittel | Hoch | Snapshot-Pattern von Anfang an. Async-Projections als opt-in-Escape-Hatch |
| Event-Schema-Evolution wird Boilerplate-Hölle | Hoch | Hoch | Deklarative Upcaster mit Tests. Convention “additive-only” solange möglich |
| Developer verstehen das Modell nicht, verlassen das Framework | Mittel | Hoch | Level-1-API ist identisch zu CRUD. inspect macht Internals sichtbar, nicht verpflichtend |
| Performance bei sehr vielen Events pro Aggregat | Niedrig-Mittel | Mittel | Snapshots. Aggregate-Design-Guidelines (max ~1000 Events pro Aggregat als Richtwert) |
| Full-Stack-Integration-Tests werden flaky durch Projection-Lag | Mittel | Mittel | Default synchrone Projections eliminiert das. Async-Projections haben explizite Await-Helpers im TestStack |
| Access-Control-Matrix wird komplex (event-level + projection-level) | Mittel | Mittel | Guards erzwingen Konsistenz. Doc-First-Approach mit Beispielen |
| Marketing-Positionierung zieht ES-Experten, die dann Level-1-API “zu einfach” finden | Niedrig | Niedrig | Level 2-4 ist da. Verschiedene Einstiege für verschiedene Zielgruppen |
8. Offene Design-Fragen
- Aggregate-Boundaries: Ist “ein Task” ein Aggregat, oder ist “ein Projekt mit all seinen Tasks” ein Aggregat? DDD-Frage. Guideline nötig. Default: ein Aggregat = eine Entity-Instanz.
- Cross-Aggregate-Transactions: Wenn Command 2 Aggregates touched (z.B.
order.placeändertcart+inventory) — Saga-Pattern oder atomare Multi-Aggregate-Transaction? Vorschlag: atomic für den MVP, Saga als Pattern-Sample später. - Event-Bubbling / Inter-Aggregate-Events: Wenn
order.placedevent →inventory.reservedevent triggern soll, wie modelliert? Sync in selber TX oder async via Job? Beide anbieten? - Snapshot-Invalidierung bei Reducer-Code-Change: Automatisch erkennen (Hash des Reducers)? Manual bump? Pragmatisch: Manual bump über
snapshotVersionin Entity-Def. - Multi-Tenant-Events-Table: Alle Tenants in einer Tabelle mit
tenant_id-Spalte + Partitionierung, oder pro Tenant eine Table? Default: eine Tabelle + Partitioning ab Skalierungsbedarf. - GDPR / Right to be Forgotten: Events sind immutable. Lösung: Tombstone-Events + Krypto-Shredding (Schlüssel pro Tenant, löschen = Entschlüsselung verlieren). Separate ADR.
- Event-Archivierung: Ab wann darf ein Aggregate archiviert werden (Events in Cold-Storage)? Nach Entity-”Schließung” (
task.archived) + X Monate? Policy-driven. Später. - High-Volume-Nebendaten (Login-Historie, Page-Views): ES oder Redis-TTL-Counter oder externer Analytics-Store? Vorschlag: Redis-TTL für operationales Zeug, externer Analytics-Store bei Bedarf. Kein Grund ins ES zu drücken.
9. Was bleibt unverändert
- Hono-Server + HTTP-Surface. Client-API (Write/Query/Command) gleich
- JWT-Auth, Multi-Tenant-Middleware
- Zod-Validation auf Command-Schemas
- CQRS-Split via Dispatcher (Write/Query/Command)
- Tenant-Isolation-Layer
- i18n-Engine
- Observability-Framework (Metrics/Traces/Logs — nur neue Metrics dazu)
- Feature-Definition-DSL (
defineFeature,r.entity,r.crud,r.hooketc. — erweitert, nicht ersetzt) - Registry mit Pre-Computation (Hooks, Relations, Access)
- Guard-Scripts + Check-Pipeline
- Error-Contract-Basis (nur neue Reasons ergänzen)
- CLI-Grundgerüst (nur neue Commands dazu)
- Samples-Konzept (nur inhaltlich erneuert)
10. Nicht im Scope (dieser Entscheidung)
- Distributed Event-Store (Kafka, EventStoreDB) — Postgres reicht, Migration später möglich
- CQRS mit separatem Read-/Write-Prozess — overkill für v1
- Projektionen auf externen Stores (ClickHouse, ElasticSearch) — als Sample später demonstrierbar
- GraphQL-Layer über Projections — out of scope
- Admin-UI — kein Framework-Feature, aber CLI + inspect-Route geben Bausteine
- Retry-Policies für asynchrone Projections — Phase 4+
11. Entscheidungs-Checkliste vor Start
- Full-Pivot bestätigt (nicht Opt-In)
- Infrastruktur-Entities definiert (user, tenant, session — sind NICHT ES)
- Phase-1-Spike als Gate akzeptiert (wenn’s hakt, zurück ans Reißbrett)
- Zeitbudget von ~7-9 Wochen realistisch
- Risiko “Schema-Evolution wird Boilerplate” akzeptiert — deklarative Upcaster sind der Bet
- Bereitschaft, alle Samples neu zu denken
- Docs-Redesign (Benefits-First) akzeptiert
12. UUID-only aggregate IDs
Status: Hart. Der event-store-executor wirft beim Boot, wenn eine Entity ein anderes idType deklariert als uuid.
Warum
Die events-Tabelle speichert aggregate_id als Postgres-uuid-Typ. Das hat drei Gründe:
- Globale, kollisionsfreie IDs ohne Koordination. Ein Aggregate entsteht in der App ohne DB-Roundtrip. Serial-PKs verlangen die Datenbank als Arbiter — das kollidiert mit Offline-Writes und vorgenerierten IDs in Commands.
- Cross-Aggregate-Joins sind scharf getypt. Multi-Stream-Projektionen gruppieren Events über
UUID-Keys (User-ID, Tenant-ID).integer/serialwürde pro Entity-Typ unterschiedliche Zählerräume schaffen — die Projektion müsste nach(aggregateType, id)komposit-keyen. - Tenant-Schutz beim Append. Der
INSERT … SELECT … WHERE EXISTS-Guard prüft dieaggregate_id+tenant_idgemeinsam. Gemischte ID-Typen pro Tenant würden den Check unbrauchbar machen.
Auswirkung auf Features
- Alle Entities, die durch
r.crud()odercreateEventStoreExecutor(...)gehen, müssenidType: "uuid"deklarieren. Das Framework generiert beimcreateautomatisch eine UUID (v7 wenn verfügbar, sonst v4). Feature-Autor:innen müssen nichts selbst generieren. - Integer-/Serial-PKs sind nicht migrierbar. Ein späterer Shift erfordert ein vollständiges Replay mit Mapping-Tabelle. Das ist bewusst keine Roadmap-Item — wir machen UUID zum Gold-Standard.
- Ausnahmen für Non-ES-Tabellen: Projection-Tabellen, Ops-Tabellen (
kumiko_projections,kumiko_event_consumers) und reine Read-Models dürfen beliebige Keys haben — sie gehen nicht durch den event-store-executor.
Ergonomie-Kompromiss
UUIDs in URLs sind 36 Zeichen gegenüber 8-stelligen Serials. Das ist der einzige spürbare Nachteil und wir akzeptieren ihn: kürzere Zahlen-IDs werden über einen separaten slug-Field oder eine Short-ID-Projektion gelöst, nicht über den PK.
13. Sprint E — Marten Gold Standard (2026-04-17)
Status: Implementiert. Commits d5bf319 … 28bdecb auf main.
13.1 Entscheidung
Nach Architektur-Audit durch feature-dev:code-architect wurde der ES-Stack in Richtung Marten’s Gold-Standard gezogen. Marten (jasperfx) ist die ausgereifteste ES-Bibliothek für Postgres; ihre API-Entscheidungen wurden 1:1 auf Kumiko-Idiome gemappt statt eigene Patterns zu erfinden.
13.2 Neue User-APIs
| API | Zweck | Marten-Äquivalent |
|---|---|---|
ctx.appendEvent({ aggregateId, aggregateType, type, payload }) | Domain-Event auf Aggregate-Stream schreiben | session.Events.Append(id, event) |
ctx.loadAggregate(id, { asOf? }) | Event-Stream lesen (mit Upcaster), optional point-in-time | session.Events.AggregateStreamAsync<T>(id, timestamp) |
ctx.archiveStream(id, { aggregateType }) / ctx.restoreStream(id) / ctx.isStreamArchived(id) | Stream-Lifecycle | session.Events.ArchiveStream(id) |
ctx.queryProjection(name, { allTenants? }) | Read-Model via qualified name, auto-tenant-scoped | session.Query<T>() |
r.defineEvent(name, schema, { version }) | Versioniertes Event-Schema | [EventVersion] attribute |
r.eventMigration(name, from, to, transform) | Step-wise Upcaster | Upcast<TOld, TNew>(transform) |
r.multiStreamProjection({ name, table?, apply }) | Cross-aggregate, async Read-Model | MultiStreamProjection<TView, TIdentity> |
13.3 Was ersetzt / entfernt wurde
ctx.emit+PUBSUB_AGGREGATE_TYPEentfernt (Track C Runde 1). Jedes Event gehört zu genau einem Aggregate-Stream — keine synthetischen pubsub-Streams mehr. Cross-Feature-Reaktionen laufen überctx.appendEvent+r.multiStreamProjection.r.postEvententfernt. Der Mechanismus (event-dispatcher-cursor, SKIP LOCKED, LISTEN/NOTIFY) bleibt — die API-Fassade ist jetztr.multiStreamProjection. Table-less MSPs decken den Side-Effect-Only-Fall (Mail, Webhook) ab.events_idempotency_idx(Partial-UNIQUE aufmetadata.requestId) entfernt (Track C Runde 4). HTTP-Retry-Idempotency läuft komplett überpipeline/idempotency.ts(Redis-cached response replay), bevor der Command executet.metadata.requestIdist ein reiner Trace-Marker.
13.4 Neue Tabellen / Schema-Spalten
kumiko_archived_streams(tenant_id, aggregate_id PK, archived_at, archived_by, reason). Sparse — nur archivierte Streams landen dort. Auto-angelegt durchcreateEventsTable.events.event_versionexistierte schon — wird jetzt aktiv gesetzt (fresh writes stamp mitdefineEvent.version, nicht mehr Default 1).
13.5 Sample-Coverage
samples/cross-feature-eventsmigriert aufctx.appendEvent+r.multiStreamProjection(Cross-Feature-Reaktionen).samples/event-sourcing-showcaseist der Production-Pattern-Beweis: versioniertes Event + Upcaster + Inline-Projection + MSP + asOf + Archive + queryProjection in einem Feature mit 6 Integration-Tests.
13.6 Offen / bewusste Kompromisse
- Snapshots sind als Storage-Primitive da (
ctx.snapshotAggregate,ctx.loadAggregateWithSnapshot) — die Strategy (after N events, every M minutes) bleibt bewusst feature-level, Framework bietet nur die Primitiven. - MSP-Rebuild ist manuell (TRUNCATE + Cursor-Reset). Marten hat
RebuildProjectionAsync— bei uns nur für Single-Stream-Projections. Eigener Sprint. loadAllEventsByTypestreamt nicht. Heute buffered — reicht für moderate event-stores; bei >10⁶ Events pro Typ auf Streaming umstellen.AsyncOnlyEventUpcaster(Marten-Pattern für Upcaster mit DB-Lookups) fehlt. Sync-only reicht für alle bekannten Use-Cases.
Next Step nach Freigabe: Phase-1-Spike in samples/event-sourced-task/ bauen. ~3-4 Tage Arbeit. Proof-of-Concept, kein Framework-API. Wenn Ergonomie passt → Phase 2. Wenn nicht → Scope überdenken.