Table of contents
- Reads and Writes Have Different Requirements
- From CQS to CQRS
- The Structure of CQRS
- Simple CQRS vs Event Sourcing CQRS
- Event-Driven Architecture
- CQRS + Event Sourcing
- When to Adopt CQRS
- Summary
Reads and Writes Have Different Requirements
If you measure the ratio of read requests to write requests in a typical web application, reads are overwhelmingly dominant. Read requests like product listing, search results, and detail page views account for 80-90% of the total, while write requests like order creation, review posting, and cart modifications are around 10-20%.
Yet in traditional architectures, reads and writes share the same model. They pass through the same entities, the same repositories, and the same service layer. Here’s where problems surface.
A data model optimized for writes is inefficient for reads. Normalized table structures ensure data integrity, but rendering complex query screens requires JOINing multiple tables. Conversely, denormalized structures optimized for reads make it hard to maintain data consistency during writes. Trying to satisfy both requirements with a single model means both sides compromise.
CQRS (Command Query Responsibility Segregation) addresses this problem head-on: separate reads and writes into distinct models.
From CQS to CQRS
To understand CQRS, you first need to know CQS (Command Query Separation). This principle, proposed by Bertrand Meyer, is simple: Every method should either be a Command that changes state or a Query that returns state — not both. A single method shouldn’t change state while simultaneously returning a value.
// A design following CQS
class ShoppingCart {
private val items = mutableListOf<CartItem>()
// Command — changes state but returns nothing
fun addItem(item: CartItem) {
items.add(item)
}
// Query — returns state but changes nothing
fun totalPrice(): Money {
return items.fold(Money.ZERO) { acc, item ->
acc + item.price * item.quantity
}
}
// CQS violation — changes state while returning a value
// fun removeAndReturnLast(): CartItem { ... }
}
CQS is a method-level principle. Greg Young extended this idea to the architectural level. Instead of splitting methods into Command and Query, the proposal was to separate the models themselves into Command models and Query models — that’s CQRS.
The Structure of CQRS
flowchart LR
Client["Client"]
subgraph COMMAND["Command Path"]
direction TB
CC["Command Controller"]
CH["Command Handler"]
WM["Write Model"]
WDB[("Write DB")]
CC --> CH
CH --> WM
WM --> WDB
end
subgraph QUERY["Query Path"]
direction TB
QC["Query Controller"]
QH["Query Handler"]
RM["Read Model"]
RDB[("Read DB")]
QC --> QH
QH --> RM
RM --> RDB
end
Client -->|"POST, PUT, DELETE"| CC
Client -->|"GET"| QC
WDB -.->|"Sync"| RDB
style COMMAND fill:#ff6b6b,color:#fff
style QUERY fill:#4ecdc4,color:#fff
The Command path and Query path are completely separated. Write requests go through the Command Handler to the Write Model, and read requests go through the Query Handler to the Read Model.
The Command Side
A Command is an object that carries the intent to change the system’s state. Names are written as imperatives, not past tense.
// Command — "Do this"
data class PlaceOrderCommand(
val customerId: String,
val items: List<OrderItemDto>,
val shippingAddress: Address
)
data class CancelOrderCommand(
val orderId: String,
val reason: String
)
A Command Handler receives a single Command and processes it. Its role spans validation, business rule application, and state persistence.
@Service
class PlaceOrderCommandHandler(
private val orderRepository: OrderRepository,
private val inventoryService: InventoryService
) {
@Transactional
fun handle(command: PlaceOrderCommand) {
// 1. Check stock
command.items.forEach { item ->
inventoryService.reserve(item.productId, item.quantity)
}
// 2. Create order
val order = Order.create(
customerId = CustomerId(command.customerId),
items = command.items.map { it.toDomain() },
shippingAddress = command.shippingAddress
)
// 3. Save
orderRepository.save(order)
}
}
In pure CQRS, Command Handlers don’t return values. Of course, in practice it’s common to return at least the created resource’s ID. Finding a balance with practicality is better than rigidly following the principle.
The Query Side
The Query model is a read-only model pre-shaped to match what the screen needs. There’s no need to worry about normalization. Denormalize to eliminate JOINs and query performance improves dramatically.
// Query model — holds data exactly as the screen needs it
data class OrderSummaryView(
val orderId: String,
val customerName: String,
val itemCount: Int,
val totalAmount: Long,
val status: String,
val orderedAt: LocalDateTime
)
// Query Handler — queries directly from read-only storage
@Service
class OrderQueryHandler(
private val orderReadRepository: OrderReadRepository
) {
fun getOrderSummaries(customerId: String): List<OrderSummaryView> {
return orderReadRepository.findSummariesByCustomerId(customerId)
}
fun getOrderDetail(orderId: String): OrderDetailView {
return orderReadRepository.findDetailById(orderId)
?: throw OrderNotFoundException(orderId)
}
}
The Read Model is not a domain entity. It’s closer to a DTO (Data Transfer Object) shaped to what the screen demands. There’s no place for domain rules here — focus exclusively on query performance.
Simple CQRS vs Event Sourcing CQRS
There’s a point of confusion when discussing CQRS. CQRS and Event Sourcing are separate concepts, but they’re mentioned together so often that they seem like a package deal.
Simple CQRS shares the same database and only separates Command and Query at the code level. It has the lowest adoption cost, and for most projects, this alone is sufficient.
// Simple CQRS — same DB, different models
@Service
class OrderCommandService(
private val orderRepository: OrderRepository // JPA repository
) {
@Transactional
fun placeOrder(command: PlaceOrderCommand) {
val order = Order.create(...)
orderRepository.save(order)
}
}
@Service
class OrderQueryService(
private val jdbcTemplate: JdbcTemplate // Direct SQL queries
) {
fun getOrderList(customerId: String): List<OrderSummaryView> {
return jdbcTemplate.query(
"""
SELECT o.id, c.name, COUNT(oi.id), SUM(oi.price * oi.quantity), o.status, o.created_at
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
WHERE o.customer_id = ?
GROUP BY o.id, c.name, o.status, o.created_at
""",
OrderSummaryViewMapper(),
customerId
)
}
}
The write side uses JPA to work with domain models, and the read side fires optimized queries directly with JdbcTemplate. One DB, but the code paths are separated.
Event Sourcing CQRS goes one step further. Instead of storing current state, the write side stores a sequence of state change events. The read side subscribes to these events and builds its own read-optimized views. Event sourcing is powerful but brings significant complexity. Careful evaluation is needed before adoption, and applying event sourcing to problems that simple CQRS can solve is excessive engineering.
Event-Driven Architecture
Another architecture that frequently appears alongside CQRS is Event-Driven Architecture (EDA). Event-driven is independent from CQRS. You can use event-driven without CQRS, and CQRS without event-driven. But combining them creates synergy, which is why they’re discussed together.
The core of event-driven architecture is simple: Communication between components is performed through events rather than direct calls. Instead of the order service directly calling the payment service, it publishes an “order was created” event, and the payment service receives that event and processes it.
sequenceDiagram
participant Client as Client
participant Order as Order Service
participant Broker as Event Broker
participant Payment as Payment Service
participant Inventory as Inventory Service
participant Notification as Notification Service
Client->>Order: Place order
Order->>Order: Create order
Order->>Broker: Publish OrderCreated event
par Parallel processing
Broker->>Payment: Receive OrderCreated
Payment->>Payment: Process payment
Payment->>Broker: Publish PaymentCompleted
and
Broker->>Inventory: Receive OrderCreated
Inventory->>Inventory: Deduct stock
and
Broker->>Notification: Receive OrderCreated
Notification->>Notification: Send order confirmation email
end
Broker->>Order: Receive PaymentCompleted
Order->>Order: Update order status → Payment completed
The key observation in this diagram is that the order service doesn’t know the payment, inventory, or notification services exist. The order service simply announces the fact that “an order was created” via an event. Who consumes that event is outside its concern.
The Event Broker
The event broker mediates between event producers and consumers. The most prominent example is Apache Kafka. Kafka stores events in topics and allows consumer groups to read events at their own pace.
Here’s what publishing and consuming events looks like in Spring.
First, define the event class.
data class OrderCreatedEvent(
val orderId: String,
val customerId: String,
val items: List<OrderItemDto>,
val totalAmount: Long,
val occurredAt: Instant = Instant.now()
)
The publishing side.
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: ApplicationEventPublisher
) {
@Transactional
fun placeOrder(command: PlaceOrderCommand) {
val order = Order.create(
customerId = CustomerId(command.customerId),
items = command.items.map { it.toDomain() }
)
orderRepository.save(order)
eventPublisher.publishEvent(
OrderCreatedEvent(
orderId = order.id.value,
customerId = command.customerId,
items = command.items,
totalAmount = order.totalAmount().amount
)
)
}
}
The consuming side.
@Component
class PaymentEventHandler {
@EventListener
fun onOrderCreated(event: OrderCreatedEvent) {
// Payment processing logic
processPayment(event.orderId, event.totalAmount)
}
}
@Component
class InventoryEventHandler {
@EventListener
fun onOrderCreated(event: OrderCreatedEvent) {
// Stock deduction logic
event.items.forEach { item ->
decreaseStock(item.productId, item.quantity)
}
}
}
The example above uses Spring’s ApplicationEventPublisher for in-process events. When services are separated into different processes, an external broker like Kafka or RabbitMQ is needed.
Benefits of Loose Coupling
The greatest advantage of event-driven is loose coupling.
Adding new features is easy. Suppose you need to add point accrual when an order is created. In the synchronous approach, you’d add a PointService dependency to OrderService and modify the code. With events, just create a new PointEventHandler that subscribes to OrderCreatedEvent. Not a single line of existing code changes.
Fault isolation is possible. Even if the notification service goes down, order processing continues unaffected. Events accumulate in the broker and are processed once the notification service recovers.
Independent scaling is possible. If read traffic is heavy, scale out only the Query service. If write load is high, scale only the Command service.
The Difficulty of Debugging
There are downsides too. The biggest drawback of event-based systems is that flow tracing is difficult.
In the synchronous approach, following the call stack OrderService -> PaymentService -> InventoryService reveals the entire flow. You can step through line by line in a debugger, and the method call order appears in logs.
In the event approach, you can’t immediately tell from the code which handlers execute after an event is published. The publishing code and consuming code are physically separated. When failures occur, you must trace where the event was published, who consumed it, and where it failed — requiring a distributed tracing system.
Event ordering is also challenging. Due to network latency, rebalancing, and reprocessing, events may be consumed in a different order than they were published. Designing for idempotency is essential.
CQRS + Event Sourcing
Event Sourcing stores the complete history of state change events instead of current state. Instead of storing a bank account balance, you store the list of deposit/withdrawal events and calculate the balance by replaying events from the beginning when needed.
Combining CQRS with Event Sourcing creates a powerful structure. The Command side stores events, and those events propagate to the Read side to update read-optimized views.
// Event Sourcing-based Aggregate
class OrderAggregate {
lateinit var id: OrderId
var status: OrderStatus = OrderStatus.DRAFT
private val items = mutableListOf<OrderItem>()
private val pendingEvents = mutableListOf<DomainEvent>()
fun place(customerId: CustomerId, items: List<OrderItem>) {
// Instead of directly changing state, create an event
applyEvent(OrderPlacedEvent(
orderId = OrderId.generate(),
customerId = customerId,
items = items
))
}
fun cancel(reason: String) {
require(status == OrderStatus.PLACED) {
"Can only cancel in PLACED state"
}
applyEvent(OrderCancelledEvent(id, reason))
}
private fun applyEvent(event: DomainEvent) {
handleEvent(event) // Apply state
pendingEvents.add(event) // Accumulate event
}
private fun handleEvent(event: DomainEvent) {
when (event) {
is OrderPlacedEvent -> {
id = event.orderId
status = OrderStatus.PLACED
items.addAll(event.items)
}
is OrderCancelledEvent -> {
status = OrderStatus.CANCELLED
}
}
}
fun pendingEvents(): List<DomainEvent> = pendingEvents.toList()
}
Event Sourcing provides a complete audit trail and the ability to restore state at any point in time — powerful advantages. But the complexity cost is significant. Event schema migrations are tricky, snapshot strategies are needed when millions of events accumulate, and dealing with eventual consistency is not intuitive.
When to Adopt CQRS
Distinguishing when CQRS is appropriate from when it isn’t is critical.
Appropriate for adoption:
- When the ratio between reads and writes is large and each has different performance requirements
- When the read model needs to aggregate data from multiple Aggregates, leading to repeated complex JOINs
- When scaling demands for reads and writes are independent — for example, when only read traffic surges
- When integrating with other Bounded Contexts based on domain events
Should not adopt:
- Simple CRUD applications. If read and write models are nearly identical, separating them just increases code
- When strong consistency is absolutely required. In CQRS, the Read model can lag behind the Write model
- When the team has no operational experience with event-based systems. Operations are harder than development with this architecture
- In early project stages when the domain model hasn’t stabilized yet
Greg Young himself has said: CQRS should not be applied to the entire system, but selectively to Bounded Contexts that need it. Using CQRS for complex domains like order processing while keeping traditional CRUD for simple domains like user profiles is the realistic approach.
Summary
CQRS starts from the reality that reads and writes have different requirements. Event-driven architecture makes coupling between components loose, enabling easier change and extension. Combining the two creates a powerful but complex system, and whether the team can handle that complexity is the key criterion for adoption.
In the next part, we’ll cover the final topic of the series: the modular monolith. It’s a middle ground you can try before moving to microservices.




Loading comments...