Skip to main content

ADR-003: CQRS Pattern Implementation

StatusDateDeciders
Accepted2024-02-01Core Team

Context​

Event sourcing naturally leads to a separation between how we write data (append events) and how we read data (query projections). This separation aligns with the Command Query Responsibility Segregation (CQRS) pattern.

Key challenges we face:

  1. Query Performance: Replaying events for every query is expensive
  2. Query Flexibility: Different read use cases need different data shapes
  3. Scalability: Read and write workloads have different characteristics
  4. Consistency: Need to balance consistency with performance

We need to decide how strictly to implement CQRS and what abstractions to provide.

Decision​

We will implement CQRS with clear separation between command and query sides:

Command Side (Write Model)​

┌─────────────────────────────────────────────────────────────┐
│ COMMAND SIDE │
│ │
│ Command ──▶ Command Bus ──▶ Handler ──▶ Aggregate ──▶ Events
│ │ │
│ ▼ │
│ Middleware │
│ (Validation, Auth) │
└─────────────────────────────────────────────────────────────┘

Characteristics:

  • Optimized for consistency
  • Domain logic in aggregates
  • Commands validated before execution
  • Events stored atomically

Query Side (Read Model)​

┌─────────────────────────────────────────────────────────────┐
│ QUERY SIDE │
│ │
│ Events ──▶ Projection Engine ──▶ Read Models ──▶ Queries │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Inline │ │ Async │ │ Live │ │
│ │ (Immediate) │ │ (Background)│ │ (Real-time) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────┘

Characteristics:

  • Optimized for query performance
  • Multiple views of same data
  • Denormalized for specific use cases
  • Eventually consistent (configurable)

Implementation​

// Command Side
type CreateOrderCommand struct {
CustomerID string
Items []OrderItem
}

func (h *OrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (CommandResult, error) {
order := NewOrder(uuid.New().String())
if err := order.Create(cmd.CustomerID, cmd.Items); err != nil {
return CommandResult{}, err
}
return h.store.SaveAggregate(ctx, order)
}

// Query Side
type OrderSummaryView struct {
OrderID string
CustomerID string
TotalAmount float64
Status string
}

func (p *OrderSummaryProjection) Apply(ctx context.Context, event StoredEvent) error {
switch event.Type {
case "OrderCreated":
// Create read model
case "OrderShipped":
// Update read model
}
return nil
}

// Query
orders, _ := orderRepo.Query(ctx, NewQuery().
Where("CustomerID", Eq, customerId).
Where("Status", Eq, "Pending"))

Consequences​

Positive​

  1. Performance Optimization: Each side optimized for its purpose
  2. Scalability: Can scale read and write independently
  3. Flexibility: Multiple read models for different use cases
  4. Simplicity: Clear boundaries between concerns
  5. Testing: Can test command and query sides independently

Negative​

  1. Eventual Consistency: Queries may return stale data
  2. Complexity: More moving parts than simple CRUD
  3. Duplication: Data exists in events and projections
  4. Synchronization: Must keep projections up to date

Neutral​

  1. Learning Curve: Teams need to understand CQRS concepts
  2. Tooling: Need projection management tools

Design Decisions​

1. Commands Return Results, Not Entities​

Commands return a result indicating success/failure, not the updated entity:

type CommandResult struct {
AggregateID string
Version int64
Error error
}

Rationale: Returning entities would bypass the query side and couple commands to specific read models.

2. Queries Use Dedicated Repositories​

Queries go through read model repositories, not the event store:

// Good: Query read model
orders, _ := orderRepo.Query(ctx, query)

// Avoid: Loading aggregate for queries
order := NewOrder(id)
store.LoadAggregate(ctx, order) // Only for commands

3. Projection Consistency Levels​

We support three consistency levels via projection types:

TypeConsistencyUse Case
InlineStrongFinancial transactions
AsyncEventualReports, analytics
LiveReal-timeDashboards, notifications

Alternatives Considered​

Alternative 1: No CQRS (Event Sourcing Only)​

Description: Use event sourcing without separate read models.

Rejected because:

  • Query performance degrades with event count
  • Forces single data model for all use cases
  • Doesn't leverage event sourcing benefits fully

Alternative 2: Full CQRS with Separate Databases​

Description: Completely separate databases for read and write.

Rejected because:

  • Adds infrastructure complexity
  • Overkill for many use cases
  • Can be added later if needed

Alternative 3: Synchronous Projections Only​

Description: All projections updated in same transaction.

Rejected because:

  • Reduces write throughput
  • Limits scalability
  • Some projections don't need strong consistency

References​