CQRS & Event Sourcing Cheat Sheet
Covers separating command and query models, storing state as an append-only event log, projections, and snapshotting for performance.
Separating Commands from Queries
Commands mutate state and return nothing (or an ack); queries read state and never mutate it.
// Command: intent to change state, handled by the write modelinterface PlaceOrderCommand { orderId: string; items: { productId: string; qty: number }[];}class PlaceOrderHandler { constructor(private repo: OrderRepository) {} async handle(cmd: PlaceOrderCommand): Promise<void> { const order = Order.place(cmd.orderId, cmd.items); await this.repo.save(order); }}// Query: read-only, served by a separate (often denormalized) read modelinterface OrderSummaryQuery { orderId: string; }class OrderSummaryHandler { constructor(private readDb: ReadDatabase) {} async handle(q: OrderSummaryQuery): Promise<OrderSummaryDto> { return this.readDb.orderSummaries.findById(q.orderId); }}
Event-Sourced Aggregate
State is derived by replaying a sequence of domain events rather than stored directly.
type OrderEvent = | { type: 'OrderPlaced'; orderId: string; items: Item[] } | { type: 'OrderShipped'; orderId: string; trackingId: string } | { type: 'OrderCancelled'; orderId: string; reason: string };class Order { private status: 'placed' | 'shipped' | 'cancelled' = 'placed'; private uncommitted: OrderEvent[] = []; static rehydrate(events: OrderEvent[]): Order { const order = new Order(); events.forEach(e => order.apply(e, false)); return order; } private apply(event: OrderEvent, isNew: boolean): void { if (event.type === 'OrderShipped') this.status = 'shipped'; if (event.type === 'OrderCancelled') this.status = 'cancelled'; if (isNew) this.uncommitted.push(event); } ship(trackingId: string): void { if (this.status !== 'placed') throw new Error('cannot ship'); this.apply({ type: 'OrderShipped', orderId: this.id, trackingId }, true); }}
Projections & Snapshots
Projections build read models from the event stream; snapshots avoid replaying the full history every load.
// Projection: subscribes to the event stream, updates a read-optimized tableasync function onOrderShipped(event: OrderShippedEvent) { await readDb.orderSummaries.update(event.orderId, { status: 'shipped', trackingId: event.trackingId, });}// Snapshot: periodically persist current state to bound replay costinterface Snapshot { aggregateId: string; version: number; state: unknown; }async function loadOrder(id: string): Promise<Order> { const snapshot = await snapshotStore.findLatest(id); const eventsSince = await eventStore.readFrom(id, snapshot?.version ?? 0); return Order.rehydrate(eventsSince, snapshot?.state);}// Rule of thumb: snapshot every N events (e.g. 100) or on a time interval
CQRS/ES Glossary
Core vocabulary for this architecture pair.
- Command- intent to change state; validated and either accepted or rejected
- Event- immutable fact that something happened; already-accepted, never rejected
- Event Store- append-only log, the source of truth for event-sourced aggregates
- Projection- process that builds a read model by consuming events
- Read Model- denormalized, query-optimized view, often eventually consistent
- Eventual Consistency- read model lags the write model by a small, bounded delay
- Snapshot- cached aggregate state at a version, to avoid full event replay
- Idempotent Handler- projection handler safe to re-run on the same event without side effects
Optimistic Concurrency on Append
Guard against lost updates by requiring the expected stream version when appending new events.
interface EventStore { append(streamId: string, expectedVersion: number, events: OrderEvent[]): Promise<void>; readFrom(streamId: string, fromVersion: number): Promise<{ event: OrderEvent; version: number }[]>;}async function saveOrder(repo: EventStore, order: Order): Promise<void> { try { await repo.append(order.id, order.loadedVersion, order.uncommittedEvents); } catch (err) { if (err instanceof ConcurrencyError) { // another writer appended since we loaded — reload, reapply the // command against fresh state, and retry (bounded number of times) const fresh = await Order.load(order.id, repo); throw new RetryCommand(fresh); } throw err; }}
Schema Evolution with Upcasters
Old events on the log never change; upcasters transform stored payloads into the current shape at read time.
// v1 shape (already persisted, immutable): { type: 'OrderPlaced', orderId, itemIds: string[] }// v2 shape (current code expects): { type: 'OrderPlaced', orderId, items: { id: string; qty: number }[] }const upcasters: Record<string, (raw: any) => any> = { OrderPlaced_v1: (raw) => ({ type: 'OrderPlaced', orderId: raw.orderId, items: raw.itemIds.map((id: string) => ({ id, qty: 1 })), }),};function deserialize(stored: { type: string; version: number; payload: any }): OrderEvent { const key = `${stored.type}_v${stored.version}`; const upcast = upcasters[key]; return upcast ? upcast(stored.payload) : stored.payload;}
Transactional Outbox for Reliable Publishing
Write events and an outbox row in the same DB transaction, then relay them asynchronously so a crash never loses an event.
BEGIN;INSERT INTO event_store (stream_id, version, type, payload)VALUES ('order-123', 4, 'OrderShipped', '{"trackingId":"1Z..."}');INSERT INTO outbox (id, stream_id, type, payload, published)VALUES (gen_random_uuid(), 'order-123', 'OrderShipped', '{"trackingId":"1Z..."}', false);COMMIT;-- a separate relay process polls (or uses CDC / logical replication on)-- the outbox table, publishes to the message broker, then marks published=true-- SELECT * FROM outbox WHERE published = false ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED;
Saga / Process Manager for Cross-Aggregate Workflows
A saga reacts to events from one aggregate and issues commands to others, coordinating a multi-step business process.
class OrderFulfillmentSaga { async on(event: OrderEvent): Promise<void> { switch (event.type) { case 'OrderPlaced': await this.commandBus.send(new ReserveInventoryCommand(event.orderId, event.items)); break; case 'InventoryReserved': await this.commandBus.send(new ChargePaymentCommand(event.orderId)); break; case 'PaymentFailed': // compensating action — undo the reservation, not a DB rollback await this.commandBus.send(new ReleaseInventoryCommand(event.orderId)); await this.commandBus.send(new CancelOrderCommand(event.orderId, 'payment_failed')); break; } }}
Common CQRS/ES Pitfalls
Failure modes that show up once a system is in production, not during the prototype.
- Unbounded stream growth- an aggregate that never closes (e.g. a long-lived cart) accumulates events forever; split it or snapshot aggressively
- Leaking write-model types into reads- reusing domain events as API DTOs couples clients to internal schema changes; project into dedicated read DTOs
- Synchronous projection updates- blocking the command handler on the read model write reintroduces coupling that CQRS was meant to remove
- No replay tooling- if you can't rebuild a read model from the event store on demand, projections can't safely evolve
- Fat events vs. thin events- thin events (IDs only) force projections to query back for data; fat events duplicate data but decouple projections from the write DB
- Missing correlation/causation IDs- without them, tracing a saga's multi-step flow through logs across services becomes guesswork
Don't adopt full event sourcing just to get CQRS's read/write scaling benefits — you can run CQRS with a conventional state-stored write model and still split it from a denormalized read model; add event sourcing only when you specifically need the audit trail or temporal replay it provides.