Stéphane De Todaro — tech lead

@super-dev.app · Tech-lead
Active since 2017

Technical lead and full-stack architect, freelance since 2019. I design, industrialize and operate business platforms on Azure, and I publish open-source software engines.

Back to articles
.NET
.NET

CQRS and vertical slices without the ceremony

Article 3 of 5 — Modern .NET
Minimal APIs, CQRS, gRPC, source generators: clean, testable .NET 8 without the ceremony.

Beyond software engineering, many teams file CQRS in the same drawer as event sourcing, message buses, and duplicated databases. Yet the original idea is modest: separate the read path from the write path. It's applied without extra infrastructure, by organizing code into vertical slices , one feature per folder.

Split by feature, not by layer

Layered architecture splits a feature across five folders: Controllers , Services , Repositories , DTOs , Validators . To follow "create an order" end to end, you jump from file to file, and each folder ends up mixing pieces of dozens of unrelated features.

The vertical slice flips the organization around: one folder per use case, everything about it in the same place.

Bash
1Features/
2 Orders/
3 CreateOrder.cs # command + handler + validator
4 GetOrderById.cs # query + handler
5 ListOrders.cs

A slice contains its request, its handler, the validator that goes with it, and the DTO it returns. It reads top to bottom and deletes without side effects: nothing else depends on it. Two slices only share the domain , never a catch-all "service" that the whole application dips into. Jimmy Bogard, who popularized the approach under the name vertical slice architecture , sums up the guiding constraint: what changes together lives together.

Command and query, two distinct intents

A command changes state and ideally returns only an identifier, or nothing. A query reads only what the view needs and touches nothing. Modeling them as two separate types makes the intent readable straight from the signature.

The distinction isn't new. It extends Bertrand Meyer's Command-Query Separation principle, where a method either changes state or returns a value, never both. Greg Young turned it into an acronym, CQRS, pushing the idea to the scale of a whole model rather than a single method.

C#
1public sealed record CreateOrder(Guid CustomerId, IReadOnlyList<LineItem> Items)
2 : IRequest<Guid>;
3
4public sealed class CreateOrderHandler(AppDbContext db)
5 : IRequestHandler<CreateOrder, Guid>
6{
7 public async Task<Guid> Handle(CreateOrder command, CancellationToken ct)
8 {
9 var order = Order.Create(command.CustomerId, command.Items);
10 db.Orders.Add(order);
11 await db.SaveChangesAsync(ct);
12
13 return order.Id;
14 }
15}

The handler stays thin : it orchestrates, it doesn't reason. The business logic lives in Order.Create , where the aggregate protects its invariants. If it migrates into the handler, you've just renamed the "service" you were trying to escape.

A handler this flat is tested without ceremony: it receives its dependencies as parameters, you instantiate it with an AppDbContext on SQLite or the in-memory provider, call Handle , and inspect the return value. No HTTP server to spin up, no pipeline to simulate.

Two data models for two needs

It's on the read side that the separation pays off, even with a single database. The write side goes through the aggregate because it has to validate rules before mutating state. The read side has no reason to rebuild that aggregate: it projects the table directly into the DTO the caller expects.

C#
1public sealed record GetOrderSummary(Guid OrderId) : IRequest<OrderSummary?>;
2
3public sealed record OrderSummary(Guid Id, string Customer, decimal Total, int LineCount);
4
5public sealed class GetOrderSummaryHandler(AppDbContext db)
6 : IRequestHandler<GetOrderSummary, OrderSummary?>
7{
8 public Task<OrderSummary?> Handle(GetOrderSummary query, CancellationToken ct) =>
9 db.Orders
10 .AsNoTracking()
11 .Where(o => o.Id == query.OrderId)
12 .Select(o => new OrderSummary(
13 o.Id,
14 o.Customer.Name,
15 o.Lines.Sum(l => l.Quantity * l.UnitPrice),
16 o.Lines.Count))
17 .SingleOrDefaultAsync(ct);
18}

AsNoTracking disables the change tracker, which is pointless for a read, and the Select lets EF Core generate a SELECT that only pulls back the projected columns. The query is no longer a prisoner of the write model's shape: it assembles exactly the intended view, sums and joins included.

The day reads become the bottleneck, that same slice becomes the natural anchor point for plugging in Dapper, raw SQL, or a denormalized table, without touching the write side. Nothing forces you to do it until a metric demands it.

The mediator, an implementation detail

CQRS often comes bundled with MediatR . The mediator decouples the endpoint from the handler and provides an anchor point for pipeline behaviors . An endpoint then just sends the message: await sender.Send(command) returns the identifier, which gets wrapped in a TypedResults.Created .

Convenient, but separate: nothing in CQRS requires a mediator. You can inject the handler directly into the endpoint and call it by hand. MediatR has, moreover, moved to a commercial license, which makes the homegrown option more compelling than before: a dispatcher that resolves IRequestHandler<,> from the container and calls Handle fits in about fifteen lines. On a small application, that one less layer of indirection is often worth it.

The endpoint itself can live in the slice's file, exposed through an extension method MapCreateOrder that Program.cs simply calls like a MapGroup . The route, the command, and the handler then all fit in the same file, and nothing about the feature lives elsewhere.

Where to put validation and cross-cutting concerns

Validation, logging, opening a transaction: these concerns recur in every slice. Copying them into every handler is the surest way to miss one. A pipeline behavior factors them out by wrapping every handler at once.

C#
1public sealed class ValidationBehavior<TRequest, TResponse>(
2 IEnumerable<IValidator<TRequest>> validators)
3 : IPipelineBehavior<TRequest, TResponse>
4 where TRequest : notnull
5{
6 public async Task<TResponse> Handle(
7 TRequest request,
8 RequestHandlerDelegate<TResponse> next,
9 CancellationToken ct)
10 {
11 var context = new ValidationContext<TRequest>(request);
12 var failures = validators
13 .Select(v => v.Validate(context))
14 .SelectMany(result => result.Errors)
15 .ToList();
16
17 if (failures.Count != 0)
18 {
19 throw new ValidationException(failures);
20 }
21
22 return await next();
23 }
24}

Each slice declares its FluentValidation validator , the behavior picks it up through injection and triggers it before the handler. Registered once via AddOpenBehavior(typeof(ValidationBehavior<,>)) , it covers commands and queries without repeated code.

The behavior has a cost: it's invisible control flow. Use it sparingly, each one clearly scoped, otherwise you recreate the magic you blamed the mediator for.

Don't over-engineer

The question to ask in front of every slice remains the same: do I actually need this? Separate databases, asynchronous projections, event sourcing answer specific problems: reads massively outnumbering writes, an immutable audit trail, a read model far removed from the write model. Absent that problem, they only add latency and delayed-consistency bugs.

The useful CQRS, in the vast majority of cases: separate commands and queries, a single DbContext , slices you can read without grepping. Everything else waits for a quantified reason.

CQRS starts as a naming discipline before it's an architecture. Separate intents, keep handlers thin, let reads bypass the domain, and only add a message bus the day a measurement forces you to.
Stéphane De Todaro — super-dev.app
// More in .NET
Externalizing SQL in EF Core migrations: procedures and views as versioned files
Aug 14, 2026 • 8 min
Orchestrating without a database: a ProcessAPI / SystemAPI split in .NET
Aug 14, 2026 • 10 min
{{ }}
Interpret, don't compile: a template engine for .NET
Jul 30, 2026 • 5 min