Learnixo
Back to blog
Backend Systemsintermediate

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.

Asma Hafeez KhanMay 24, 20266 min read
.NETC#CQRSClean Architecturechallengespitfallsarchitecture
Share:𝕏

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

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.