Pattern · Consistency
Event Sourcing and CQRS
Treating an append-only sequence of domain events as the authoritative system of record. Read and write decoupling with Command Query Responsibility Segregation (CQRS), asynchronous projections, aggregate snapshots, schema evolution upcasters, and operational antipatterns.
11 min read
In traditional CRUD systems, the database stores only the current state of an entity. When a user changes their shipping address, the server executes an UPDATE users SET address = '...' statement, completely overwriting the old value. The history of how the system arrived at its current state is lost unless tracked in secondary audit tables.
Event Sourcing inverts this model: instead of storing mutated state, the database stores an append-only log of immutable domain events (OrderPlaced, PaymentReceived, ItemShipped) as the primary source of truth. Current state is never stored as the root authority; it is derived dynamically by replaying the event stream from the beginning of time.
CQRS: Command Query Responsibility Segregation
Because replaying thousands of historical events to answer a simple UI read query is slow, Event Sourcing is almost always paired with Command Query Responsibility Segregation (CQRS):
- Write side (Commands): Enforces business logic and invariants. Receives intent (
PlaceOrder), validates it against the current aggregate, and appends an immutable event (OrderPlaced) to an append-only event store. - Read side (Queries): Independent read-optimized data stores (PostgreSQL, Elasticsearch, Redis) tailored specifically for UI screens and queries.
- Asynchronous projection: A background event processor consumes events from the event log and updates the read-side models asynchronously.
Projections: Materializing views for queries
A projection is an event consumer that listens to the event stream and maintains a read model:
- An
OrderPlacedevent inserts a new row in a PostgreSQL read table. - A
PaymentProcessedevent updates the order status toPAID. - An
AddressChangedevent updates the shipping address column.
Projections can be torn down, re-indexed, or rebuilt from scratch at any time simply by replaying the append-only event stream from offset zero into a brand-new database schema or an Elasticsearch index.
The aggregate rebuild cost: Snapshots
To validate a new command (for example, verifying that an account has sufficient funds to withdraw $100), the command handler must reconstruct the current balance by loading past events for that aggregate.
If an account has accumulated 50,000 transactions over 5 years, loading and replaying 50,000 events takes hundreds of milliseconds, crippling write throughput.
- Periodic snapshotting: Every $N$ events (e.g. every 100 events), a background task computes and writes a point-in-time state snapshot (
{ account_id: "A1", balance: 4500, version: 1000 }) to a fast key-value store. - Accelerated hydration: When a new command arrives, the aggregate loads the latest snapshot (version 1000) and replays only the few events that occurred after version 1000 (e.g. versions 1001 to 1004).
- Constant-time write validation: Loading time drops from $O(N)$ back down to $O(1)$ regardless of aggregate age.
Schema evolution and upcasters
In an event-sourced system, events are immutable and persisted forever. However, application requirements inevitably evolve: field names change, new mandatory fields are introduced, and enum values split.
You cannot run an ALTER TABLE statement to change the payload of historic JSON events recorded five years ago. Production event stores handle evolution using upcasters:
- An upcaster is a deterministic transformation pipeline located in the application layer between storage and deserialization.
- When the event store reads an
OrderPlaced_v1event from disk, the upcaster interceptor transforms the JSON intoOrderPlaced_v2by supplying default values or mapping renamed properties in memory. - The domain aggregate only deals with the latest strongly-typed event models, while raw on-disk historical events remain untouched.
When NOT to use event sourcing
Event Sourcing introduces immense operational and conceptual overhead:
Use standard CRUD with relational databases for 90% of microservices. Reserve Event Sourcing strictly for domains that demand:
- Immutable audit trails: Financial ledgers, stock trading, banking balances, healthcare record changes.
- Time-travel debugging: The ability to reconstruct exact system state at any arbitrary historical timestamp.
- Complex temporal business logic: Git-like branching, undo/redo state machines, or regulatory compliance workflows.
Comparing CRUD vs Event Sourcing
Traditional CRUD
- Simple, standard tooling
- Instant read-your-own-writes consistency
- Trivial schema migrations with SQL ALTER TABLE
- Destructive updates destroy historical audit context
- Lock contention on single rows under heavy concurrent writes
Pick it when: User management, settings, catalogs, general web applications.
Event Sourcing + CQRS
- Complete 100% audit trail built in
- Zero-loss historical replay
- Append-only writes eliminate database row lock contention
- Tailor custom read models for any query
- Read models are eventually consistent (lag)
- Event schema evolution requires upcasters
- Significant operational complexity
Pick it when: Financial ledgers, order tracking, compliance, booking state machines.
Numbers to quote
- Snapshot frequency
- Take a snapshot every 100 to 500 events per aggregate
- Hydration budget
- < 10 ms target to rebuild aggregate state into memory
- Read model replication lag
- Typically 50 ms to 500 ms between event append and projection update
- Optimistic concurrency check
- Append event with expected_version = N; abort with HTTP 409 if version changed
Where it bites
- Eventual consistency UX: Because the read model updates asynchronously, a user who clicks "Submit Order" and is redirected to "Order Details" may see a 404 Not Found if the projection worker has a 200 ms backlog. Clients must either poll, subscribe to WebSocket updates, or pass optimistic local state.
- Cross-aggregate invariants: Validating a business rule that spans two separate aggregates (e.g. "Customer cannot exceed credit limit across all orders") cannot be enforced atomically on the write side without distributed locks or saga coordinators.
Try it
Event-sourced Aggregate Service HLD
Design an event-sourced ledger and order aggregate with async CQRS read projections.