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.
- name
cqrs-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
- You need reliable events between services from a normal database — use event-driven-architecture
- The question is aggregate and bounded context modelling — use domain-driven-design
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.
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.
| Requirement | Plain CRUD | CQRS, state-stored | Event sourcing |
|---|---|---|---|
| Simple forms over tables | Best | Overkill | Wrong |
| Read shapes very different from write shape, heavy read load | Views/replicas first | Good | Possible |
| Full audit of every business decision, legally required | Audit table | Audit table + outbox | Strong fit |
| Temporal queries (“what did we know on March 3?”) | Hard | Hard | Strong fit |
| Complex domain rules, many state transitions | Possible | Good | Good |
| Team new to the pattern, deadline in weeks | Best | OK | High 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. NotOrderUpdated(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
| Option | Use when |
|---|---|
| Postgres table you own | Moderate 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 / KurrentDB | Dedicated store, persistent subscriptions, high stream counts |
| Kafka as the event store | Avoid: 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
bigserialis 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 });
}
evolveis pure and never throws; it must accept every historical event shape.decideholds 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:
- Create the new read table (
order_summary_v2) and a new checkpoint at 0. - Run the new projection from position 0 until it catches up with the head.
- Switch reads (view swap, feature flag, or config) once lag is near zero.
- Drop the old table after a soak period.
6. Consistency at the edges
Read models lag writes by milliseconds to seconds. Choose per screen:
| Technique | When |
|---|---|
| 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 client | Lists and dashboards |
| Accept lag and show “updating” states | Analytics 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
schemaVersionin 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
# 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.