CQRS pitfalls — when does it become over-engineering?
CQRS done wrong produces MORE complexity than CRUD without the benefits. Here's how it fails — and the signals to watch for.
Pitfall 1 — Applying CQRS to a trivial CRUD app
If your "command" is UpdateUserCommand(id, name, email, ...) and the handler just maps to db.SaveChangesAsync(), you've added 4 files (command, handler, validator, DTO) for what was 5 lines of controller code.
Signal: every command handler is 10 lines and contains no branching logic.
Fix: delete CQRS for that endpoint. Use a plain controller. Reserve CQRS for endpoints with real business logic.
Pitfall 2 — Anaemic commands
A UpdateOrderCommand with 25 nullable fields and the comment "update whatever's not null". That's CRUD with extra steps. Real CQRS commands are named after intent, not "update".
// ❌ Anaemic
public record UpdateOrderCommand(
Guid Id, string? Status, decimal? Discount, string? ShippingAddress, ...);
// ✅ Intent-driven
public record CancelOrderCommand(Guid OrderId, string Reason);
public record ApplyDiscountCommand(Guid OrderId, decimal Percentage);
public record UpdateShippingAddressCommand(Guid OrderId, Address NewAddress);
Signal: your command has more than ~5 fields and most are optional.
Fix: split into specific commands by intent.
Pitfall 3 — Business logic in queries
Queries should be pure reads. The moment a query "decides eligibility" or "computes a customer's risk score" on every fetch, you've moved business logic to the read side.
// ❌ Business logic inside a query
public class GetEligibleCustomersQuery {
public Task Handle() {
var all = await db.Customers.ToListAsync();
return all.Where(c => c.Spend > 100_000 && c.JoinDate < ...); // BUSINESS RULE
}
}
// ✅ Eligibility is a projection — updated on relevant commands
public record CustomerEligibilityRow(Guid CustomerId, bool IsEligible);
// Updated by a projector listening to OrderPlaced / CustomerJoined events
Signal: query handlers contain conditionals based on business rules.
Fix: move the rule into a command handler that updates a projection.
Pitfall 4 — Adopting Event Sourcing too early
CQRS works fine with a normal Postgres write model. Adding Event Sourcing on top tripled your complexity (event store, snapshots, upcasters, replay, eventual consistency on the read side).
Signal: you're spending more time on event-store plumbing than on business features.
Fix: revert to a normal write DB. Only add ES when you have a concrete need (regulatory audit, temporal queries, multiple independent read models).
Pitfall 5 — Two DBs for a small app
You don't need a separate read DB until the read load justifies it. For most apps, one Postgres with smart projections + indexes is enough.
Signal: under 10 RPS on either side, but you're operating Mongo + Postgres + a Kafka sync.
Fix: consolidate to one DB. Use views or materialized views if you need denormalization.
Pitfall 6 — Pipeline behaviors in the wrong order
The order of IPipelineBehavior matters and bugs are silent.
| Order | Behavior |
|---|---|
| Outermost first | Logging |
| Then | Validation |
| Then | Authorization |
| Then | Transaction begin |
| Then | Handler |
| Then | Transaction commit |
| Finally | Caching (queries only) |
If transaction begins BEFORE validation, every failed-validation request rolls back an empty transaction (wasted DB call). If caching wraps the transaction, you cache uncommitted reads. Subtle and dangerous.
Signal: writes succeed on the API side but data doesn't appear in DB; or you see cached "ghost" data.
Fix: explicit pipeline order in Program.cs. Add an integration test that asserts the order.
Pitfall 7 — Returning entities from queries
// ❌ Returning the entity
public Task<Order> Handle(GetOrderQuery q) => db.Orders.FindAsync(q.Id);
// ✅ Returning a UI-shaped DTO
public Task<OrderDetailDto> Handle(GetOrderQuery q) =>
db.Orders
.Where(o => o.Id == q.Id)
.Select(o => new OrderDetailDto(o.Id, o.Customer.Name, o.Items.Count, ...))
.FirstOrDefaultAsync();
Returning entities couples the API surface to the DB schema and bloats the response with internal fields. The whole point of CQRS is that the read shape is INDEPENDENT of the write shape.
Pitfall 8 — One handler doing multiple things
public class CancelOrderHandler {
public async Task Handle(CancelOrderCommand cmd) {
// Cancel the order
// Refund the payment
// Release the inventory
// Send the email
// Update analytics
// Notify warehouse
}
}
That's not one handler — it's six. They should each be triggered by events, not by the handler.
Fix: the handler does ONE write + emits ONE event. Other side effects subscribe to the event (SAGA-style).
Signal that CQRS is over-engineered for YOUR app
- 80% of your commands are 1-line
Add + SaveChanges - Most handlers have no validation, no business rules, no events
- Single dev on the project
- One read shape per entity
- No audit / regulatory requirement
If 3+ of these are true, ditch CQRS. Use Minimal APIs / plain controllers. The MediatR ceremony is hurting more than helping.
Interview-grade summary
"CQRS pays off when the read and write sides have genuinely different concerns. The most common failure is applying it everywhere — including simple CRUD endpoints where it just adds files and indirection. The other common failure is adding Event Sourcing too early, which triples complexity without proportional benefit. Real CQRS shines when commands are named for intent, queries are pure reads with UI-shaped DTOs, and pipeline behaviors are ordered carefully. Used appropriately, it's a force multiplier; used universally, it's a tax."