CQRS is often pitched as a way to optimize database performance, but its real power is in its ability to decouple how you query data from how you change it, which fundamentally changes how you reason about your application.
Let’s see this in action. Imagine a simple Order service.
Write Side:
When a customer places an order, we’re focused on consistency and ensuring the order is valid. The write model might look like this:
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
public OrderService(OrderRepository orderRepository, PaymentGateway paymentGateway) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
}
public Order placeOrder(PlaceOrderCommand command) {
// Business logic: validate items, check stock, etc.
if (!command.getItems().isEmpty()) {
Order order = new Order(command.getCustomerId(), command.getItems());
orderRepository.save(order);
// External integration
paymentGateway.processPayment(order.getId(), command.getPaymentDetails());
return order;
}
throw new IllegalArgumentException("Order must contain items.");
}
}
// Domain entity
public class Order {
private UUID id;
private UUID customerId;
private List<OrderItem> items;
// Constructor and getters...
}
Here, OrderRepository is likely an ORM that maps Order to a relational table. We’re concerned with ACID properties.
Read Side:
Now, consider a customer wanting to view their order history. We don’t need the full Order object with all its transactional details. We want a denormalized, optimized view.
// Read model DTO
public class OrderSummary {
private UUID id;
private LocalDate orderDate;
private BigDecimal totalAmount;
private String status;
// Getters...
}
public class OrderQueryService {
private final OrderSummaryRepository orderSummaryRepository;
public OrderQueryService(OrderSummaryRepository orderSummaryRepository) {
this.orderSummaryRepository = orderSummaryRepository;
}
public List<OrderSummary> getOrdersForCustomer(UUID customerId) {
return orderSummaryRepository.findByCustomerId(customerId);
}
public OrderSummary getOrderById(UUID orderId) {
return orderSummaryRepository.findById(orderId);
}
}
OrderSummaryRepository might query a NoSQL document store or a materialized view, optimized for fast retrieval.
The core problem CQRS solves is that the data structures and access patterns for writing data (commands) are often fundamentally different from those for reading data (queries). Forcing them into a single model, like a typical CRUD repository, leads to impedance mismatch. You end up with a write model that’s too complex for reads, or a read model that’s inefficient for writes, or both.
CQRS addresses this by creating two distinct models:
- Command Side: Focused on receiving commands, enforcing business rules, and persisting the state. This model is often state-centric and emphasizes consistency.
- Query Side: Focused on providing data for display and reporting. This model is often optimized for specific read scenarios, denormalized, and can relax consistency guarantees (e.g., eventual consistency).
The "magic" happens in how these two sides stay in sync. When an Order is saved on the write side, an event (e.g., OrderPlacedEvent) is typically published. A separate process, often called a "projection" or "event handler," subscribes to these events and updates the read model.
// Event handler for the read side
public class OrderEventHandler {
private final OrderSummaryRepository orderSummaryRepository;
public OrderEventHandler(OrderSummaryRepository orderSummaryRepository) {
this.orderSummaryRepository = orderSummaryRepository;
}
public void handle(OrderPlacedEvent event) {
OrderSummary summary = new OrderSummary(
event.getOrderId(),
event.getOrderDate(),
calculateTotal(event.getItems()),
"PENDING" // Initial status
);
orderSummaryRepository.save(summary);
}
public void handle(OrderStatusChangedEvent event) {
OrderSummary summary = orderSummaryRepository.findById(event.getOrderId());
if (summary != null) {
summary.setStatus(event.getNewStatus());
orderSummaryRepository.save(summary); // Update existing
}
}
// Helper method to calculate total...
}
This event-driven approach allows the read model to be built and maintained independently. You can have multiple read models, each optimized for a different query. For instance, one for customer order history, another for an internal dashboard showing recent orders, and yet another for an analytics system.
The "secret sauce" often lies in the database choice for each side. The write side might use a transactional relational database like PostgreSQL or SQL Server for strong consistency. The read side, however, could leverage a document database like MongoDB for flexible schemas and fast document retrieval, a search engine like Elasticsearch for complex filtering and aggregations, or even a specialized time-series database for analytical workloads. The key is that the choice is driven purely by the read requirements, not by the write requirements.
When you see CQRS implemented, pay close attention to the event bus or message queue. This is the backbone that connects the write-side events to the read-side projections. Technologies like Kafka, RabbitMQ, or AWS SQS/SNS are common here. The latency introduced by the eventing mechanism is a critical factor in how "eventually consistent" your read models will be.
The next logical step after mastering CQRS is to explore how it pairs with Event Sourcing, where the write side doesn’t just store the current state but a sequence of all events that led to that state.