CQRS, When It Earns Its Keep
CQRS gets applied too eagerly. Teams read a blog post, see the diagram with two databases and an event bus, and decide their subscription management feature needs it. Six months later they're debugging read-model inconsistencies in production and wondering why they didn't just use a SQL join.
CQRS earns its keep in specific situations. Outside those situations, it adds operational complexity for no gain. Let's be precise about when each form is worth it.
The Core Idea
Command Query Responsibility Segregation separates operations that change state (commands) from operations that read state (queries). The insight is that these two operations often have different requirements:
- Commands need strong consistency and business rule enforcement.
- Queries need performance and flexibility in shaping the response.
Forcing both through the same aggregate model creates friction: aggregates optimized for invariant enforcement are terrible for building complex read views.
The Spectrum: Three Levels
There's a spectrum from "almost no overhead" to "full infrastructure commitment."
Most teams that think they need Level 3 actually need Level 2. And many teams that think they need Level 2 are fine with Level 1.
Level 1: Separate Query Methods (Almost Free)
Don't route queries through the aggregate. Bypass the domain model for reads and query the database directly.
// Write side: full aggregate, business rules, invariants
public class OrderApplicationService {
public void confirmOrder(ConfirmOrderCommand cmd) {
Order order = orderRepository.findById(cmd.orderId()).orElseThrow();
order.confirm();
orderRepository.save(order);
eventPublisher.publishAll(order.pullDomainEvents());
}
}
// Read side: SQL query shaped for the response, no aggregate loading
@Repository
public class OrderQueryService {
private final JdbcTemplate jdbc;
public OrderSummaryView getOrderSummary(String orderId) {
return jdbc.queryForObject("""
SELECT o.id, o.status, o.confirmed_at,
c.name AS customer_name,
SUM(li.quantity * li.unit_price) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN line_items li ON li.order_id = o.id
WHERE o.id = ?
GROUP BY o.id, o.status, o.confirmed_at, c.name
""",
OrderSummaryViewMapper::mapRow,
orderId
);
}
}Same database. No event buses. No eventual consistency. Just two different access patterns: one through the aggregate (writes), one directly to the database (reads). This solves "my repository returns domain objects that are hard to project into API responses" for nearly zero cost.
Level 2: Dedicated Read Models
When queries need data that crosses aggregate boundaries and joins become painful, materialise a dedicated read model. This is a table or document shaped for the query, updated by reacting to domain events.
// Read model: a denormalised table shaped for the dashboard query
interface OrderDashboardRow {
orderId: string;
customerName: string;
status: string;
itemCount: number;
totalAmount: number;
currency: string;
confirmedAt: string | null;
lastUpdated: string;
}
// Updater: listens to domain events, maintains the read model
class OrderDashboardProjection {
constructor(private readonly readDb: ReadModelDatabase) {}
async on(event: OrderConfirmed): Promise<void> {
await this.readDb.upsert('order_dashboard', {
order_id: event.orderId,
customer_name: event.customerName, // denormalized at event time
status: 'CONFIRMED',
item_count: event.lineItems.length,
total_amount: event.totalAmount.amount,
currency: event.totalAmount.currency,
confirmed_at: event.confirmedAt,
last_updated: new Date().toISOString(),
});
}
async on(event: OrderShipped): Promise<void> {
await this.readDb.update('order_dashboard',
{ status: 'SHIPPED', last_updated: new Date().toISOString() },
{ order_id: event.orderId }
);
}
}Still the same database. But the order_dashboard table is owned by the query side and is never used by the write side. It's eventually consistent — there's a short lag between confirming an order and seeing it in the dashboard.
Level 3: Separate Read Store
Use a different database for reads when: (a) query volumes dwarf write volumes and you need independent scaling, or (b) the query technology differs (e.g., Elasticsearch for full-text, Redis for real-time dashboards).
The tradeoffs at Level 3 are real:
- Operational overhead: two databases to provision, monitor, back up.
- Eventual consistency: read side lags behind write side. How much? How do you handle it in the UI?
- Rebuild cost: if the projector has a bug, you must replay all events to rebuild the read store.
Level 3 is worth it when you need to serve hundreds of thousands of reads per second from a write-optimised transactional database that can only handle thousands. That's a real problem — but it's not every team's problem.
When CQRS Does Not Earn Its Keep
- CRUD applications with simple reporting needs.
- Small teams where operational complexity slows down more than CQRS speeds up.
- Systems where the query patterns haven't stabilised — premature read model design creates migration headaches.
- When the "read model" would just be the same data in the same shape — no denormalization, no projection — just with an event bus in the way.
// This is CQRS theater — the read model is the same as the write model
// Don't do this
@EventHandler
public void on(OrderUpdated event) {
readModelRepo.save(new OrderReadModel(
event.getId(),
event.getStatus()
// identical to what's in the write table
));
}
// Just query the write table directly. Level 1 was fine.Key Takeaways
- CQRS exists on a spectrum — start at Level 1 (separate query methods, same DB) before committing to separate read stores.
- Level 1 CQRS (bypass the aggregate for reads) is nearly free and solves the most common pain: projecting domain objects into API responses.
- Level 2 (dedicated read model tables in the same DB, updated from events) handles complex cross-aggregate queries without a separate infrastructure stack.
- Level 3 (separate read and write databases) is justified only when independent scaling or different query technologies are genuinely needed.
- Eventual consistency in the read model is a real tradeoff — design your UI to handle stale reads before committing to Level 2 or 3.
- The biggest CQRS mistake is building a read model that mirrors the write model exactly — if there's no denormalization or projection benefit, you just added an event bus for nothing.