Blog / Architecture
Architecture

CQRS and Event Sourcing: When the Complexity Is Worth It

When CQRS and event sourcing justify their complexity, with event store design, projection building, snapshot strategies, and a cost-benefit analysis.

Akhil Sharma

Akhil Sharma

January 16, 2026

11 min read

CQRS and Event Sourcing: When the Complexity Is Worth It

CQRS (Command Query Responsibility Segregation) and event sourcing are frequently mentioned together, but they're independent patterns. You can use CQRS without event sourcing and event sourcing without CQRS. Most teams that adopt both underestimate the operational cost. Let's look at when that cost is justified.

CQRS in 60 Seconds

Separate your read model from your write model. Commands (writes) go through one path with business rule validation. Queries (reads) go through another path optimized for the specific read pattern.

Why bother? Because read and write patterns have different optimization needs. Writes need normalization, constraints, and transactional integrity. Reads need denormalized views, indexes, and fast retrieval. Forcing both through the same schema means compromising on both.

Simple CQRS example: Your orders table is normalized for writes. A materialized view or separate table provides the read model:

sql

The read model is updated whenever the write model changes (synchronously via triggers, asynchronously via events, or on a schedule).

Event Sourcing in 60 Seconds

Instead of storing current state, store the sequence of events that produced the state. Current state is derived by replaying events.

The event store is append-only. Events are immutable facts — things that happened. You never update or delete events.

Event Store Design

sql

Loading and saving an aggregate:

python

Advanced System Design Cohort

We build this end-to-end in the cohort.

Live sessions, real systems, your questions answered in real time. Next cohort starts 2nd July 2026 — 20 seats.

Reserve your spot →

class EventStore: async def load(self, aggregate_id: str) -> list[dict]: return await db.fetch( "SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version", aggregate_id, )*

async def save(self, aggregate_id: str, events: list[dict], expected_version: int): for i, event in enumerate(events): try: await db.execute(""" INSERT INTO events (aggregate_id, aggregate_type, event_type, version, payload, created_at) VALUES ($1, $2, $3, $4, $5, NOW()) """, aggregate_id, "Order", event["event_type"], expected_version + i + 1, json.dumps(event["payload"])) except UniqueViolationError: raise ConcurrencyConflict( f"Aggregate {aggregate_id} was modified concurrently" )

Rebuilding projections is event sourcing's killer feature. If your read model has a bug or you need a new read model, replay all events from the beginning. No data migration, no backfill scripts. The event log is the source of truth.

python

Snapshots: Taming Long Event Streams

An aggregate with 10,000 events takes 10,000 replays to load. Snapshots periodically capture the current state, so you only replay events since the last snapshot.

python

When to snapshot: Every N events (e.g., every 100) or when load time exceeds a threshold. Don't snapshot after every event — that defeats the purpose of event sourcing.

The Cost-Benefit Analysis

Costs

  1. Eventual consistency. Read models lag behind writes. For many use cases this is fine (an order dashboard that's 500ms behind is acceptable). For others, it's not (showing a user their just-updated balance).

  2. Complexity. Two data models, projection management, snapshot logic, event schema evolution. A CRUD service that took 2 weeks now takes 6.

  3. Event schema evolution. Events are immutable, but your domain model evolves. Adding a required field to an event type means handling old events that don't have it. This requires upcasting or versioned event handlers.

  4. Operational overhead. Monitoring event store growth, projection lag, snapshot staleness. Debugging requires thinking in events, not state.

Benefits

  1. Complete audit trail. Every state change is recorded with timestamp and metadata. Regulatory compliance (finance, healthcare) often requires this.

  2. Temporal queries. "What was the order state at 3 PM yesterday?" Replay events up to that timestamp. Impossible with state-based storage without explicit history tables.

  3. Projection flexibility. Need a new read model? Build a new projection and replay events. No data migration, no downtime.

  4. Domain insight. Events capture intent ("OrderCancelled because inventory_shortage") that state updates lose ("status = cancelled").

When It's Worth It

Use CQRS + Event Sourcing when you have:

  • Regulatory requirements for complete audit trails
  • Multiple read models serving different views of the same data
  • Temporal query requirements
  • Complex domain logic where the event-driven model simplifies business rules

Skip it when:

  • You have simple CRUD operations
  • Your team is small and moving fast
  • You don't need audit trails or temporal queries
  • Eventual consistency in reads is unacceptable

The honest assessment: most applications don't need event sourcing. CQRS (separating read and write models) has broader applicability, especially as an optimization for read-heavy workloads. Event sourcing is powerful but expensive — save it for domains where the audit trail and temporal queries are genuine requirements, not theoretical future needs.

CQRS Event Sourcing Architecture Domain-Driven Design

become an engineering leader

Advanced System Design Cohort