CQRS stands for Command Query Responsibility Segregation. Under the fancy name is a simple idea: the model you use to change data doesn’t have to be the same as the model you use to read it. This article is a practical introduction — when CQRS earns its complexity, when it doesn’t, and how it shows up in actual code.
The problem
Most applications start with one model: a User class maps to a users table; writes update it, reads return it. Clean, simple.
At a certain scale, tensions appear:
- Write model wants normalization — one source of truth, referential integrity, fewer places to screw up
- Read model wants denormalization — one query returns everything the UI needs, no N+1 joins, aggregations precomputed
Trying to satisfy both with one schema produces awkwardness for both. Writes get complicated to maintain the denormalization; reads get complicated because they have to join everything on demand.
The CQRS idea
Split. Writes go through commands into a normalized write model. Reads come from one or more projections — denormalized views optimized for query patterns — kept in sync via events.
[Client]
│
├── command ──▶ [Write model] ──── event ──▶ [Read projection 1]
│ ▶ [Read projection 2]
│ ▶ [Read projection 3]
│
└── query ────────────────────────▶ [any projection]Write side stays clean and transactional. Read side can have multiple projections, each shaped for one UI need.
A practical example
Imagine an e-commerce admin showing “orders placed this week, grouped by customer, with totals and item counts”.
Without CQRS: complicated SQL at request time, aggregating across orders + order_items tables. As data grows, query gets slow, UI lags.
With CQRS: a projection table weekly_order_summary has (customer_id, week, order_count, item_count, total_amount). It’s updated whenever an OrderPlaced event fires. Reads are SELECT * FROM weekly_order_summary — instant.
// Write side
@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
Order order = orderRepo.save(Order.from(cmd));
eventBus.publish(new OrderPlaced(order.id(), order.customerId(), ...));
}
// Read-side projection
@EventListener
public void on(OrderPlaced event) {
summaryRepo.upsert(event.customerId(), currentWeek(),
s -> s.addOrder(event.itemCount(), event.amount()));
}
// Query (dead simple)
public List<WeeklySummary> weeklySummaries() {
return summaryRepo.findAll();
}The trade-offs
What you get:
- Read models tailored per screen — blazing-fast UIs
- Independent scaling (reads > writes usually)
- Write model stays simple (domain logic, not reporting logic)
What you pay:
- Eventual consistency between write and read
- More storage (same data in multiple shapes)
- More code to maintain projections
- “Read your own writes” becomes tricky UX
When to use CQRS
Reach for it when read patterns genuinely differ from write patterns:
- Reports, dashboards, analytics
- Search screens with many filters
- Activity feeds aggregating many sources
- Multiple clients with very different data needs
Don’t use it for:
- Basic CRUD where reads and writes share structure
- MVPs — adds complexity before you know the real patterns
- Small teams that can’t afford to maintain projections
Eventual consistency and UX
The hard part. User places an order, immediately navigates to “my orders” — projection hasn’t caught up, list is empty. Bad UX.
Solutions:
- Read from write side for “your own” data — current user’s recent orders come from the authoritative store; other users’ come from projections
- Optimistic UI — show what you just submitted without waiting for the projection
- Wait for projection — after a write, poll the read side briefly until you see the record; show a spinner
- Sync projections for a subset — update user’s own projection in the same transaction as the write; others lag
Most teams use a mix.
Simple CQRS without the ceremony
You don’t need CQRS frameworks (Axon, EventStore) to get benefits. A minimalist approach:
- Commands/queries are just service methods with clear intent
- Events are published to Kafka (or an in-process bus)
- Projections are just classes listening to events and updating denormalized tables
- No special “CQRS framework” — just discipline
This gets you 80% of the value with 20% of the complexity.
CQRS vs Event Sourcing
Often conflated. They’re independent:
- CQRS = separate read and write models
- Event Sourcing = the write model stores events, not state
You can have CQRS without event sourcing (the most common combination). You can have event sourcing without CQRS (rare but possible). They compose well together, but you can adopt one without the other.
The middle ground
Full CQRS adds substantial complexity. The middle ground I’ve found most useful:
- Keep one write model (normalized)
- Build read-specific projections only when needed (1-2 per service is typical)
- Use events to keep projections in sync
- Don’t pretend the write model is the only source of truth — read models are first-class
This gives most of the performance benefit with only modest complexity increase.
Closing note
CQRS is a tool, not an architecture. Apply it to the specific screens or subsystems where read/write shapes diverge enough to justify the split. Avoid treating it as a default — for most CRUD, one model is still simpler, cheaper, and correct.