CQRS Challenges — What Goes Wrong and How to Fix It
Real-world challenges with CQRS in .NET: eventual consistency, handler proliferation, read model synchronisation, over-engineering small systems, and testing strategies for command/query separation.
CQRS Challenges — What Goes Wrong and How to Fix It
CQRS is a powerful pattern, but it introduces real complexity that teams often underestimate. This article covers the most common problems and practical solutions.
Challenge 1: Handler Proliferation
Every command and query needs its own handler. A moderate-sized system can end up with 50–100 handler classes.
Problem: Navigating the codebase becomes hard. Where is the logic for "create order"?
CreateOrderCommand.cs
CreateOrderCommandHandler.cs
CreateOrderCommandValidator.cs
CreateOrderCommandResponse.cs
GetOrderQuery.cs
GetOrderQueryHandler.cs
GetOrderQueryResponse.cs
... × 20 features = 140 files in Application/Orders/Solution: Vertical Slice Organisation
Before (by pattern):
Application/
Commands/
CreateOrderCommand.cs
UpdateOrderCommand.cs
Queries/
GetOrderQuery.cs
GetOrdersQuery.cs
Handlers/
CreateOrderCommandHandler.cs ← handlers separated from commands
After (by feature slice):
Application/
Orders/
CreateOrder/
CreateOrderCommand.cs
CreateOrderCommandHandler.cs ← all related in one folder
CreateOrderCommandValidator.cs
GetOrder/
GetOrderQuery.cs
GetOrderQueryHandler.cs
GetOrders/
GetOrdersQuery.cs
GetOrdersQueryHandler.cs// Option: collapse small command + handler into one file
public static class CreateOrder
{
public record Command(int CustomerId, List<OrderItemDto> Items) : IRequest<int>;
public class Handler(IOrderRepository repo) : IRequestHandler<Command, int>
{
public async Task<int> Handle(Command cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
await repo.AddAsync(order, ct);
return order.Id;
}
}
public class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.CustomerId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty();
}
}
}Challenge 2: Read Model Synchronisation
When you use separate read and write models (especially with Event Sourcing), keeping them in sync is hard.
Problem: Command writes to the write DB → event is published → read model projector
updates the read DB. If the projector crashes between steps:
- Write DB: order created
- Read DB: order NOT visible
User queries the order → 404
Minutes later the projector catches up → order appears
This is eventual consistency. Users and product managers hate it.Solution 1: Same Database — No Separate Read Model
// CQRS does NOT require separate databases
// Separate models in the same DB = no sync problem
// Write model: rich domain object
public class Order
{
public int Id { get; private set; }
public string Status { get; private set; } = "Draft";
public decimal Total { get; private set; }
public void Pay() { Status = "Paid"; }
}
// Read model: flat, denormalised DTO — same DB, different query
public class OrderSummaryDto
{
public int Id { get; set; }
public string Status { get; set; } = "";
public decimal Total { get; set; }
public string CustomerName { get; set; } = "";
}
// Query handler uses raw SQL for performance — no ORM overhead
public class GetOrdersSummaryHandler(SqlConnection db)
: IRequestHandler<GetOrdersSummaryQuery, List<OrderSummaryDto>>
{
public async Task<List<OrderSummaryDto>> Handle(
GetOrdersSummaryQuery q, CancellationToken ct)
{
const string sql = """
SELECT o.Id, o.Status, o.Total, c.Name AS CustomerName
FROM Orders o
JOIN Customers c ON c.Id = o.CustomerId
WHERE o.Status = @Status
ORDER BY o.CreatedAt DESC
""";
return (await db.QueryAsync<OrderSummaryDto>(sql, new { q.Status })).ToList();
}
}Solution 2: Outbox + Reliable Projection
// If you do need eventual consistency, make it reliable
// Outbox pattern: write event to DB in same transaction as command
// Projector reads from outbox — at-least-once delivery guaranteed
public class CreateOrderHandler(AppDbContext db) : IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
db.Orders.Add(order);
// Write event to outbox in SAME transaction — atomic
db.OutboxMessages.Add(new OutboxMessage
{
Type = "OrderCreated",
Payload = JsonSerializer.Serialize(new OrderCreatedEvent(order.Id)),
});
await db.SaveChangesAsync(ct); // both or neither
return order.Id;
}
}Challenge 3: Over-Engineering Simple CRUD
CQRS adds significant ceremony for simple create/read/update/delete operations.
Bad use case for CQRS:
A "Manage Tags" page with simple CRUD on a Tags table.
CreateTagCommand + CreateTagCommandHandler + CreateTagCommandValidator
UpdateTagCommand + UpdateTagCommandHandler + UpdateTagCommandValidator
DeleteTagCommand + DeleteTagCommandHandler
GetTagsQuery + GetTagsQueryHandler
GetTagByIdQuery + GetTagByIdQueryHandler
= 13 files for a 50-line CRUD featureDecision Framework
// Use CQRS when:
// ✓ Complex business logic in command handlers
// ✓ Commands trigger domain events or side effects
// ✓ Read performance requires specialised queries (joins, projections)
// ✓ Team size makes handler isolation valuable
// Skip CQRS when:
// ✗ Simple CRUD (no business logic, just save and retrieve)
// ✗ Very small project or solo developer
// ✗ Tight deadlines with no time for the setup overhead
// Hybrid: use CQRS selectively — only for complex features
public class TagsController(AppDbContext db) : ControllerBase
{
// Simple CRUD goes direct to DbContext — no CQRS overhead
[HttpGet]
public async Task<IActionResult> GetTags()
=> Ok(await db.Tags.ToListAsync());
[HttpPost]
public async Task<IActionResult> CreateTag([FromBody] string name)
{
db.Tags.Add(new Tag { Name = name });
await db.SaveChangesAsync();
return Ok();
}
}Challenge 4: Testing Command Handlers in Isolation
Handlers have dependencies — testing without the full DI container takes discipline.
// Handler under test
public class CreateOrderHandler(
IOrderRepository repo,
IInventoryService inventory,
IEventPublisher events) : IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
await inventory.ReserveAsync(cmd.Items, ct);
var order = Order.Create(cmd.CustomerId, cmd.Items);
await repo.AddAsync(order, ct);
await events.PublishAsync(new OrderCreatedEvent(order.Id), ct);
return order.Id;
}
}
// Test with NSubstitute — no DI container, no HTTP layer
public class CreateOrderHandlerTests
{
private readonly IOrderRepository _repo = Substitute.For<IOrderRepository>();
private readonly IInventoryService _inventory = Substitute.For<IInventoryService>();
private readonly IEventPublisher _events = Substitute.For<IEventPublisher>();
[Fact]
public async Task Handle_ValidOrder_ReservesInventoryAndPublishesEvent()
{
var handler = new CreateOrderHandler(_repo, _inventory, _events);
var cmd = new CreateOrderCommand(
CustomerId: 1,
Items: [new OrderItemDto(ProductId: 10, Quantity: 2)]
);
var orderId = await handler.Handle(cmd, CancellationToken.None);
await _inventory.Received(1).ReserveAsync(cmd.Items, Arg.Any<CancellationToken>());
await _events.Received(1).PublishAsync(
Arg.Is<OrderCreatedEvent>(e => e.OrderId == orderId),
Arg.Any<CancellationToken>()
);
}
[Fact]
public async Task Handle_InventoryUnavailable_DoesNotSaveOrder()
{
_inventory.ReserveAsync(Arg.Any<List<OrderItemDto>>(), Arg.Any<CancellationToken>())
.Throws(new InsufficientInventoryException("SKU-010"));
var handler = new CreateOrderHandler(_repo, _inventory, _events);
var cmd = new CreateOrderCommand(1, [new OrderItemDto(10, 5)]);
await Assert.ThrowsAsync<InsufficientInventoryException>(
() => handler.Handle(cmd, CancellationToken.None));
await _repo.DidNotReceive().AddAsync(Arg.Any<Order>(), Arg.Any<CancellationToken>());
}
}Challenge 5: Pipeline Behaviour Ordering
MediatR pipeline behaviours execute in registration order. Getting this wrong causes subtle bugs.
// CORRECT order — most important first
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(ApplicationAssemblyMarker).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>)); // 1st: log every request
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); // 2nd: validate before touching DB
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehaviour<,>)); // 3rd: wrap in transaction
});
// WRONG: Validating inside a transaction wastes a transaction for invalid requests
// WRONG: Logging after validation misses invalid request logsInterview Answer
"CQRS introduces five common challenges. (1) Handler proliferation — organise by feature slice (CreateOrder folder contains command, handler, validator), not by pattern layer. (2) Read model sync — you don't need separate databases; a flat SQL read against the same DB with Dapper is often enough. If you do separate models, use the Outbox Pattern for reliable eventual consistency. (3) Over-engineering CRUD — CQRS is overkill for simple tables with no business logic; use it selectively for complex features. (4) Handler testing — handlers test in complete isolation with mocked dependencies (NSubstitute/Moq); no DI container or HTTP layer needed. (5) Pipeline behaviour ordering — validation must precede transaction behaviours; logging should be outermost. The fundamental question before adopting CQRS: does your read complexity or command complexity justify the overhead? For most CRUD features, the answer is no."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.