1. The Problem Context β€” When β€œService Layer” Stops Scaling

A typical .NET service starts with one OrderService that has fifteen methods β€” PlaceOrder, GetOrder, ListOrders, SearchOrders, UpdateShippingAddress, CancelOrder, and so on. Reads and writes share the same domain model. Six months in, the read methods carry six different DTO shapes for six different screens; the write methods carry domain invariants no read needs. Touching one breaks the other. Pull requests block on rebase conflicts in a single 1,400-line file.

πŸ€” Sound familiar?
Reads and writes have asymmetric requirements. Writes care about invariants, transactions, and concurrency. Reads care about projection shape, latency, and screen-specific data. CQRS β€” Command/Query Responsibility Segregation β€” is the simple act of stopping the pretence that they are the same thing.

CQRS is not Event Sourcing. It is not a separate database. It is not even necessarily MediatR. At its smallest, CQRS is β€œa method either changes state or returns data, never both” (Meyer's Command-Query Separation, lifted up to the responsibility/architectural layer by Greg Young). Everything else β€” separate read models, separate stores, async projection β€” is an option you adopt when the workload demands it.

2. The Concept Explanation β€” Spectrum, Not a Switch

flowchart LR
  A[Single service<br/>reads + writes] --> B[Split handlers<br/>same DB]
  B --> C[Split handlers<br/>separate read models]
  C --> D[Separate read store<br/>eventually consistent]
  D --> E[Event Sourcing<br/>append-only writes]

  style A fill:#1f2937,stroke:#6b7280,color:#e5e7eb
  style B fill:#1e3a5f,stroke:#3b82f6,color:#e0f2fe
  style C fill:#1e3a5f,stroke:#3b82f6,color:#e0f2fe
  style D fill:#3a2a4d,stroke:#a855f7,color:#f3e8ff
  style E fill:#3a2a4d,stroke:#a855f7,color:#f3e8ff

Most production systems sit happily at B or C. Reaching for Dintroduces eventual consistency, which is a product decision masquerading as a technical one β€” the user must be able to see β€œit might take a moment to appear” outcomes. E is a specialised tool for audit-heavy, temporal-query-heavy domains; using it for an admin dashboard is malpractice.

The three concepts you actually need:

  • Command β€” an intent to change state. Returns nothing meaningful (an id, a result enum, a void task). Validated, authorised, idempotent if at all possible.
  • Query β€” a request for data. Side-effect-free. Free to bypass the domain model, project directly from the database into a screen-shaped DTO.
  • Handler β€” a single class per command/query. One responsibility, easy to test, easy to find.

3. The Implementation β€” CQRS Without MediatR

πŸ€” MediatR in 2025
MediatR moved to a paid commercial license in v13 (Sep 2024). For most apps you no longer need it: the in-process bus is ten lines of DI plus an interface. The example below is deliberately mediator-free; if you already use MediatR or a maintained alternative (Wolverine, Brighter, FastEndpoints) the patterns transfer one-for-one.

3.1 Define the contracts

public interface ICommand<TResult> { }
public interface IQuery<TResult>   { }

public interface ICommandHandler<TCommand, TResult> where TCommand : ICommand<TResult>
{
    Task<TResult> HandleAsync(TCommand command, CancellationToken ct);
}

public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    Task<TResult> HandleAsync(TQuery query, CancellationToken ct);
}

3.2 A command β€” the write side

public sealed record PlaceOrder(
    CustomerId CustomerId,
    IReadOnlyList<OrderLineDto> Lines,
    string IdempotencyKey) : ICommand<OrderId>;

internal sealed class PlaceOrderHandler(
    AppDb db,
    IOutbox outbox,
    TimeProvider clock) : ICommandHandler<PlaceOrder, OrderId>
{
    public async Task<OrderId> HandleAsync(PlaceOrder cmd, CancellationToken ct)
    {
        // Idempotency β€” same key returns the same id
        var existing = await db.Orders
            .Where(o => o.IdempotencyKey == cmd.IdempotencyKey)
            .Select(o => (OrderId?)o.Id)
            .SingleOrDefaultAsync(ct);
        if (existing is { } id) return id;

        var order = Order.Place(cmd.CustomerId, cmd.Lines, cmd.IdempotencyKey, clock.GetUtcNow());

        await db.Orders.AddAsync(order, ct);
        await outbox.EnqueueAsync(new OrderPlaced(order.Id, order.PlacedAt), ct);
        await db.SaveChangesAsync(ct);

        return order.Id;
    }
}

3.3 A query β€” the read side

The read handler does not load entities. It projects directly into the DTO the screen needs.

public sealed record GetCustomerOrderSummary(CustomerId CustomerId, int Page, int Size)
    : IQuery<PagedResult<OrderSummaryDto>>;

internal sealed class GetCustomerOrderSummaryHandler(AppDb db)
    : IQueryHandler<GetCustomerOrderSummary, PagedResult<OrderSummaryDto>>
{
    public async Task<PagedResult<OrderSummaryDto>> HandleAsync(
        GetCustomerOrderSummary q, CancellationToken ct)
    {
        var baseQuery = db.Orders
            .AsNoTracking()
            .Where(o => o.CustomerId == q.CustomerId);

        var total = await baseQuery.CountAsync(ct);

        var items = await baseQuery
            .OrderByDescending(o => o.PlacedAt)
            .Skip(q.Page * q.Size)
            .Take(q.Size)
            .Select(o => new OrderSummaryDto(
                o.Id,
                o.PlacedAt,
                o.Status,
                o.Lines.Sum(l => l.Quantity * l.UnitPrice)))
            .ToListAsync(ct);

        return new PagedResult<OrderSummaryDto>(items, total, q.Page, q.Size);
    }
}

3.4 The minimal dispatcher

public sealed class Dispatcher(IServiceProvider sp)
{
    public Task<TResult> SendAsync<TResult>(ICommand<TResult> cmd, CancellationToken ct)
    {
        var handlerType = typeof(ICommandHandler<,>).MakeGenericType(cmd.GetType(), typeof(TResult));
        dynamic handler = sp.GetRequiredService(handlerType);
        return (Task<TResult>)handler.HandleAsync((dynamic)cmd, ct);
    }

    public Task<TResult> AskAsync<TResult>(IQuery<TResult> q, CancellationToken ct)
    {
        var handlerType = typeof(IQueryHandler<,>).MakeGenericType(q.GetType(), typeof(TResult));
        dynamic handler = sp.GetRequiredService(handlerType);
        return (Task<TResult>)handler.HandleAsync((dynamic)q, ct);
    }
}

// Wire-up β€” Scrutor scans handlers automatically
builder.Services.Scan(s => s.FromAssemblyOf<PlaceOrder>()
    .AddClasses(c => c.AssignableTo(typeof(ICommandHandler<,>)))
        .AsImplementedInterfaces().WithScopedLifetime()
    .AddClasses(c => c.AssignableTo(typeof(IQueryHandler<,>)))
        .AsImplementedInterfaces().WithScopedLifetime());

builder.Services.AddScoped<Dispatcher>();

3.5 Vertical slices β€” folder structure that pays for itself

Once you have one handler per command/query, organise by feature, not by layer. Each slice is a self-contained folder: command/query record, handler, validator, endpoint mapping. Cross-cutting concerns (auth, logging, transactions) live in pipeline behaviours or endpoint filters.

Features/
  Orders/
    Place/
      PlaceOrder.cs            (record + handler)
      PlaceOrderValidator.cs
      PlaceOrderEndpoint.cs
    GetSummary/
      GetCustomerOrderSummary.cs
      GetCustomerOrderSummaryEndpoint.cs
    Cancel/
      CancelOrder.cs
      ...

3.6 Prioritised in-process queues β€” .NET 9

For local back-pressure or priority handling on the command side, .NET 9 added Channel.CreateUnboundedPrioritized<T>. Useful when you have a background command queue (e.g. webhook delivery) and certain commands need to jump ahead.

var channel = Channel.CreateUnboundedPrioritized&lt;DispatchEnvelope&gt;(
    new UnboundedPrioritizedChannelOptions&lt;DispatchEnvelope&gt;
    {
        Comparer = Comparer&lt;DispatchEnvelope&gt;.Create((a, b) =&gt; a.Priority.CompareTo(b.Priority))
    });

await channel.Writer.WriteAsync(new DispatchEnvelope(cmd, Priority: 0), ct); // jumps the queue

4. Pitfalls That Erase the Benefits

  • Treating CQRS as a project template. If your domain has five entities and four screens, a service class is fine. CQRS pays back when the handler-per-use-case discipline reduces merge contention and lets readers and writers evolve independently.
  • Sharing entities between commands and queries. Defeats the entire point. Reads project to DTOs; writes operate on the domain model.
  • Putting business logic in the handler.Handlers orchestrate. Invariants and rules belong in the aggregate. A handler that's 200 lines long is a refactor waiting to happen.
  • Reaching for separate read stores too early.Eventual consistency is a product feature you must ship UX for. Don't adopt it because a blog post said you should. Most slow read paths are fixed by an index, a covering projection, or a Redis cache β€” not by a CDC pipeline.
  • MediatR everywhere, including inside handlers.Cross-handler dispatch is a code smell. If handler A needs handler B's logic, extract a domain service both depend on.
  • Validators that hit the database via the handler. Synchronous validation (shape, ranges) lives in FluentValidation. Asynchronous validation that requires a DB read either lives in the handler (where it belongs) or as an explicit pre-check the handler runs β€” not as a magic pipeline behaviour.

5. Practical Takeaways

  • Adopt CQRS as a folder discipline first. One file, one command or query, one handler. Worry about MediatR/Wolverine later.
  • Read handlers project directly to DTOs. Write handlers operate on aggregates. They share nothing but the database.
  • Keep idempotency at the command boundary, not at the HTTP boundary. Same key, same result, no surprises.
  • Pair CQRS with vertical slice architecture. Cross-cutting concerns go in pipeline behaviours or endpoint filters, never sprinkled in handlers.
  • Defer eventual consistency until the workload requires it. A read replica or a covered index is almost always cheaper than a CDC pipeline.
  • If you take MediatR's licence change as the prompt: a 30-line dispatcher plus Scrutor is the entire framework you need for in-process CQRS.