Skip to main content

CQRS and Event Sourcing

This guide introduces the patterns underlying Angzarr. If you're already familiar with CQRS/ES, skip to the Components.

Learning Resources

Visual Guides

Talks

Articles


Glossary

Core Concepts

TermDefinition
AggregateA cluster of domain objects treated as a single unit. Has a root entity that controls access.
Aggregate RootThe entry point entity. All external references go through the root. Identified by UUID.
CommandA request to change state. Imperative mood: CreateCustomer, ReserveFunds. May be rejected.
EventAn immutable fact that something happened. Past tense: CustomerCreated, FundsReserved. Cannot be rejected.
Event StoreAppend-only database of events. The source of truth. Events are never modified or deleted.
Event SourcingStoring state as a sequence of events. Current state is derived by replaying events.
CQRSCommand Query Responsibility Segregation. Separate models for reading and writing.

Angzarr Components

TermDefinition
DomainA bounded context representing a business capability (e.g., "player", "table", "hand"). Each domain has exactly one aggregate codebase.
ProjectorService that subscribes to events and performs side effects—building read models, writing to databases, streaming to clients.
SagaService that subscribes to events and emits commands to other aggregates. Orchestrates multi-domain workflows.
Process ManagerStateful saga that tracks workflow state using correlation IDs.
SnapshotCached aggregate state at a point in time. Optimization to avoid replaying all events.

Event Sourcing Explained

Traditional State Storage

┌─────────────────────────────────────┐
│ players table │
├─────────────────────────────────────┤
│ id: "player-123" │
│ username: "Alice" │
│ bankroll: 1500 │
│ updated_at: 2024-01-15 10:30:00 │
└─────────────────────────────────────┘

Problem: We only know current state.
- When did Alice register?
- How did she accumulate 1500?
- What was her bankroll before the last change?

Event Sourced Storage

┌─────────────────────────────────────────────────────────────┐
│ events table │
├──────┬───────────────────────────┬──────────────────────────┤
│ seq │ type │ data │
├──────┼───────────────────────────┼──────────────────────────┤
│ 0 │ PlayerRegistered │ {username: "Alice", ...} │
│ 1 │ FundsDeposited │ {amount: 1000, ...} │
│ 2 │ FundsReserved │ {amount: 500, ...} │
│ 3 │ FundsReleased │ {amount: 500, ...} │
│ 4 │ FundsDeposited │ {amount: 500, ...} │
└──────┴───────────────────────────┴──────────────────────────┘

Current state: replay events 0-4 → bankroll = 1000 - 500 + 500 + 500 = 1500

Benefits:
- Complete audit trail
- Time travel (state at any point)
- Debug by replaying
- Never lose information

State Reconstruction

def rebuild_state(events):
state = empty_state()
for event in events:
state = apply(state, event)
return state

With snapshots (optimization):

def rebuild_state(events, snapshot):
if snapshot:
state = snapshot.state
events = events[snapshot.sequence + 1:]
else:
state = empty_state()

for event in events:
state = apply(state, event)
return state

Idempotent State Reconstruction

Events can contain either deltas (changes) or absolute values (facts). Both are valid—sequence numbers make either approach idempotent.

Delta approach:

# FundsReserved { amount: 500 }  # Delta: "reserve 500 more"
def apply(state, event, sequence):
if sequence <= state.last_applied:
return # Already applied—skip
state.reserved += event.amount
state.last_applied = sequence

Absolute value approach:

# FundsReserved { amount: 500, new_reserved: 500, new_available: 500 }
def apply(state, event):
state.reserved = event.new_reserved # Idempotent by design
state.available = event.new_available

Trade-offs:

ApproachProsCons
DeltasSmaller events, clearer intentRequires sequence tracking
Absolute valuesIdempotent by design, self-describingLarger events, computed at write time

⍼ Angzarr's sequence numbers ensure events are never applied twice, making deltas safe. Choose based on your domain's needs.


CQRS Explained

CQRS separates the write model (commands/events) from read models (projections):

Why separate?

  • Write model optimized for consistency (validate, sequence events)
  • Read models optimized for specific query patterns
  • Scale reads independently from writes
  • Each projection can use the best storage for its purpose

Command Flow in Angzarr


Example: Player Bankroll

Commands (Requests)

RegisterPlayer { username: "Alice", initial_bankroll: 1000 }
DepositFunds { amount: 500 }
ReserveFunds { amount: 200, table_id: "table-1" }

Events (Facts)

PlayerRegistered { username: "Alice", initial_bankroll: 1000 }
FundsDeposited { amount: 500, new_bankroll: 1500 }
FundsReserved { amount: 200, new_available: 1300, new_reserved: 200 }

State (Derived)

PlayerState {
username: "Alice",
bankroll: 1500, # Total funds
reserved: 200, # Locked for tables
available: 1300, # bankroll - reserved
}

Validation Rules

CommandPreconditionValidation
RegisterPlayerPlayer must not existUsername required
DepositFundsPlayer must existAmount > 0
ReserveFundsPlayer must existAmount > 0, available >= amount

Event Design Philosophy

Internal vs External Events

Some frameworks distinguish between "internal" events (implementation details) and "external" events (public API contracts). ⍼ Angzarr rejects this classification at the event level.

Events are immutable data. Classification as "internal" or "external" is a usage decision, not an intrinsic property:

ApproachProblem
Mark events as internal/externalClassification is frozen. Can't later decide to expose an "internal" event without schema changes.
Separate internal/external event storesDuplication, synchronization complexity, defeats event sourcing benefits.

⍼ Angzarr's approach: All events are stored as data. What you do with them determines visibility:

  • Internal use: Sagas and projectors within your system consume events directly
  • External publication: A projector at the boundary transforms and publishes events to external systems
Internal:  Aggregate → Event Store → Saga → Aggregate
External: Aggregate → Event Store → Projector → External API/CloudEvents/Webhook

The projector is the boundary. It can:

  • Filter which events to publish externally
  • Transform event schemas for external consumers
  • Add external envelope formats (CloudEvents, etc.)
  • Version external contracts independently from internal events

This preserves immutability while giving you full control over external contracts.


Next Steps

  • Aggregates — Command handling and event emission
  • Sagas — Cross-domain workflows
  • Why Poker — Why the poker domain exercises every pattern