| name | design-event-sourcing-cqrs |
| description | Designs event-sourced and CQRS systems — past-tense immutable event schemas, aggregate boundaries with command→validate→emit→apply and expected-version optimistic concurrency, append-only per-stream event store with outbox publishing, rebuildable idempotent projections, snapshotting, and versioned upcasting for event evolution. |
| when_to_use | When you need an audit-complete, replayable, append-only domain model (ledgers, order/workflow state machines, compliance) or are splitting write commands from read queries, or fixing event-sourcing pain (projection lag, frozen event shapes, slow rebuilds, lost ordering). For plain CRUD use db-migration-safety; for the messaging transport use message-queue-jobs. |
When to Use
Reach for this skill when the domain needs the history of changes as first-class truth, not just the current row:
- "We need a full audit trail / who-changed-what-when that nobody can edit after the fact"
- "Model an order / loan / subscription as a state machine with replayable transitions"
- "Build a ledger or balance that must reconcile to zero from its entries"
- "Separate the write side (commands) from a denormalized read side (queries)"
- "Time-travel: rebuild what the state was at any past moment"
- Fixing existing pain: projection lag, "we can't change the shape of a 2-year-old event", multi-hour rebuilds, lost per-aggregate ordering, eventual-consistency bugs in the UI
NOT this skill:
- Plain CRUD with mutable rows and no replay need → db-migration-safety (and stop here — event sourcing is the wrong tool for simple CRUD)
- The broker/transport that carries events (Kafka/SQS/RabbitMQ delivery, retries, DLQ) → message-queue-jobs
- A read-only cache layer to cut DB load → caching-strategy (a projection is a system of record for reads; a cache is disposable)
- Syncing offline client state with conflict resolution → build-offline-first-sync
- Recording why you chose event sourcing as a decision → write-adr
- Tuning the projection's query/index once it exists → optimize-sql-query
- Wiring client UI state to the read API → manage-client-server-state
Steps
-
First, decide if event sourcing is even warranted — most apps should not use it. Adopt it only when ≥1 of these is a hard requirement, and accept the listed cost:
| Driver (need ≥1) | Why ES wins | Cost you take on |
|---|
| Audit/compliance: immutable, complete history | Events are the audit log, tamper-evident | More moving parts than a table |
| Temporal queries / "state as of T" | Replay to any point | Rebuild + snapshot machinery |
| Complex state machine w/ many transitions | Each transition = one fact | Up-front modelling effort |
| Multiple read shapes from one write model | CQRS projections, independent scaling | Eventual consistency everywhere |
| Debugging by replaying real history | Deterministic reproduction | Replay must stay deterministic forever |
If none apply → use a normal table with CRUD and an updated_at; do not event-source CRUD. CQRS (split read/write models) is independently useful and does not require event sourcing — you can do CQRS over a normal DB.
-
Model events as immutable, past-tense facts — name them as business outcomes, never CRUD verbs. OrderPlaced, PaymentCaptured, FundsWithdrawn, ShipmentDispatched — not OrderUpdated/OrderSaved/SetStatus. An event records what happened, is append-only, and never carries read-model concerns (no denormalized display strings, no joined names, no computed totals the reader could derive). Event payload contract:
{
"event_id": "uuid-v4",
"event_type": "FundsWithdrawn",
"event_version"
Common Errors
- Event-sourcing plain CRUD. No audit/temporal/state-machine need → you bought replay/snapshot/upcasting machinery for nothing. Use a table.
- CRUD-named events (
OrderUpdated, EntitySaved, SetField). They carry no business meaning and force readers to diff state. Name the fact: OrderShipped, PriceReduced.
- Read concerns leaking into events — denormalized display names, joined data, computed totals. The event is now coupled to a read shape and breaks when the read model changes. Store only the writer's decided facts.
- Giant aggregate. "Account" containing every transaction of every user serializes all writes and replays forever. Scope the aggregate to the smallest invariant boundary.
- No expected-version on append. Two concurrent commands both read version 41 and both write 42 → lost update / broken invariant. Enforce
UNIQUE(aggregate_id, sequence) and retry on conflict.
- Dual-write to store and broker. A crash between the two loses or duplicates events. Use the outbox (the event row) + a relay; make consumers idempotent.
- Non-deterministic replay —
apply calls now(), random(), or a remote service, so rebuild ≠ original. Capture all nondeterminism into the event at emit time; apply must be a pure fold.
- Non-idempotent projector. Re-delivery (at-least-once) double-counts. Track per-projection
global_position and make applies upserts keyed by a natural id.
- Validating against a projection instead of the rebuilt aggregate. The projection is stale, so the invariant check races. Always rebuild the aggregate's own state from its stream to decide.
- Treating rejections as events. A failed/declined command must not append
OrderRejected unless the rejection itself is a meaningful business fact; otherwise return an error — don't pollute the log.
- Editing or deleting old events to "fix" them. Destroys auditability and breaks every existing projection's replay. Append a compensating event instead.
- Snapshot used as source of truth. If the log can't reproduce the snapshot, a snapshot bug becomes permanent corruption. Snapshots are a disposable cache.
- Assuming a global event order across aggregates. Per-stream order is guaranteed; cross-stream is not. Don't build invariants that need two streams ordered together — use a saga.
Verify
- Round-trip determinism: replay an aggregate's full stream twice into fresh in-memory state → byte-identical result; replaying with vs without a snapshot → identical state.
- Optimistic concurrency: fire two commands against the same aggregate at the same
expected_version in parallel → exactly one commits, the other gets the UNIQUE(aggregate_id, sequence) violation (23505) surfaced as 409 Conflict and succeeds only after reload+retry. The stream has no gap and no duplicated sequence.
- Projection rebuild:
TRUNCATE read_table, reset checkpoint to 0, replay all events → read model is bit-identical to its pre-truncate state. This proves it's rebuildable, not a hidden write model.
- Idempotent projector: replay the same event slice twice → read rows and the checkpoint are unchanged after the second pass (no double counts).
- Outbox at-least-once: kill the relay mid-publish, restart → every event reaches the broker at least once, consumers dedupe on
event_id, no event lost.
- Upcasting: feed a stored
event_version: 1 payload through the upcaster chain → it deserializes to current shape and apply accepts it; a lenient-deserialize test with an unknown extra field still loads.
- Drift detection: intentionally skip one event in a projection → the reconciliation checksum job flags the mismatch, and a rebuild from zero repairs it.
- Eventual consistency surfaced: a write returns a position; a read issued before the projector catches up is detectably stale (returns an older
as_of/version), and the read-your-writes path waits for checkpoint ≥ that position.
Done = replay is deterministic (1), concurrent appends conflict-detect with gap-free sequences (2), every projection rebuilds from zero idempotently (3,4), publishing is at-least-once with idempotent consumers (5), old event versions upcast cleanly (6), and projection drift is both detectable and auto-repairable (7) — all under parallel load, with eventual consistency made explicit to readers (8).