Skip to content

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.ts schreibt Rows direkt in Entity-Tabellen
  • Outbox-Table bekommt Events in derselben TX
  • outbox-poller.ts publiziert Events via event-broker (Redis Pub/Sub)
  • event-log.ts appendet 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.ts wird zu event-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)

Terminal window
yarn kumiko inspect task <id> # Event-Timeline eines Aggregats
yarn kumiko project list # Alle registrierten Projections
yarn kumiko project rebuild <name> # Projection aus Events neu aufbauen
yarn kumiko project status # Lag + Health aller Projections
yarn kumiko events tail # Live-Event-Stream
yarn kumiko events asOf <timestamp> # Zustand zu einem Zeitpunkt

3. 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 version im 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)

AltNeuÄnderung
db/crud-executor.tsdb/event-store-executor.tsWrites werden Events + Projection-Update in selber TX
pipeline/outbox-table.tsbleibtWird zu “pending-events-for-fanout” (SSE, Search, Notifications)
pipeline/outbox-poller.tsbleibtLiest aus events-table mit last_processed_id-Cursor
pipeline/event-log.tsentferntErsetzt durch events-Table in Postgres
(neu)db/event-store.tsLow-level Append/Load/Snapshot-API
(neu)db/projection-runtime.tsProjection-Apply-Runtime
(neu)db/upcaster.tsUpcaster-Registry + Transform-Runtime
(neu)db/snapshot-store.tsSnapshot-Persistierung

4.2 Engine

AltNeuÄnderung
engine/registry.tserweitertEvent-Schema-Registry, Projection-Registry, Upcaster-Registry
engine/define-feature.tserweitertNeue Registrar-Methoden: r.event, r.projection, r.eventMigration
engine/types/*.tserweitertEvent-Def, Projection-Def, Aggregate-Def Types
(neu)engine/event-shape-builder.tsAuto-generiert Default-Event-Shapes aus Entity-Fields
(neu)engine/projection-shape-builder.tsAuto-generiert Default-Projection aus Entity-Fields
(neu)engine/reducer-builder.tsAuto-generiert Default-Reducer aus Field-Types

4.3 Pipeline / Dispatcher

AltNeuÄnderung
pipeline/dispatcher.tserweitertWrite-Path: Command → Validate → Load-Aggregate → Validate-Against-State → Append-Events → Update-Projection (in TX)
pipeline/lifecycle-pipeline.tserweitertHooks werden auf Event-Level umgeformt: preSavepreEvent, postSavepostEvent + postProjection
pipeline/idempotency.tserweitertRequest-Level + Event-Level (via expected_version)

4.4 Commands

AltNeuÄnderung
engine/types/handlers.tsWriteHandler emit-basierthandler erhält State, liefert Events zurück (statt DB-Writes)
(neu)engine/command-handler.tsAuto-Command-Handler für Create/Update/Delete aus Entity-Def

4.5 Queries

AltNeuÄnderung
engine/types/handlers.tsQueryHandler liest ProjectionDefault-Queries (list, detail) lesen aus Projection — identisch zu bisheriger CRUD-Logik
(neu)engine/query-asof.tsasOf-Parameter: rekonstruiert Projection-State zum Timestamp via Event-Replay

4.6 System-Hooks

AltNeuÄnderung
pipeline/system-hooks.ts — Searchreagiert auf postEvent + postProjection statt postSaveIndexing-Logik bleibt, Trigger wechselt
pipeline/system-hooks.ts — SSEsendet Event-Delta statt Row-SnapshotClients kriegen Events, reduzieren clientseitig (oder kriegen Projection-Updates, je nach Channel-Typ)
pipeline/system-hooks.ts — AuditentfälltEvents sind der Audit — keine separate Audit-Tabelle nötig

4.7 Core-Features

Alle Postgres-Entities sind ES. Ephemeres wandert nach Redis.

EntitySpeicherGrund
userESEmail-/Password-Change-Audit automatisch
tenantESBilling-/Config-Änderungen automatisch auditiert
deliveryESStatus-Übergänge sind Events
notificationESZustellungs-Historie = Events
session, Auth-TokensRedis (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

AltNeuÄnderung
api/server.tsbleibtHTTP-Shape unverändert
api/observability-middleware.tsbleibtSpan-Naming: cmd.task.create statt write.task.create (kosmetisch)
(neu)api/inspect-route.tsGET /api/inspect/:type/:id — Event-Timeline (Admin-Only)
(neu)api/projections-route.tsGET /api/projections/status — Projection-Health (Admin-Only)

4.9 CLI

NeuZweck
yarn kumiko inspect <type> <id>Event-Timeline eines Aggregats
yarn kumiko project listAlle Projections + Status
yarn kumiko project rebuild <name>Projection aus Events neu bauen
yarn kumiko project statusLag-Monitoring
yarn kumiko events tailLive-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.create aufrufen)
  • 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)
  • Meilisearch-Indexer reagiert auf postEvent-Hook (nicht mehr postSave)
  • 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)
  • 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-trail wird 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_version im 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 (ersetzt entity_not_found konzeptuell, aber beide behalten)

5.9 Observability

Neue Metrics:

  • kumiko_events_appended_total{tenant, aggregate_type, event_type}
  • kumiko_events_appended_duration_seconds
  • kumiko_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.append pro Event

5.10 Testing

  • TestStack lä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:

SampleLevelZweck
basic-crudLevel 1Zeigt: User schreibt CRUD-Code, kriegt ES-Vorteile (kann inspect aufrufen und Timeline sehen)
custom-handlersLevel 2Zeigt: Domain-Events (task.completed etc.)
audit-trail (renamed → event-timeline)Level 1+inspectZeigt: Audit ist Default, keine extra Config
time-travel (neu)Level 1+asOfZeigt: Zustand zu beliebigem Timestamp
projections (neu, renamed von search?)Level 3Zeigt: Custom Read-Model für Report
schema-evolution (neu)Level 4Zeigt: Upcaster in Action
state-machineLevel 2Zeigt: Events als Zustandsübergänge
relationsLevel 1/2Zeigt: Cross-Aggregate-Events
Übrige SamplesdiversAnpassen 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 → GET liest aus Projection → inspect zeigt 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.ts ersetzt crud-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 projections zeigt zweites Read-Model

Phase 4 — Power-Features (2 Wochen)

Ziel: Upcaster, Snapshots, Time-Travel, Domain-Events

  • r.event() für Custom Domain-Events
  • r.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

RisikoWahrscheinlichkeitImpactMitigation
Synchrone Projection-Writes werden zu langsam bei großen AggregatenMittelHochSnapshot-Pattern von Anfang an. Async-Projections als opt-in-Escape-Hatch
Event-Schema-Evolution wird Boilerplate-HölleHochHochDeklarative Upcaster mit Tests. Convention “additive-only” solange möglich
Developer verstehen das Modell nicht, verlassen das FrameworkMittelHochLevel-1-API ist identisch zu CRUD. inspect macht Internals sichtbar, nicht verpflichtend
Performance bei sehr vielen Events pro AggregatNiedrig-MittelMittelSnapshots. Aggregate-Design-Guidelines (max ~1000 Events pro Aggregat als Richtwert)
Full-Stack-Integration-Tests werden flaky durch Projection-LagMittelMittelDefault synchrone Projections eliminiert das. Async-Projections haben explizite Await-Helpers im TestStack
Access-Control-Matrix wird komplex (event-level + projection-level)MittelMittelGuards erzwingen Konsistenz. Doc-First-Approach mit Beispielen
Marketing-Positionierung zieht ES-Experten, die dann Level-1-API “zu einfach” findenNiedrigNiedrigLevel 2-4 ist da. Verschiedene Einstiege für verschiedene Zielgruppen

8. Offene Design-Fragen

  1. 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.
  2. Cross-Aggregate-Transactions: Wenn Command 2 Aggregates touched (z.B. order.place ändert cart + inventory) — Saga-Pattern oder atomare Multi-Aggregate-Transaction? Vorschlag: atomic für den MVP, Saga als Pattern-Sample später.
  3. Event-Bubbling / Inter-Aggregate-Events: Wenn order.placed event → inventory.reserved event triggern soll, wie modelliert? Sync in selber TX oder async via Job? Beide anbieten?
  4. Snapshot-Invalidierung bei Reducer-Code-Change: Automatisch erkennen (Hash des Reducers)? Manual bump? Pragmatisch: Manual bump über snapshotVersion in Entity-Def.
  5. 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.
  6. 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.
  7. 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.
  8. 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.hook etc. — 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:

  1. 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.
  2. Cross-Aggregate-Joins sind scharf getypt. Multi-Stream-Projektionen gruppieren Events über UUID-Keys (User-ID, Tenant-ID). integer/serial würde pro Entity-Typ unterschiedliche Zählerräume schaffen — die Projektion müsste nach (aggregateType, id) komposit-keyen.
  3. Tenant-Schutz beim Append. Der INSERT … SELECT … WHERE EXISTS-Guard prüft die aggregate_id + tenant_id gemeinsam. Gemischte ID-Typen pro Tenant würden den Check unbrauchbar machen.

Auswirkung auf Features

  • Alle Entities, die durch r.crud() oder createEventStoreExecutor(...) gehen, müssen idType: "uuid" deklarieren. Das Framework generiert beim create automatisch 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 d5bf31928bdecb 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

APIZweckMarten-Äquivalent
ctx.appendEvent({ aggregateId, aggregateType, type, payload })Domain-Event auf Aggregate-Stream schreibensession.Events.Append(id, event)
ctx.loadAggregate(id, { asOf? })Event-Stream lesen (mit Upcaster), optional point-in-timesession.Events.AggregateStreamAsync<T>(id, timestamp)
ctx.archiveStream(id, { aggregateType }) / ctx.restoreStream(id) / ctx.isStreamArchived(id)Stream-Lifecyclesession.Events.ArchiveStream(id)
ctx.queryProjection(name, { allTenants? })Read-Model via qualified name, auto-tenant-scopedsession.Query<T>()
r.defineEvent(name, schema, { version })Versioniertes Event-Schema[EventVersion] attribute
r.eventMigration(name, from, to, transform)Step-wise UpcasterUpcast<TOld, TNew>(transform)
r.multiStreamProjection({ name, table?, apply })Cross-aggregate, async Read-ModelMultiStreamProjection<TView, TIdentity>

13.3 Was ersetzt / entfernt wurde

  • ctx.emit + PUBSUB_AGGREGATE_TYPE entfernt (Track C Runde 1). Jedes Event gehört zu genau einem Aggregate-Stream — keine synthetischen pubsub-Streams mehr. Cross-Feature-Reaktionen laufen über ctx.appendEvent + r.multiStreamProjection.
  • r.postEvent entfernt. Der Mechanismus (event-dispatcher-cursor, SKIP LOCKED, LISTEN/NOTIFY) bleibt — die API-Fassade ist jetzt r.multiStreamProjection. Table-less MSPs decken den Side-Effect-Only-Fall (Mail, Webhook) ab.
  • events_idempotency_idx (Partial-UNIQUE auf metadata.requestId) entfernt (Track C Runde 4). HTTP-Retry-Idempotency läuft komplett über pipeline/idempotency.ts (Redis-cached response replay), bevor der Command executet. metadata.requestId ist 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 durch createEventsTable.
  • events.event_version existierte schon — wird jetzt aktiv gesetzt (fresh writes stamp mit defineEvent.version, nicht mehr Default 1).

13.5 Sample-Coverage

  • samples/cross-feature-events migriert auf ctx.appendEvent + r.multiStreamProjection (Cross-Feature-Reaktionen).
  • samples/event-sourcing-showcase ist 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.
  • loadAllEventsByType streamt 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.