CQRS and Event Sourcing skill for Claude Code

Event-source only what truly needs history. Claude first checks whether event sourcing is justified by audit, temporal queries or domain complexity, and recommends plain CQRS when it is not. When it is, it designs past-tense events with metadata, appends with expected version, folds aggregates with pure evolve and decide functions, and builds idempotent projections with gap-safe checkpoints that can be rebuilt side by side without downtime.

  • namecqrs-event-sourcing
  • size221 lines · ≈2,450 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Decides whether CQRS or event sourcing is worth the cost and implements them correctly - separate read models, event design and metadata, an append-only event store with optimistic concurrency, aggregates that fold events and decide, idempotent projections with checkpoints, read-your-writes handling, snapshots, projection rebuilds, event upcasting, and personal data in immutable streams. Use when someone proposes event sourcing, builds an event store on Postgres or EventStoreDB, a projection drifts or needs rebuilding, or aggregate loads get slow.

Try asking

  • Should our billing ledger be event sourced? Make the case for or against
  • Build a Postgres event store with optimistic concurrency and a projection runner
  • Our order summary projection is missing events. Find out why and fix the reader

Use it when

  • Someone proposes event sourcing or CQRS for a service
  • Building or reviewing an event store and projection runner
  • A projection drifts, skips events, or needs a rebuild
  • Aggregate loads are slow or old event shapes need upcasting

Skip it when

How to check the result

Given-when-then tests cover every decide rule, a concurrency test shows exactly one of two racing appends wins, and a from-zero projection rebuild matches the live read model.

The common mistake it prevents

Reading projections by an auto-increment position, which silently skips events whose transactions commit out of order and leaves read models permanently wrong.

~/.claude/skills/cqrs-event-sourcing/SKILL.md

--- name: cqrs-event-sourcing description: Decides whether CQRS or event sourcing is worth the cost and implements them correctly - separate read models, event design and metadata,… license: MIT ---

Done well means: the team chose event sourcing for a concrete requirement it uniquely satisfies, appends are guarded by expected version, projections are idempotent and rebuildable from zero, the UI handles eventual consistency on purpose, and stored events are never edited.

Decide first

CQRS (separate write model and read models) and event sourcing (state stored as a sequence of events) are separate choices. Most systems that need one need only CQRS.

RequirementPlain CRUDCQRS, state-storedEvent sourcing
Simple forms over tablesBestOverkillWrong
Read shapes very different from write shape, heavy read loadViews/replicas firstGoodPossible
Full audit of every business decision, legally requiredAudit tableAudit table + outboxStrong fit
Temporal queries (“what did we know on March 3?”)HardHardStrong fit
Complex domain rules, many state transitionsPossibleGoodGood
Team new to the pattern, deadline in weeksBestOKHigh risk

Event sourcing costs: schema evolution of immutable data, eventual consistency in every read, projection infrastructure, harder ad-hoc queries, and GDPR handling. Write down which row in the table above justifies it. If none does, stop and recommend a state-stored model with an outbox (event-driven-architecture covers that).

Scope it: event-source only the bounded contexts that need it (ledger, orders, claims), not the whole system.

Workflow

- [ ] 1. Record the justification and the bounded context in scope
- [ ] 2. Design events from the domain language, with metadata
- [ ] 3. Choose the event store; enforce per-stream optimistic concurrency
- [ ] 4. Implement aggregates as fold + decide
- [ ] 5. Build projections: idempotent, checkpointed, rebuildable
- [ ] 6. Handle consistency in the UI/API (read-your-writes)
- [ ] 7. Plan versioning, snapshots, and personal data
- [ ] 8. Test with given-when-then and a rebuild comparison

2. Event design

  • Past-tense business facts: OrderPlaced, PaymentCaptured, ShipmentDispatched. Not OrderUpdated (no business meaning) and not commands (PlaceOrder).
  • Each event carries what happened, not derived state that can be recomputed.
  • Envelope metadata on every event:
{
  "eventId": "0192f3c4-...",
  "streamId": "order-8f3k",
  "streamVersion": 7,
  "type": "PaymentCaptured",
  "schemaVersion": 2,
  "occurredAt": "2026-03-14T09:26:53Z",
  "correlationId": "req-41ab",
  "causationId": "cmd-77c2",
  "actor": "user:123",
  "data": { "paymentId": "pay_19x", "amountCents": 4999, "currency": "EUR" }
}
  • One stream per aggregate instance (order-<id>). Streams should stay short (tens to hundreds of events); a stream that grows without bound signals a modelling problem (see “closing the books” below).

3. Event store

OptionUse when
Postgres table you ownModerate volume, team knows Postgres, wants one database
Marten (.NET on Postgres), Axon (JVM), Eventuous, Emmett (Node)Framework fits the stack; handles projections and concurrency
EventStoreDB / KurrentDBDedicated store, persistent subscriptions, high stream counts
Kafka as the event storeAvoid: no per-stream optimistic concurrency, no cheap read of one stream

Non-negotiables for any store:

  • Append with expected version; reject with a concurrency error if the stream moved. In Postgres: unique (stream_id, stream_version).
  • A global, gap-safe read order for projections. A plain bigserial is not gap-safe: sequence values are allocated before commit, so a projection can read id 101, then a slower transaction commits id 100, which the projection never sees. Use a commit-ordered technique (see the reference) or a single writer.
  • Publishing to other services goes through an outbox or the store’s subscription, never “append then publish” in application code.

For a complete Postgres schema, append function, gap-safe reading, and checkpoint tables, read references/postgres-event-store.md when building the store yourself.

4. Aggregates as fold + decide

type OrderEvent =
  | { type: "OrderPlaced"; data: { customerId: string; lines: Line[] } }
  | { type: "PaymentCaptured"; data: { paymentId: string; amountCents: number } }
  | { type: "OrderCancelled"; data: { reason: string } };

type OrderState = { status: "none" | "placed" | "paid" | "cancelled"; totalCents: number };

const initial: OrderState = { status: "none", totalCents: 0 };

function evolve(s: OrderState, e: OrderEvent): OrderState {
  switch (e.type) {
    case "OrderPlaced": return { status: "placed", totalCents: sum(e.data.lines) };
    case "PaymentCaptured": return { ...s, status: "paid" };
    case "OrderCancelled": return { ...s, status: "cancelled" };
  }
}

function decideCancel(s: OrderState, reason: string): OrderEvent[] {
  if (s.status === "none") throw new DomainError("order does not exist");
  if (s.status === "cancelled") return [];                 // idempotent no-op
  if (s.status === "paid") throw new DomainError("refund required before cancel");
  return [{ type: "OrderCancelled", data: { reason } }];
}

async function handleCancel(store: EventStore, orderId: string, reason: string) {
  const { events, version } = await store.readStream(`order-${orderId}`);
  const state = events.reduce(evolve, initial);
  const newEvents = decideCancel(state, reason);
  await store.append(`order-${orderId}`, newEvents, { expectedVersion: version });
}
  • evolve is pure and never throws; it must accept every historical event shape.
  • decide holds all business rules; it is where tests focus.
  • On a concurrency error, reload and re-decide (bounded retries, 3 is typical), or return 409 to the caller.

5. Projections

  • Each projection has a checkpoint (last processed global position). Update the read model and the checkpoint in the same transaction when both live in the same database.
  • Handlers are idempotent: upserts keyed by entity ID, or “apply only if event version > stored version”.
  • One projection per read need. Projections never read other projections’ tables mid-build; they can depend only on events.
  • Failures stop that projection (poison event alert) rather than skipping events silently.

Rebuild without downtime:

  1. Create the new read table (order_summary_v2) and a new checkpoint at 0.
  2. Run the new projection from position 0 until it catches up with the head.
  3. Switch reads (view swap, feature flag, or config) once lag is near zero.
  4. Drop the old table after a soak period.

6. Consistency at the edges

Read models lag writes by milliseconds to seconds. Choose per screen:

TechniqueWhen
Return the new state from the command handler (from decided events)The caller only needs its own result
Return the stream or global position; the read endpoint waits until the projection checkpoint passes it (with a timeout)Read-your-writes on the next page load
Optimistic UI update on the clientLists and dashboards
Accept lag and show “updating” statesAnalytics and reporting views

7. Versioning, snapshots, personal data

  • Never mutate stored events. Evolve with: additive optional fields; upcasters that transform old shapes to the current one on read; a new event type when meaning changes. Keep schemaVersion in metadata and test upcasters against stored samples.
  • Snapshots are an optimization: add them only when measured aggregate load time hurts (often several hundred events per stream). Store (streamId, version, state, snapshotSchemaVersion), load the snapshot and then events after it, and treat snapshots as disposable (delete and regenerate when the state shape changes).
  • Closing the books: for naturally long-lived entities (accounts), end a stream per period with a summary event and start a new stream seeded from it.
  • Personal data: keep PII out of event payloads (reference a separate, deletable store), or encrypt per data subject and delete the key on erasure requests (crypto-shredding). Decide this before the first event is stored.
  • Deleting or rewriting streams in production is irreversible; require explicit user confirmation and a backup.

8. Tests

test("cannot cancel a paid order", () => {
  const given: OrderEvent[] = [placed, paymentCaptured];
  const state = given.reduce(evolve, initial);
  expect(() => decideCancel(state, "changed mind")).toThrow("refund required");
});
  • Given events, when command, then events (or error). No database needed.
  • Concurrency test: two handlers load version 5, both append; exactly one succeeds.
  • Rebuild test: rebuild each projection from zero in CI against a fixture event set and compare with the incrementally built tables.

Gotchas

  • Event types named after CRUD (Updated) lose the “why” that justified event sourcing.
  • Projections that call external services while replaying will re-send emails on rebuild. Side effects belong in separate, checkpointed processors that a rebuild does not touch.
  • Querying the event store for business reads (“all orders over 100 EUR”) means a missing projection.
  • Large event payloads (documents, images) bloat every replay; store references.

Output contract

## Decision
Justifying requirement(s), bounded context in scope, alternatives rejected
## Events
| Event | Stream | Key fields | Schema version |
## Store and concurrency
Store choice, append contract, global ordering approach
## Projections
| Read model | Events consumed | Checkpoint | Rebuild plan |
## Consistency per screen / endpoint
## Versioning, snapshots, personal data policy

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/postgres-event-store.md 132 lines · 5.1 KB
# Postgres event store

A minimal, correct event store on PostgreSQL 14+. Use a library (Marten, Emmett,
Eventuous, Axon) when one fits the stack; build this only when you need to own it.

## Schema

```sql
CREATE TABLE events (
  global_position bigint GENERATED ALWAYS AS IDENTITY,
  stream_id       text        NOT NULL,
  stream_version  int         NOT NULL,
  event_id        uuid        NOT NULL UNIQUE,
  type            text        NOT NULL,
  schema_version  smallint    NOT NULL DEFAULT 1,
  data            jsonb       NOT NULL,
  metadata        jsonb       NOT NULL DEFAULT '{}',
  occurred_at     timestamptz NOT NULL DEFAULT now(),
  tx_id           xid8        NOT NULL DEFAULT pg_current_xact_id(),
  PRIMARY KEY (global_position),
  UNIQUE (stream_id, stream_version)          -- optimistic concurrency
);

CREATE INDEX events_stream_idx ON events (stream_id, stream_version);
CREATE INDEX events_tx_pos_idx ON events (tx_id, global_position);

CREATE TABLE projection_checkpoints (
  projection      text PRIMARY KEY,
  last_tx_id      xid8   NOT NULL DEFAULT '0',
  last_position   bigint NOT NULL DEFAULT 0,
  updated_at      timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE snapshots (
  stream_id        text PRIMARY KEY,
  stream_version   int  NOT NULL,
  snapshot_version smallint NOT NULL,
  state            jsonb NOT NULL,
  created_at       timestamptz NOT NULL DEFAULT now()
);
```

Revoke `UPDATE` and `DELETE` on `events` from the application role so immutability is
enforced by the database, not by convention.

## Append with expected version

Application side, in one transaction:

1. Read the current max version: `SELECT coalesce(max(stream_version), 0) FROM events WHERE stream_id = $1`.
2. Compare with the expected version the caller loaded. If different, raise a
   concurrency error immediately (cheap early exit).
3. Insert new events with versions `expected + 1 ... expected + n`.
4. Commit. If the insert hits the unique `(stream_id, stream_version)` constraint
   (SQLSTATE 23505), another writer won the race: map it to the same concurrency error.

The unique constraint is the real guard; step 2 only avoids wasted work.

For a brand-new stream, expected version is 0. For "append regardless" (rarely correct),
skip step 2 and retry on 23505 with the new max.

## Reading a stream

```sql
SELECT stream_version, type, schema_version, data, metadata
FROM events
WHERE stream_id = $1 AND stream_version > $2   -- $2 = snapshot version or 0
ORDER BY stream_version;
```

## Gap-safe global reading for projections

Identity values are assigned at insert time, not commit time. A projection that reads
`WHERE global_position > last_position` can skip an event whose transaction commits after
a later-numbered one was already read.

Commit-ordered reading with transaction IDs (the approach used by several Postgres event
store libraries):

```sql
SELECT global_position, tx_id, stream_id, stream_version, type, data, metadata
FROM events
WHERE (tx_id, global_position) > ($1::xid8, $2::bigint)     -- last checkpoint
  AND tx_id < pg_snapshot_xmin(pg_current_snapshot())      -- only fully committed txs
ORDER BY tx_id, global_position
LIMIT 500;
```

`pg_snapshot_xmin(pg_current_snapshot())` is the oldest transaction still in progress; any
transaction ID below it has either committed or aborted, so no earlier event can appear
later. Store both `tx_id` and `global_position` of the last processed row as the
checkpoint.

Alternatives when this is not possible:

- Single writer process per store (serializes appends; simple, limits throughput).
- Take a transaction-level advisory lock around appends (`pg_advisory_xact_lock(<const>)`),
  which serializes commits in id order at the cost of write concurrency.
- Read with a safety lag (only events older than N seconds). Simple but not a guarantee
  under long transactions; avoid for financial data.

## Projection loop

```text
loop:
  begin
    rows = read batch after checkpoint (query above)
    for row in rows: apply(row) to read tables (idempotent upsert)
    update projection_checkpoints set last_tx_id, last_position = last row
  commit
  if rows empty: wait for NOTIFY or sleep 200-1000 ms
```

- Apply and checkpoint in the same transaction so a crash never double-applies or skips.
- Use `LISTEN/NOTIFY` (trigger on insert into `events`) to wake projections instead of
  tight polling; still poll on a timer as a fallback because notifications can be missed.
- Run each projection in exactly one process (advisory lock per projection name) to keep
  ordering.

## Snapshots

- Write a snapshot every N events (for example 100) after a successful append, outside the
  command's critical path.
- On load: read snapshot; if `snapshot_version` differs from the code's current state
  version, ignore it and fold from zero; otherwise fold events after `stream_version`.

## Retention and size

- Partitioning `events` by range of `global_position` keeps indexes manageable past a few
  hundred million rows; projections still read across partitions in order.
- Archive closed streams to cold storage only if no projection rebuild will need them, or
  keep an archive table that rebuild jobs can read.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc