Backend Development7 min read

Event Sourcing & CQRS

Implement event sourcing: append-only event store, aggregate reconstruction, CQRS with separate read/write models, and projection updates.

event-sourcingcqrsdddarchitecture

Event sourcing stores state as a sequence of events rather than current state. CQRS separates read and write models. Together they enable temporal queries, audit logs, event replay, and scalable read models - at the cost of complexity. Use only where the benefits justify the overhead.

Event sourcing is a powerful but complex pattern. Only adopt it when you need an audit log, temporal queries, or event replay as a core business requirement - not as an architectural default. Start with a simple CRUD system and refactor to event sourcing if/when you hit specific limitations.

Workflow

  1. Design the event storeThe event store is the source of truth - events are immutable and never deleted.
  2. Model aggregates and eventsAggregates encapsulate business logic; events describe what happened (not what to do).
  3. Implement CQRS with projectionsSeparate read and write models: write model (events) is normalized; read model (projections) is denormalized for query efficiency.
  4. Handle event versioning and migrationEvents are immutable but their schemas evolve - event versioning prevents breaking old events.
  5. Rebuild projections via event replayEvent replay lets you create new views of historical data or fix corrupted projections.

Output Format

PostgreSQL event store with optimistic concurrency, aggregate with `apply`/`evolve`, CQRS projection table per view, event upcasting for version migration, snapshot for performance, full replay capability.

Examples

Append event: `async function appendEvent(streamId: string, expectedVersion: number, event: Event) { try { await db.query("INSERT INTO events (stream_id, stream_version, event_type, payload) VALUES ($1, $2, $3, $4)", [streamId, expectedVersion + 1, event.type, event.payload]) } catch (e) { if (isUniqueViolation(e)) throw new ConcurrencyError("Stream version conflict") throw e } }`
Load aggregate: `async function loadOrder(orderId: string): Promise<Order> { const events = await db.query("SELECT * FROM events WHERE stream_id = $1 ORDER BY stream_version", [orderId]); return Order.reconstitute(events.rows.map(r => ({ type: r.event_type, ...r.payload }))) }`
Projection with position: `SELECT e.* FROM events e WHERE e.id > (SELECT last_position FROM projection_positions WHERE name = "order-summary") ORDER BY e.id LIMIT 100`

Edge Cases & Gotchas

  • Eventually consistent read models: after writing an event, the projection is updated asynchronously - clients may read stale data. For UIs that need immediate read-your-writes consistency, return the new state in the write response
  • Long-lived aggregates: an aggregate with 10,000 events is slow to reconstitute. Implement snapshots at N events: store current state, load snapshot + only events after it
  • Saga/process managers: multi-step workflows spanning multiple aggregates require sagas - listen to events from one aggregate, trigger commands on another

When to Break These Rules

  • For most CRUD apps - user profiles, blog posts, configuration - event sourcing adds complexity without benefit. If you don't need audit history, temporal queries, or event replay, use a standard relational model.

Reference Files

  • EventStoreDB: https://www.eventstore.com/eventstoredb
  • Event Sourcing pattern: https://martinfowler.com/eaaDev/EventSourcing.html
  • CQRS: https://martinfowler.com/bliki/CQRS.html
  • Axon Framework concepts: https://docs.axoniq.io/axon-framework-reference/4.10/

More skills in this library

Browse all technical skills - Mobile, AI/ML, Web, Backend, Design, and Product.