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
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.