.NET & C# Development · Lesson 99 of 229
CQRS Challenges — What Goes Wrong and How to Fix It
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."