CQRS — Deep Dive

Level: Intermediate
Pre-reading: 04 · Event-Driven Architecture · 03.03 · Data Management Patterns


What is CQRS?

CQRS (Command Query Responsibility Segregation) separates the write model from the read model. Each is optimized for its purpose.

graph LR
    C[Client]
    C -->|Commands| WS[Write Side]
    C -->|Queries| RS[Read Side]
    WS --> WDB[(Write DB)]
    WS -->|Events| EB[Event Bus]
    EB --> P[Projector]
    P --> RDB[(Read DB)]
    RS --> RDB

Why CQRS?

Challenge CQRS Solution
Read/write optimization conflict Separate models; optimize each
Complex queries across aggregates Denormalized read models
Different scaling needs Scale read side independently
Multiple view requirements Multiple projections

When CQRS Makes Sense

Good Fit Poor Fit
High read/write ratio (10:1 or more) Simple CRUD
Complex queries Single model suffices
Different scaling needs Small scale
Multiple read representations Team unfamiliar with pattern

CQRS Components

Command Side (Write Model)

Handles state changes. Focuses on business rules and invariants.

// Command
public record PlaceOrderCommand(
    String customerId,
    List<OrderLineDto> items
) {}

// Command Handler
@Service
public class OrderCommandHandler {
    private final OrderRepository repository;
    private final EventPublisher publisher;

    @Transactional
    public OrderId handle(PlaceOrderCommand cmd) {
        // Business logic
        Order order = Order.create(cmd.customerId(), cmd.items());
        order.validate();  // Invariants

        // Persist
        repository.save(order);

        // Publish event (or via Outbox)
        publisher.publish(new OrderPlaced(order));

        return order.getId();
    }
}

Query Side (Read Model)

Handles queries. Optimized for specific read patterns.

// Query
public record GetOrderSummaryQuery(String orderId) {}

// Query Handler
@Service
public class OrderQueryHandler {
    private final OrderReadRepository readRepository;

    public OrderSummaryDto handle(GetOrderSummaryQuery query) {
        return readRepository.findSummaryById(query.orderId())
            .orElseThrow(() -> new OrderNotFoundException(query.orderId()));
    }
}

Event Projector

Consumes events and updates read models.

@Component
public class OrderProjector {
    private final OrderReadRepository readRepository;

    @EventListener
    public void on(OrderPlaced event) {
        OrderReadModel readModel = new OrderReadModel(
            event.orderId(),
            event.customerId(),
            event.totalAmount(),
            "PLACED",
            event.occurredAt()
        );
        readRepository.save(readModel);
    }

    @EventListener
    public void on(OrderShipped event) {
        OrderReadModel model = readRepository.findById(event.orderId());
        model.setStatus("SHIPPED");
        model.setShippedAt(event.occurredAt());
        readRepository.save(model);
    }
}

Read Model Strategies

Single Denormalized Table

Flatten related data into one table for fast queries.

-- Write side: normalized
orders (id, customer_id, status, created_at)
order_lines (id, order_id, product_id, quantity, price)
customers (id, name, email)
products (id, name, sku)

-- Read side: denormalized
order_summaries (
    order_id,
    customer_name,
    customer_email,
    status,
    total_amount,
    item_count,
    created_at,
    shipped_at
)

Multiple Projections

Different read models for different query patterns.

graph LR
    E[Events] --> P1[Order Summary Projector]
    E --> P2[Order Search Projector]
    E --> P3[Dashboard Projector]
    P1 --> DB1[(PostgreSQL)]
    P2 --> DB2[(Elasticsearch)]
    P3 --> DB3[(Redis)]
Projection Storage Use Case
Order Summary PostgreSQL Order detail page
Order Search Elasticsearch Full-text search
Dashboard Redis Real-time metrics
Reporting Data warehouse Analytics

Materialized Views

Database-level projections. Simpler but less flexible.

CREATE MATERIALIZED VIEW order_dashboard AS
SELECT 
    date_trunc('day', created_at) as day,
    status,
    COUNT(*) as order_count,
    SUM(total_amount) as revenue
FROM orders
GROUP BY 1, 2;

-- Refresh periodically or via trigger
REFRESH MATERIALIZED VIEW order_dashboard;

Eventual Consistency

Read models are eventually consistent with the write model. There's a lag between write and read.

sequenceDiagram
    participant C as Client
    participant W as Write Side
    participant E as Event Bus
    participant P as Projector
    participant R as Read Side

    C->>W: PlaceOrder command
    W->>W: Save order
    W->>E: Publish OrderPlaced
    W->>C: OrderId (immediate)
    E->>P: OrderPlaced event
    P->>R: Update read model
    Note over C,R: Consistency window
    C->>R: GetOrder query
    R->>C: Order data (eventually)

Handling Consistency Lag

Strategy Implementation
Optimistic UI Show expected state; reconcile later
Read-your-writes Query write model for just-created data
Polling Client polls until read model updated
WebSocket Push notification when projection ready
Timestamps Client sends last-known version; wait if stale

CQRS Implementation Patterns

Same Database, Different Tables

Simplest approach. Events sync tables in same database.

graph LR
    subgraph Same Database
        WT[orders table]
        RT[order_summaries table]
    end
    W[Write Side] --> WT
    WT -->|Trigger/Event| RT
    RT --> R[Read Side]

Separate Databases

Full separation. Different databases optimized per side.

graph LR
    W[Write Side] --> WDB[(PostgreSQL)]
    WDB -->|Events| K[Kafka]
    K --> P[Projector]
    P --> RDB[(Elasticsearch)]
    R[Read Side] --> RDB

CQRS + Event Sourcing

Events are the write model's source of truth. Read models are projections of events.

graph LR
    W[Write Side] --> ES[(Event Store)]
    ES -->|Replay| P1[Projection 1]
    ES -->|Replay| P2[Projection 2]
    P1 --> R1[(Read DB 1)]
    P2 --> R2[(Read DB 2)]

Rebuilding Projections

Read models can be rebuilt from events. Useful for:

  • Fixing bugs in projection logic
  • Adding new projections
  • Recovery from corruption
sequenceDiagram
    participant O as Ops
    participant P as Projector
    participant ES as Event Store
    participant R as Read DB

    O->>R: DROP read model
    O->>P: Trigger rebuild
    P->>ES: Fetch all events
    ES->>P: Events stream
    P->>R: Rebuild read model
    P->>O: Rebuild complete

Rebuild Considerations

Concern Mitigation
Long rebuild time Parallel processing; checkpointing
Missing events Event store must retain all events
Schema changes Event versioning; upcasting
Downtime Blue-green read models

CQRS Without Events

CQRS doesn't require event sourcing. You can sync read models via:

Method Description
Database triggers Trigger updates read table on write
Scheduled sync Periodic job syncs data
Change Data Capture Debezium captures changes
Application-level Code updates both models
// Application-level sync (simpler, but coupled)
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
    Order order = Order.create(cmd);
    writeRepository.save(order);

    // Update read model in same transaction
    OrderSummary summary = OrderSummary.from(order);
    readRepository.save(summary);
}

CQRS Trade-offs

Benefit Cost
Optimized read performance Two models to maintain
Independent scaling Eventual consistency
Flexible query patterns Increased complexity
Right storage per use case More infrastructure
Audit-friendly Steeper learning curve

CQRS in Spring Boot

// Command side
@RestController
@RequestMapping("/orders")
public class OrderCommandController {
    private final OrderCommandHandler handler;

    @PostMapping
    public ResponseEntity<OrderIdResponse> placeOrder(@RequestBody PlaceOrderRequest request) {
        OrderId id = handler.handle(request.toCommand());
        return ResponseEntity.created(URI.create("/orders/" + id)).body(new OrderIdResponse(id));
    }
}

// Query side
@RestController
@RequestMapping("/orders")
public class OrderQueryController {
    private final OrderQueryHandler handler;

    @GetMapping("/{id}")
    public OrderSummaryDto getOrder(@PathVariable String id) {
        return handler.handle(new GetOrderSummaryQuery(id));
    }

    @GetMapping("/search")
    public List<OrderSummaryDto> search(@RequestParam String q) {
        return handler.handle(new SearchOrdersQuery(q));
    }
}

When should you use CQRS?

Use CQRS when: (1) Read/write ratio is high (10:1+). (2) You need complex queries across multiple aggregates. (3) Read and write have different scaling needs. (4) You need multiple read representations (list, search, dashboard). Avoid CQRS for simple CRUD or when the team is unfamiliar with eventual consistency.

How do you handle eventual consistency in CQRS?

(1) Optimistic UI: Show expected state immediately; reconcile if different. (2) Polling: Client polls read side until updated. (3) WebSocket: Push notification when projection complete. (4) Read-your-writes: For just-created entities, query write side directly. (5) Accept it: Many use cases tolerate slight delay.

Do you need event sourcing to use CQRS?

No. CQRS separates read and write models — that's independent of how you persist writes. You can use CQRS with a traditional relational database and sync read models via triggers, CDC, or application code. Event sourcing is complementary but optional.