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.