Learnixo

.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
C#
// 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

C#
// 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

C#
// 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 feature

Decision Framework

C#
// 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.

C#
// 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.

C#
// 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 logs

Interview 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."