CQRS literally stands for Command Query Responsibility Segregation, which you can think of as the "separation of responsibilities between commands and queries". But let's not get lost in fancy words — let's start with the gist.
In traditional apps we often use the same data model for both reads and writes. For example, imagine an Order entity in an online store system. We use the same object to:
- Create a new order (write).
- Get order information (read).
Everything looks fine... until your data and traffic skyrocket. Then problems show up:
- Different requirements for reads and writes. For example, when reading a user might need info from several related tables, while for writing you only need certain fields.
- Scaling pain. Read requests are usually 80–90% of the load. If you use the same model, reads and writes will be fighting over the same resources.
- Model evolution. If you change the write model, you may need to rework all the read logic, and vice versa.
Core idea behind CQRS
CQRS suggests splitting the data models and processing paths for reads (Query) and writes (Command):
- Command — used to change system state: update, create, delete data.
- Query — used to retrieve data, and they can be optimized specifically for read operations.
Analogy: think of a restaurant kitchen. Waiters take orders (commands), and the chef cooks the dishes. But to show the order to the customer, the waiter grabs the ready plate (a query). Commands and queries operate on different planes!
Main principles of CQRS
Before diving into an actual implementation, let's break down CQRS's key principles.
1. Separate data models
In CQRS you use different models for read and write operations. The command model can be simple and contain only the data needed to change state. For example, when creating an order you might only need the user ID and a list of items.
The query model, on the other hand, can be complex, aggregate data from multiple sources, and be optimized for fast reads. Sometimes it even lives in a different database!
2. Command handling mechanism
Commands in CQRS aren't just "save this object to the DB". They're separate objects that:
- Describe an action:
CreateOrderCommand,CancelOrderCommand. - Contain only the information needed to perform a specific operation.
- Are implemented via command handlers.
3. Query handling mechanism
Queries in CQRS are used exclusively for reading data. They:
- Can aggregate data from multiple sources (for example, an SQL database and a cache).
- In some cases have their own storage, like a denormalized database.
Why use CQRS
You might think CQRS is unnecessary complexity, especially for a small project. But in certain situations this pattern is basically a must:
1. Heavy read load
If your system has a huge number of read requests and data needs to be assembled from several sources, CQRS helps split the load.
2. Different requirements for reads and writes
When reads and writes have different business rules. For example, writes may need to check user permissions and run complex validations, while reads only need fast access to data.
3. Scalability
You can scale read and write components independently. For example:
- For reads you can add more DB replicas.
- For writes you can tune the master database.
4. Model evolution
Splitting read and write models reduces coupling between the two operation types. That makes your system more flexible.
Example
Let's say we have an online store. Customers can create orders via a REST API. We need to:
- Accept new orders (write operation).
- Return a list of the user's orders with shipping info (read operation).
CQRS implementation
Command model (Command)
On the write side we want to store only the necessary data. Here's an example of a command class:
// Command for creating a new order
public class CreateOrderCommand {
private final UUID userId;
private final List<OrderItem> items;
// Constructor
public CreateOrderCommand(UUID userId, List<OrderItem> items) {
this.userId = userId;
this.items = items;
}
// Getters
public UUID getUserId() {
return userId;
}
public List<OrderItem> getItems() {
return items;
}
}
// Helper class describing ordered items
public class OrderItem {
private final String productId;
private final int quantity;
public OrderItem(String productId, int quantity) {
this.productId = productId;
this.quantity = quantity;
}
// Getters
public String getProductId() {
return productId;
}
public int getQuantity() {
return quantity;
}
}
Command handler
Now let's create a handler that will process the CreateOrderCommand:
@Component
public class CreateOrderCommandHandler {
private final OrderRepository orderRepository;
// Dependency injection via constructor
public CreateOrderCommandHandler(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void handle(CreateOrderCommand command) {
// Convert the command into an Order entity
Order order = new Order(
UUID.randomUUID(), // Generate a unique order identifier
command.getUserId(),
command.getItems(),
LocalDateTime.now()
);
// Save the order to the database
orderRepository.save(order);
}
}
Query model (Query)
Now let's create a model for reading data. It will contain all the info the client needs:
// Model for reading order data
public class OrderQueryModel {
private final UUID orderId;
private final String status;
private final LocalDateTime createdDate;
// Constructor
public OrderQueryModel(UUID orderId, String status, LocalDateTime createdDate) {
this.orderId = orderId;
this.status = status;
this.createdDate = createdDate;
}
// Getters
public UUID getOrderId() {
return orderId;
}
public String getStatus() {
return status;
}
public LocalDateTime getCreatedDate() {
return createdDate;
}
}
Query handler
Now we implement a handler to get order info via an optimized query:
@Component
public class OrderQueryHandler {
private final OrderViewRepository orderViewRepository;
// Dependency injection via constructor
public OrderQueryHandler(OrderViewRepository orderViewRepository) {
this.orderViewRepository = orderViewRepository;
}
public List<OrderQueryModel> handle(UUID userId) {
// Get the list of orders from the denormalized database
return orderViewRepository.findByUserId(userId)
.stream()
.map(order -> new OrderQueryModel(order.getId(), order.getStatus(), order.getCreatedDate()))
.collect(Collectors.toList());
}
}
Benefits of splitting commands and queries
- Easier testing. You can test commands and queries separately.
- Optimization. Each side can be optimized for its own job (writing data or reading it).
- Preparation for Event Sourcing. CQRS pairs nicely with Event Sourcing, which we'll cover in upcoming lectures.
How will your CQRS adventure end?
CQRS is a powerful pattern that complements microservice systems really well. But use it wisely! Not every problem needs separate data models. Remember the golden rule: "Don't add complexity until it's necessary". In the next lectures we'll dive deeper into CQRS's relationship with Event Sourcing and see how it can blow up in unexpected places.
GO TO FULL VERSION