Learnixo
Back to blog
System Designadvanced

CQRS in Practice — Real Challenges, When It Hurts, and Alternatives That Actually Work

An honest look at CQRS in production .NET systems: eventual consistency pain points, projection lag causing stale reads, read model sync failures, the complexity cost, and when simpler alternatives (read services, database views, compiled queries) solve the same problem with less risk.

Asma Hafeez KhanMay 26, 202620 min read
C#.NETCQRSArchitectureEventual ConsistencyProjectionsSystem DesignCase StudyDesign Patterns
Share:𝕏

CQRS in Practice — Real Challenges, When It Hurts, and Alternatives That Actually Work

Most articles about CQRS describe how to build it. This one describes what happens when you operate it. The theory is clean. Production is not.

Command Query Responsibility Segregation separates the write side from the read side. Commands mutate state. Queries return state. The read model is a separate store optimized for queries, kept in sync with the write model via projections. The promise is independent scaling, purpose-built read models, and a cleaner domain boundary.

That promise is real — in the right context. But CQRS brings a class of problems that do not exist in a simpler architecture, and those problems are underrepresented in tutorials written by people who have not run CQRS under production load.

This article covers the four hardest operational challenges with CQRS, shows production-grade C# code for mitigating each, and then makes an honest case for when you should reach for a simpler alternative instead.


What CQRS Promises vs What It Delivers

The theoretical benefits of CQRS are real but conditional:

Independent scaling is real. Your read replica can handle 100x the read load of your write database. But you do not need CQRS to use read replicas. A read replica with a connection string is all you need.

Purpose-built read models are real. A denormalized read model with pre-joined data can be faster than any relational query. But a PostgreSQL materialized view or a compiled EF Core query often achieves the same result without a second codebase.

Cleaner domain boundary is real, but only if the domain actually has different shapes for reads and writes. Most CRUD domains do not.

The costs that tutorials omit: eventual consistency between write and read models, projection lag under load, projection failure leaving the read model inconsistent, double the code to maintain, double the infrastructure to monitor, and a debugging story that is significantly harder because "why is the read model wrong" requires tracing events through a pipeline.

You should enter CQRS with eyes open. If the benefits are clear for your specific system, the challenges below are manageable with the right code. If the benefits are unclear, read the alternatives section first.


Challenge 1: Eventual Consistency — The "Read Your Own Writes" Problem

A user submits an order. You return HTTP 201. The user's browser immediately navigates to the order list. The order is not there.

This is not a bug. It is the correct behavior of an eventually consistent system. But users experience it as a bug, and support tickets follow.

The write side committed to the write database. The read model is updated asynchronously via a projection. The projection might be 50ms behind. It might be 5 seconds behind under load. The user's order list query hits the read model before the projection catches up.

The standard solutions, in order of preference:

Option 1: Read from the write side for the creating user's session immediately after a write.

This is the simplest and most effective solution. When a user places an order, return the newly created order from the write side in the response. The browser has the data without hitting the read model. The order list can show this data from local state while the projection catches up.

C#
public sealed class PlaceOrderEndpoint : ICarterModule
{
    public void AddRoutes(IEndpointRouteBuilder app)
    {
        app.MapPost("/orders", async (
            PlaceOrderRequest request,
            IMediator mediator,
            CancellationToken ct) =>
        {
            var result = await mediator.Send(new PlaceOrderCommand(
                CustomerId: request.CustomerId,
                Lines: request.Lines), ct);

            // Return the newly created order from the write side directly.
            // The client does not need to query the read model immediately.
            // This avoids the "read your own writes" problem without
            // any synchronization complexity.
            return Results.Created($"/orders/{result.OrderId}", result);
        });
    }
}

Option 2: Synchronous projection for the creating session.

If the read model must be queried immediately, project synchronously within the command handler for the data the current user will read, and let the async projection handle the rest. This is the "synchronous read model for the actor" pattern.

C#
public sealed class PlaceOrderCommandHandler : IRequestHandler<PlaceOrderCommand, PlaceOrderResult>
{
    private readonly WriteDbContext _writeDb;
    private readonly ReadDbContext _readDb;
    private readonly IEventPublisher _publisher;

    public PlaceOrderCommandHandler(
        WriteDbContext writeDb,
        ReadDbContext readDb,
        IEventPublisher publisher)
    {
        _writeDb = writeDb;
        _readDb = readDb;
        _publisher = publisher;
    }

    public async Task<PlaceOrderResult> Handle(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(cmd.CustomerId, cmd.Lines);
        _writeDb.Orders.Add(order);
        await _writeDb.SaveChangesAsync(ct);

        // Synchronously write to the read model for this entity only.
        // The async projection will also process this event, but the
        // idempotency check will skip it because it already exists.
        var readModel = new OrderSummaryReadModel
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            Status = order.Status.ToString(),
            Total = order.Total,
            CreatedAt = order.CreatedAt
        };

        _readDb.OrderSummaries.Add(readModel);
        await _readDb.SaveChangesAsync(ct);

        // Publish the event for all other interested projections
        await _publisher.PublishAsync(new OrderPlacedEvent(order.Id), ct);

        return new PlaceOrderResult(order.Id, order.Total);
    }
}

Option 3: Optimistic acknowledgment with a correlation token.

Return a correlation ID to the client with the 201. The client polls for completion with that correlation ID. The read model stores the correlation ID on the projected record. Once the projection lands, the poll returns the data. This is the most complex solution and is only justified when neither option 1 nor option 2 is acceptable.


Challenge 2: Projection Lag — When the Read Model Falls Behind

At low load, your projection processes events in under 100ms. A benchmark shows 5ms per event. You ship. Under production load with 500 concurrent users, the projection queue is 30 seconds behind.

The reason is usually lock contention on the read model database, or a slow projection step (e.g. an HTTP call inside the projection handler, which should never happen). But even a well-written projection has a lag curve that you must monitor.

Measure projection lag as a first-class metric:

C#
public sealed class ProjectionLagMonitor : BackgroundService
{
    private readonly ILogger<ProjectionLagMonitor> _logger;
    private readonly WriteDbContext _writeDb;
    private readonly ReadDbContext _readDb;
    private readonly IMeterFactory _meterFactory;

    private readonly ObservableGauge<double> _lagGauge;

    public ProjectionLagMonitor(
        ILogger<ProjectionLagMonitor> logger,
        WriteDbContext writeDb,
        ReadDbContext readDb,
        IMeterFactory meterFactory)
    {
        _logger = logger;
        _writeDb = writeDb;
        _readDb = readDb;

        var meter = meterFactory.Create("Ordering.Projections");
        _lagGauge = meter.CreateObservableGauge(
            "projection_lag_seconds",
            observeValue: GetCurrentLagSeconds,
            description: "Seconds behind between the latest write event and the latest projected event");
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        // The metric is push-based via ObservableGauge — this loop just keeps the service alive
        while (!ct.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(10), ct);
        }
    }

    private Measurement<double> GetCurrentLagSeconds()
    {
        // These queries run against in-memory checkpoints, not the live DB on every call.
        // The actual DB query runs on a slower poll in the background.
        return new Measurement<double>(_currentLagSeconds);
    }

    private volatile double _currentLagSeconds;

    // Called by a background loop at 5-second intervals
    public async Task RefreshLagAsync(CancellationToken ct)
    {
        // Latest event timestamp on the write side
        var latestWrite = await _writeDb.DomainEvents
            .OrderByDescending(e => e.OccurredAt)
            .Select(e => e.OccurredAt)
            .FirstOrDefaultAsync(ct);

        // Latest event timestamp that the projection has processed
        var latestProjected = await _readDb.ProjectionCheckpoints
            .Where(p => p.ProjectionName == "OrderSummary")
            .Select(p => p.LastProcessedAt)
            .FirstOrDefaultAsync(ct);

        if (latestWrite == default || latestProjected == default)
        {
            _currentLagSeconds = 0;
            return;
        }

        var lag = (latestWrite - latestProjected).TotalSeconds;
        _currentLagSeconds = Math.Max(0, lag);

        if (lag > 30)
        {
            _logger.LogWarning(
                "Projection lag is {LagSeconds:F1}s — read model is significantly behind write model. " +
                "Read fallback should activate.",
                lag);
        }
    }
}

Configure an alert when lag exceeds your SLA threshold. A 30-second lag on an e-commerce order list is acceptable. A 30-second lag on a medical record view is not.

When lag exceeds the threshold, fall back to querying the write side:

C#
public sealed class ReadFallbackService
{
    private readonly ReadDbContext _readDb;
    private readonly WriteDbContext _writeDb;
    private readonly ProjectionLagMonitor _lagMonitor;
    private readonly ILogger<ReadFallbackService> _logger;

    private const double FallbackThresholdSeconds = 10.0;

    public ReadFallbackService(
        ReadDbContext readDb,
        WriteDbContext writeDb,
        ProjectionLagMonitor lagMonitor,
        ILogger<ReadFallbackService> logger)
    {
        _readDb = readDb;
        _writeDb = writeDb;
        _lagMonitor = lagMonitor;
        _logger = logger;
    }

    public async Task<IReadOnlyList<OrderSummaryDto>> GetOrdersForCustomerAsync(
        Guid customerId,
        CancellationToken ct)
    {
        var lagSeconds = _lagMonitor.CurrentLagSeconds;

        if (lagSeconds > FallbackThresholdSeconds)
        {
            // Read model is too stale — fall back to write side.
            // This query is slower but accurate.
            _logger.LogWarning(
                "Projection lag {Lag:F1}s exceeds threshold {Threshold}s — " +
                "falling back to write DB for customer {CustomerId}",
                lagSeconds, FallbackThresholdSeconds, customerId);

            return await QueryWriteSideAsync(customerId, ct);
        }

        return await QueryReadSideAsync(customerId, ct);
    }

    private async Task<IReadOnlyList<OrderSummaryDto>> QueryReadSideAsync(
        Guid customerId, CancellationToken ct)
    {
        return await _readDb.OrderSummaries
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.CreatedAt)
            .Select(o => new OrderSummaryDto(o.OrderId, o.Status, o.Total, o.CreatedAt))
            .ToListAsync(ct);
    }

    private async Task<IReadOnlyList<OrderSummaryDto>> QueryWriteSideAsync(
        Guid customerId, CancellationToken ct)
    {
        // The write side query is normalized — it needs joins.
        // This is why you added CQRS in the first place.
        // Under fallback, you pay that cost temporarily.
        return await _writeDb.Orders
            .Include(o => o.Lines)
            .Where(o => o.CustomerId == customerId && !o.IsDeleted)
            .OrderByDescending(o => o.CreatedAt)
            .Select(o => new OrderSummaryDto(o.Id, o.Status.ToString(), o.Total, o.CreatedAt))
            .ToListAsync(ct);
    }
}

The fallback degrades gracefully: the read model is fast when healthy, and the write side covers when the projection is behind. This is circuit breaking applied to the read model.


Challenge 3: Projection Sync Failure — Inconsistent Read Model After a Crash

A projection worker processes a batch of 50 events. It writes 30 to the read model and then crashes. On restart it replays from the last checkpoint, which was before the batch. It processes the same 30 events again, producing duplicates, and then processes the remaining 20.

The read model is now inconsistent: some records are duplicated, others are missing.

The fix is idempotent projection handlers. Every projection must be safe to replay. If the same event is applied twice, the result must be the same as applying it once.

C#
public sealed class OrderSummaryProjectionHandler
{
    private readonly ReadDbContext _db;
    private readonly ILogger<OrderSummaryProjectionHandler> _logger;

    public OrderSummaryProjectionHandler(ReadDbContext db, ILogger<OrderSummaryProjectionHandler> logger)
    {
        _db = db;
        _logger = logger;
    }

    // This method is called for every OrderPlacedEvent.
    // It must be safe to call multiple times with the same event.
    public async Task HandleAsync(OrderPlacedEvent @event, CancellationToken ct)
    {
        // Idempotency check: if the record already exists, skip.
        // Using Upsert semantics so double-processing is always safe.
        var existing = await _db.OrderSummaries
            .FindAsync([@event.OrderId], ct);

        if (existing is not null)
        {
            // Already projected — skip without error.
            // This handles the replay-after-crash scenario.
            _logger.LogDebug(
                "OrderSummary for {OrderId} already projected — skipping (idempotent)",
                @event.OrderId);
            return;
        }

        var summary = new OrderSummaryReadModel
        {
            OrderId = @event.OrderId,
            CustomerId = @event.CustomerId,
            Status = "Placed",
            Total = @event.Total,
            CreatedAt = @event.OccurredAt,
            LastEventId = @event.EventId  // store the event ID for idempotency
        };

        _db.OrderSummaries.Add(summary);
        await _db.SaveChangesAsync(ct);
    }

    public async Task HandleAsync(OrderCancelledEvent @event, CancellationToken ct)
    {
        var summary = await _db.OrderSummaries.FindAsync([@event.OrderId], ct);

        if (summary is null)
        {
            // The OrderPlaced event may not have been projected yet.
            // This can happen during out-of-order delivery.
            // Log a warning and skip — the consistency job will fix this.
            _logger.LogWarning(
                "Received OrderCancelled for {OrderId} but no OrderSummary found. " +
                "This indicates out-of-order event delivery.",
                @event.OrderId);
            return;
        }

        // Idempotency: if already cancelled, skip
        if (summary.Status == "Cancelled")
        {
            return;
        }

        summary.Status = "Cancelled";
        summary.CancelledAt = @event.OccurredAt;
        summary.LastEventId = @event.EventId;

        await _db.SaveChangesAsync(ct);
    }
}

Checkpointing ensures the projection picks up where it left off after a crash:

C#
public sealed class IdempotentProjectionWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IEventStore _eventStore;
    private readonly ILogger<IdempotentProjectionWorker> _logger;

    private const string CheckpointName = "OrderSummary";

    public IdempotentProjectionWorker(
        IServiceScopeFactory scopeFactory,
        IEventStore eventStore,
        ILogger<IdempotentProjectionWorker> logger)
    {
        _scopeFactory = scopeFactory;
        _eventStore = eventStore;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                await ProcessBatchAsync(ct);
            }
            catch (Exception ex) when (!ct.IsCancellationRequested)
            {
                _logger.LogError(ex, "Projection worker error — will retry in 5s");
                await Task.Delay(TimeSpan.FromSeconds(5), ct);
            }
        }
    }

    private async Task ProcessBatchAsync(CancellationToken ct)
    {
        await using var scope = _scopeFactory.CreateAsyncScope();
        var readDb = scope.ServiceProvider.GetRequiredService<ReadDbContext>();
        var handler = scope.ServiceProvider.GetRequiredService<OrderSummaryProjectionHandler>();

        // Load the last known position from the checkpoint table
        var checkpoint = await readDb.ProjectionCheckpoints
            .FirstOrDefaultAsync(c => c.ProjectionName == CheckpointName, ct)
            ?? new ProjectionCheckpoint { ProjectionName = CheckpointName, LastPosition = 0 };

        // Fetch the next batch of events after the checkpoint
        var events = await _eventStore.GetEventsAfterAsync(
            position: checkpoint.LastPosition,
            maxCount: 100,
            ct: ct);

        if (events.Count == 0)
        {
            // No new events — wait briefly before polling again
            await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
            return;
        }

        foreach (var @event in events)
        {
            switch (@event)
            {
                case OrderPlacedEvent placed:
                    await handler.HandleAsync(placed, ct);
                    break;
                case OrderCancelledEvent cancelled:
                    await handler.HandleAsync(cancelled, ct);
                    break;
            }

            // Update checkpoint after EACH event, not after the batch.
            // If we crash mid-batch, we resume from the last successfully processed event,
            // not from the start of the batch.
            checkpoint.LastPosition = @event.GlobalPosition;
            checkpoint.LastProcessedAt = @event.OccurredAt;

            if (readDb.Entry(checkpoint).State == EntityState.Detached)
                readDb.ProjectionCheckpoints.Add(checkpoint);

            await readDb.SaveChangesAsync(ct);
        }

        _logger.LogDebug(
            "Projection {Name} processed {Count} events, now at position {Position}",
            CheckpointName, events.Count, checkpoint.LastPosition);
    }
}

If the read model becomes severely inconsistent, you trigger a full rebuild:

C#
public sealed class ProjectionRebuildService
{
    private readonly IEventStore _eventStore;
    private readonly ReadDbContext _readDb;
    private readonly OrderSummaryProjectionHandler _handler;
    private readonly ILogger<ProjectionRebuildService> _logger;

    public ProjectionRebuildService(
        IEventStore eventStore,
        ReadDbContext readDb,
        OrderSummaryProjectionHandler handler,
        ILogger<ProjectionRebuildService> logger)
    {
        _eventStore = eventStore;
        _readDb = readDb;
        _handler = handler;
        _logger = logger;
    }

    public async Task RebuildAsync(CancellationToken ct)
    {
        _logger.LogWarning("Starting full rebuild of OrderSummary projection");

        // Wipe the read model — rebuild from zero
        await _readDb.Database.ExecuteSqlRawAsync(
            "TRUNCATE TABLE order_summaries", ct);

        // Reset the checkpoint
        await _readDb.Database.ExecuteSqlRawAsync(
            "DELETE FROM projection_checkpoints WHERE projection_name = 'OrderSummary'", ct);

        long position = 0;
        var batchSize = 500;
        var totalProcessed = 0L;

        while (true)
        {
            var events = await _eventStore.GetEventsAfterAsync(position, batchSize, ct);
            if (events.Count == 0) break;

            foreach (var @event in events)
            {
                switch (@event)
                {
                    case OrderPlacedEvent placed:
                        await _handler.HandleAsync(placed, ct);
                        break;
                    case OrderCancelledEvent cancelled:
                        await _handler.HandleAsync(cancelled, ct);
                        break;
                }

                position = @event.GlobalPosition;
                totalProcessed++;
            }

            _logger.LogInformation(
                "Rebuild progress: {Total} events processed, position {Position}",
                totalProcessed, position);
        }

        _logger.LogInformation(
            "Rebuild complete. Processed {Total} events total.", totalProcessed);
    }
}

Because the projection handlers are idempotent, a rebuild is always safe. Rebuild produces identical results to incremental projection given the same event log.


Challenge 4: Complexity — The Hidden Cost

CQRS roughly doubles the amount of code that touches your domain. For every feature you add:

  • Write side: command, command handler, domain model change, domain event
  • Read side: projection handler, read model entity, read model migration, projection test

Every new developer who joins must understand both models. When a senior engineer explains why the OrderSummary read model has a CancelledAt field but the write-side Order aggregate does not, that conversation takes time. When a junior engineer adds a field to the Order aggregate without updating the projection, the bug is silent until a user reports stale data.

Testable, isolated projections are the mitigation:

C#
public sealed class OrderSummaryProjectionTests
{
    private readonly ReadDbContext _db;
    private readonly OrderSummaryProjectionHandler _handler;

    public OrderSummaryProjectionTests()
    {
        var options = new DbContextOptionsBuilder<ReadDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;

        _db = new ReadDbContext(options);
        _handler = new OrderSummaryProjectionHandler(_db, NullLogger<OrderSummaryProjectionHandler>.Instance);
    }

    [Fact]
    public async Task OrderPlaced_creates_summary()
    {
        var orderId = Guid.NewGuid();
        var @event = new OrderPlacedEvent(
            EventId: Guid.NewGuid(),
            OrderId: orderId,
            CustomerId: Guid.NewGuid(),
            Total: 150.00m,
            OccurredAt: DateTime.UtcNow,
            GlobalPosition: 1);

        await _handler.HandleAsync(@event, CancellationToken.None);

        var summary = await _db.OrderSummaries.FindAsync(orderId);
        Assert.NotNull(summary);
        Assert.Equal("Placed", summary!.Status);
        Assert.Equal(150.00m, summary.Total);
    }

    [Fact]
    public async Task OrderPlaced_is_idempotent()
    {
        // Applying the same event twice should not create duplicate records
        var @event = new OrderPlacedEvent(
            EventId: Guid.NewGuid(),
            OrderId: Guid.NewGuid(),
            CustomerId: Guid.NewGuid(),
            Total: 75.00m,
            OccurredAt: DateTime.UtcNow,
            GlobalPosition: 1);

        await _handler.HandleAsync(@event, CancellationToken.None);
        await _handler.HandleAsync(@event, CancellationToken.None); // replay

        var count = await _db.OrderSummaries.CountAsync();
        Assert.Equal(1, count);
    }

    [Fact]
    public async Task OrderCancelled_updates_status()
    {
        var orderId = Guid.NewGuid();

        await _handler.HandleAsync(new OrderPlacedEvent(
            Guid.NewGuid(), orderId, Guid.NewGuid(), 100m, DateTime.UtcNow, 1),
            CancellationToken.None);

        await _handler.HandleAsync(new OrderCancelledEvent(
            Guid.NewGuid(), orderId, DateTime.UtcNow, 2),
            CancellationToken.None);

        var summary = await _db.OrderSummaries.FindAsync(orderId);
        Assert.Equal("Cancelled", summary!.Status);
        Assert.NotNull(summary.CancelledAt);
    }
}

Projections that are deterministic, dependency-free, and unit-testable in isolation are the difference between a CQRS system that a team can maintain and one that only the architect understands.


When NOT to Use CQRS

CQRS is not a default architecture pattern. It is a solution to specific problems. You should not use it in these situations:

Simple CRUD systems. If your reads and writes touch the same entities with the same shape, there is nothing to separate. You are adding complexity without gaining anything.

When read and write load is similar. The main scaling benefit of CQRS is running read replicas independently from the write primary. If your reads and writes are 50/50, a read replica with a connection string gives you the same benefit without a second data model.

When the team is small or junior-heavy. CQRS is a force multiplier for experienced teams and a complexity trap for teams that are still building fundamentals. Projection debugging requires understanding the full event pipeline, and that knowledge is expensive to develop.

When consistency requirements are strict. If users cannot tolerate stale reads under any circumstances — financial balance displays, medication dosing records — eventual consistency is a liability, not a feature. Strong consistency from a single write model is the right choice.

When you cannot afford a full rebuild. A CQRS system's correctness guarantee is only as strong as your ability to rebuild the read model from the event log. If the event log is incomplete, or if a rebuild takes longer than your acceptable downtime window, you are operating without a safety net.


Alternatives That Actually Work

Before choosing CQRS, consider whether these simpler options solve your problem:

Database views and materialized views. A PostgreSQL materialized view is a pre-computed, indexed table that can be refreshed on a schedule. It handles most "I need a denormalized read model" requirements with zero application code.

SQL
CREATE MATERIALIZED VIEW order_summaries AS
SELECT
    o.id         AS order_id,
    o.customer_id,
    o.status,
    o.total,
    o.created_at,
    COUNT(l.id)  AS line_count
FROM orders o
LEFT JOIN order_lines l ON l.order_id = o.id
GROUP BY o.id, o.customer_id, o.status, o.total, o.created_at;

CREATE UNIQUE INDEX ON order_summaries (order_id);

-- Refresh incrementally (PostgreSQL 14+)
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summaries;

This is maintained by PostgreSQL, not by your application. Zero projection workers to operate.

Compiled EF Core queries. The most common reason teams reach for a read model is that their EF Core queries are slow. Before adding a second data model, try compiled queries:

C#
public static class CompiledQueries
{
    // Compiled at startup — the query plan is cached and reused on every call.
    // This eliminates LINQ translation overhead and often matches raw SQL performance.
    public static readonly Func<AppDbContext, Guid, IAsyncEnumerable<OrderSummaryDto>>
        GetOrdersForCustomer = EF.CompileAsyncQuery(
            (AppDbContext db, Guid customerId) =>
                db.Orders
                  .Where(o => o.CustomerId == customerId && !o.IsDeleted)
                  .OrderByDescending(o => o.CreatedAt)
                  .Select(o => new OrderSummaryDto(
                      o.Id,
                      o.Status.ToString(),
                      o.Total,
                      o.CreatedAt)));
}

// Usage — identical to a normal query, but no translation cost at runtime
public async Task<IReadOnlyList<OrderSummaryDto>> GetOrdersAsync(Guid customerId, CancellationToken ct)
{
    var results = new List<OrderSummaryDto>();
    await foreach (var dto in CompiledQueries.GetOrdersForCustomer(_db, customerId).WithCancellation(ct))
    {
        results.Add(dto);
    }
    return results;
}

The SimpleReadService — thin CQRS without projections. You can get clean separation between reads and writes without a separate data model. A read service queries the write database using optimized queries. No projection worker. No eventual consistency. No separate store.

C#
// This gives you the query/command separation benefit of CQRS
// without the eventual consistency cost.
public sealed class SimpleOrderReadService
{
    private readonly AppDbContext _db;

    public SimpleOrderReadService(AppDbContext db) => _db = db;

    public async Task<OrderDetailDto?> GetOrderDetailAsync(Guid orderId, CancellationToken ct)
    {
        return await _db.Orders
            .AsNoTracking()
            .Where(o => o.Id == orderId && !o.IsDeleted)
            .Select(o => new OrderDetailDto(
                o.Id,
                o.CustomerId,
                o.Status.ToString(),
                o.Total,
                o.CreatedAt,
                o.Lines.Select(l => new OrderLineDto(l.ProductId, l.Quantity, l.UnitPrice)).ToList()))
            .FirstOrDefaultAsync(ct);
    }

    public async Task<PagedResult<OrderSummaryDto>> GetOrdersForCustomerAsync(
        Guid customerId,
        int page,
        int pageSize,
        CancellationToken ct)
    {
        var query = _db.Orders
            .AsNoTracking()
            .Where(o => o.CustomerId == customerId && !o.IsDeleted);

        var totalCount = await query.CountAsync(ct);

        var items = await query
            .OrderByDescending(o => o.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(o => new OrderSummaryDto(o.Id, o.Status.ToString(), o.Total, o.CreatedAt))
            .ToListAsync(ct);

        return new PagedResult<OrderSummaryDto>(items, totalCount, page, pageSize);
    }
}

This pattern covers 80% of the scenarios where teams reach for CQRS. It keeps a clean boundary between reads and writes without eventual consistency, projection workers, or a second database.

PostgreSQL read replicas. If read throughput is genuinely the bottleneck, a read replica with a separate connection string gives you horizontal read scaling. The replication lag is typically under 100ms for a co-located replica — better than many CQRS projection workers.

C#
// Register two DbContexts — one for writes, one for reads from the replica
services.AddDbContext<WriteDbContext>(options =>
    options.UseNpgsql(config.GetConnectionString("Write")));

services.AddDbContext<ReadDbContext>(options =>
    options.UseNpgsql(config.GetConnectionString("ReadReplica"))
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

The consistency semantics are better than CQRS with async projections, because replication lag is bounded and deterministic in a way that projection lag is not.


When CQRS IS Worth It

With all the above caveats, there are cases where CQRS earns its complexity cost:

Event sourcing as the write side. If your write side is an event store (events are the source of truth, not a relational table), you need projections to build any query model. CQRS is not optional — it is the only way to query an event-sourced system at scale.

Radically different read and write shapes. If your write model is a complex aggregate with business rules enforcing invariants, and your read model is a denormalized flat structure optimized for reporting, the two genuinely cannot share a schema. Event-driven projection is a clean solution.

Very high read/write ratio with different SLAs. If you process 1,000 writes per second and 500,000 reads per second, and reads can tolerate 5 seconds of staleness, a separate read model with independent scaling is appropriate. The key phrase is "can tolerate staleness."

Separate team ownership. If the team that owns order placement is genuinely separate from the team that owns order reporting, CQRS gives them an explicit boundary with a versioned event contract between them. This is an organizational pattern as much as a technical one.


The 5-Question Decision Checklist

Before committing to CQRS on a new system, answer these questions honestly:

1. Do my read and write models have genuinely different shapes? If the same normalized tables serve both reads and writes well, you do not need a second model. A view or a compiled query is enough.

2. Can my users tolerate eventual consistency? If a user who places an order must immediately see that order in their list with 100% reliability, eventual consistency is the wrong foundation. Be explicit about this before building projections.

3. Is my read load more than 10x my write load? If not, a read replica with a connection string gives you comparable scaling without projection complexity.

4. Do I have the operational maturity to monitor projection lag, trigger fallbacks, and execute full rebuilds? If the answer is no, you will operate CQRS reactively — which means debugging inconsistent read models at 2 AM.

5. Is my team familiar enough with the pattern to own projection failures independently? If a projection fails and only one person on the team knows how to diagnose it, that is operational risk, not architecture.

If you answer no to three or more of these, use a simpler alternative. If you answer yes to all five, CQRS is a legitimate tool for your situation.


Summary

CQRS is not wrong. It is a legitimate architectural pattern for specific problems. But it is overused by teams who reach for it because tutorials make it look clean, without experiencing the operational reality of eventual consistency, projection lag, read model corruption, and doubled maintenance burden.

The production challenges are solvable — idempotent projections, lag monitoring with write-side fallback, per-event checkpointing, and full rebuild capability address the four main failure modes. The C# code in this article gives you production-grade implementations for each.

But the best architecture is the simplest one that meets your requirements. A SimpleReadService querying a read replica with compiled EF Core queries is not a compromise — it is often the right design. Add CQRS when you have a concrete problem that requires it, not because the pattern is intellectually appealing.

Enjoyed this article?

Explore the System Design learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

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