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.
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
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<DispatchEnvelope>(
new UnboundedPrioritizedChannelOptions<DispatchEnvelope>
{
Comparer = Comparer<DispatchEnvelope>.Create((a, b) => a.Priority.CompareTo(b.Priority))
});
await channel.Writer.WriteAsync(new DispatchEnvelope(cmd, Priority: 0), ct); // jumps the queue4. 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.

