ESC
Type to search guides, tutorials, and reference documentation.
Verified by Garnet Grid

Event Sourcing and CQRS

Implement event sourcing to capture every state change as an immutable event, and CQRS to optimize read and write paths independently. Covers event store design, projections, snapshots, and when these patterns create more problems than they solve.

Event sourcing stores every state change as an immutable event rather than overwriting the current state. Instead of a users table with the latest values, you have an event log: UserCreated, EmailChanged, AccountDeactivated. The current state is derived by replaying events.

CQRS (Command Query Responsibility Segregation) separates the write model (commands that produce events) from the read model (optimized views for queries).


Event Sourcing Fundamentals

Traditional vs Event-Sourced

Traditional:
  UPDATE accounts SET balance = balance - 100 WHERE id = 456;
  -- Previous balance is lost

Event Sourced:
  Event 1: AccountOpened(id=456, balance=1000)
  Event 2: MoneyDeposited(id=456, amount=500)
  Event 3: MoneyWithdrawn(id=456, amount=100)
  -- Current balance: replay events → 1000 + 500 - 100 = 1400
  -- Full audit trail preserved

Event Structure

{
  "event_id": "evt_abc123",
  "aggregate_id": "order_456",
  "aggregate_type": "Order",
  "event_type": "OrderPlaced",
  "version": 1,
  "timestamp": "2026-03-04T15:23:47Z",
  "data": {
    "customer_id": "cust_789",
    "items": [{"sku": "BOOT-001", "qty": 1, "price": 129.99}],
    "total": 129.99
  },
  "metadata": {
    "user_id": "usr_admin",
    "correlation_id": "req_xyz",
    "causation_id": "cmd_place_order"
  }
}

Event Store

CREATE TABLE events (
    event_id UUID PRIMARY KEY,
    aggregate_id UUID NOT NULL,
    aggregate_type TEXT NOT NULL,
    event_type TEXT NOT NULL,
    version INTEGER NOT NULL,
    data JSONB NOT NULL,
    metadata JSONB,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (aggregate_id, version)  -- Optimistic concurrency
);

CREATE INDEX idx_events_aggregate ON events (aggregate_id, version);

The UNIQUE (aggregate_id, version) constraint prevents concurrent writes from creating conflicting events for the same aggregate.


CQRS: Separate Read and Write

Command Side (Write)          Query Side (Read)
  ┌──────────┐                  ┌──────────────┐
  │ Commands │                  │ Read Models  │
  │ (Create, │ → Events → ──▶  │ (Projections)│
  │  Update) │                  │  Optimized   │
  └──────────┘                  │  for queries │
       ↓                        └──────────────┘
  Event Store                   Read Database
  (append-only)                 (denormalized)

Projections

Projections transform events into read-optimized views:

class OrderSummaryProjection:
    def handle(self, event):
        if event.type == "OrderPlaced":
            db.execute("""
                INSERT INTO order_summaries 
                (order_id, customer_id, total, status, placed_at)
                VALUES (%s, %s, %s, 'placed', %s)
            """, event.aggregate_id, event.data['customer_id'],
                 event.data['total'], event.timestamp)
        
        elif event.type == "OrderShipped":
            db.execute("""
                UPDATE order_summaries 
                SET status = 'shipped', shipped_at = %s
                WHERE order_id = %s
            """, event.timestamp, event.aggregate_id)

Snapshots

Replaying thousands of events per request is slow. Snapshots periodically save the current state:

def load_aggregate(aggregate_id):
    snapshot = load_latest_snapshot(aggregate_id)
    
    if snapshot:
        state = snapshot.state
        events = load_events_after(aggregate_id, snapshot.version)
    else:
        state = initial_state()
        events = load_all_events(aggregate_id)
    
    for event in events:
        state = apply_event(state, event)
    
    return state

When to Use (and When Not To)

Use Event SourcingDo Not Use Event Sourcing
Full audit trail required (finance, healthcare)Simple CRUD with no audit needs
Complex business workflowsLow-write, high-read applications
Need to replay/reprocess historical dataTeam unfamiliar with the pattern
Event-driven architecture already in placeTight deadlines, MVP phase

Anti-Patterns

Anti-PatternConsequenceFix
Events as CRUD wrappersNo business meaning, just “FieldUpdated”Model domain events, not data changes
No snapshotsReplay time grows unboundedSnapshot every N events
Mutable eventsAudit trail is meaninglessEvents are immutable, always
Ignoring eventual consistencyRead models show stale dataDesign UI for eventual consistency
Event sourcing everythingMassive complexity for simple domainsUse only where audit trail or replay justifies it

Event sourcing and CQRS are powerful patterns for the right problems. For the wrong problems, they add complexity without proportional benefit. Choose deliberately.

Jakub Dimitri Rezayev
Jakub Dimitri Rezayev
Founder & Chief Architect • Garnet Grid Consulting

Jakub holds an M.S. in Customer Intelligence & Analytics and a B.S. in Finance & Computer Science from Pace University. With deep expertise spanning D365 F&O, Azure, Power BI, and AI/ML systems, he architects enterprise solutions that bridge legacy systems and modern technology — and has led multi-million dollar ERP implementations for Fortune 500 supply chains.

View Full Profile →