S

super-dev — full-stack

@super-dev.app · Full-stack · ★ open to work
12.8k subscribers·47 videos·Active since 2017

Full-stack .NET / Angular dev with a DevOps streak on Azure Cloud. Also fluent in Flutter + Firebase. I build products, ship them, and keep them healthy.

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.

The moment someone says CQRS , many teams roll out the heavy artillery: event sourcing, a message bus, two databases. Yet CQRS starts as a modest idea — separate reads from writes — that you can apply without any ceremony at all, by organizing code into vertical slices .

Slice by feature, not by layer

Layered architecture scatters a single feature across five folders: Controllers , Services , Repositories , DTOs , Validators . To understand "create an order" you hop from file to file. The vertical slice flips it: one folder per feature, everything it touches in one place.

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

Each slice is self-contained. You read it top to bottom, you delete it without side effects, and two slices share only the domain — never a catch-all "service".

Command and query, two distinct intents

A command changes state and (ideally) returns nothing but an identifier. A query reads only what the view needs, often bypassing the domain to project straight into a DTO. Modeling them separately clarifies intent:

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 , not in the handler — otherwise you've just moved the "service" into another file.

The mediator, optional

CQRS is often glued to MediatR . The mediator decouples the endpoint from the handler and offers a hook for pipeline behaviors (validation, logging, transactions). It's handy, but it is not CQRS: you can inject the handler directly just as well.

C#
1group.MapPost("/", async (CreateOrder command, ISender sender) =>
2{
3 var id = await sender.Send(command);
4
5 return TypedResults.Created($"/orders/{id}", new { id });
6});

If the application is small, skipping the mediator and calling the handler by hand is perfectly legitimate — less indirection, less magic.

Don't over-engineer

The question to ask on every slice: do I actually need this? Separate databases, async projections, event sourcing — these are answers to specific scaling problems (reads vastly outnumbering writes, an immutable audit trail). Without that problem, they only add latency and consistency bugs. Good CQRS, in 90% of cases, is: distinct commands and queries, a single DbContext , readable slices.

CQRS isn't an architecture, it's a naming discipline . Separate the intents, keep the handlers thin, and add a message bus only the day a metric forces your hand.
super-dev — portfolio.app
// More in .NET
{ }
Strangle a .NET 8 monolith without breaking everything
9 min • 3.2k reads
ƒ()
Minimal APIs + EF Core: a clean .NET 8 API
8 min • 3.1k reads
.NET microservices with gRPC
9 min • 1.8k reads