.NET Academy

Clean Architecture and CQRS

Structure larger applications in layers or vertical slices, separate commands from queries, and dispatch them with a mediator.

15 min read

You will learn

  • Explain layered and Clean Architecture and the dependency rule
  • Compare layers with vertical slice architecture
  • Explain CQRS and when it helps
  • Implement a minimal mediator

Why architecture?

In a small app, everything can live in Program.cs. As an application grows to dozens of features and several developers, you need a structure that keeps business rules understandable, testable and independent of frameworks and databases that will change over the app's lifetime.

Layered architecture

The traditional approach splits code by technical concern:

Presentation   (API endpoints, controllers, views)
     ↓
Application    (use cases: PlaceOrder, CancelOrder)
     ↓
Domain         (entities, business rules: Order, Money)
     ↓
Infrastructure (EF Core, email, payment SDKs)

Clean Architecture: point dependencies inward

Clean Architecture (also: Onion, Hexagonal / Ports and Adapters) flips the bottom dependency. The domain is at the center and depends on nothing; infrastructure sits at the edge and implements interfaces defined inside:

        ┌───────────────────────────────────────┐
        │  Infrastructure & Presentation        │   EF Core, SMTP, ASP.NET Core
        │   ┌───────────────────────────────┐   │
        │   │  Application (use cases)      │   │   defines IOrderRepository, IEmailSender
        │   │   ┌───────────────────────┐   │   │
        │   │   │  Domain (entities,    │   │   │   pure C#, no framework references
        │   │   │  rules, value objects)│   │   │
        │   │   └───────────────────────┘   │   │
        │   └───────────────────────────────┘   │
        └───────────────────────────────────────┘
              dependencies point inward →

A typical solution:

Shop.Domain           — Order, OrderLine, Money, domain rules
Shop.Application      — PlaceOrderHandler, IOrderRepository, DTOs
Shop.Infrastructure   — ShopDbContext, EfOrderRepository, SmtpEmailSender
Shop.Api              — Program.cs, endpoints, DI wiring

Business rules can be unit-tested without a database or web server, and swapping SQL Server for PostgreSQL touches only infrastructure.

Vertical slices

Layers spread one feature across many folders: adding "cancel order" touches a controller, a service, a repository, DTOs… Vertical slice architecture groups code by feature instead:

Features/
  Orders/
    PlaceOrder.cs        ← request, validator, handler and endpoint together
    CancelOrder.cs
    GetOrderDetails.cs
  Products/
    SearchProducts.cs

Each slice can be as simple or as sophisticated as it needs. Many teams combine slices for application code with a shared, rich domain model.

CQRS: commands and queries

Command Query Responsibility Segregation separates:

  • Commands — change state: PlaceOrder, CancelOrder. Validate, apply business rules, save. Return little (an id, or nothing).
  • Queries — read state: GetOrderDetails, SearchProducts. No side effects; free to project straight into DTOs, use AsNoTracking, Dapper, or a read-optimized store.
public record PlaceOrder(int CustomerId, List<LineDto> Lines) : IRequest<int>;
 
public class PlaceOrderHandler(ShopDb db) : IRequestHandler<PlaceOrder, int>
{
    public async Task<int> Handle(PlaceOrder command, CancellationToken ct)
    {
        var order = Order.Create(command.CustomerId, command.Lines);   // domain rules inside
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
        return order.Id;
    }
}

CQRS at this level is just a clear way to organize use cases. "Full" CQRS with separate read and write databases is a heavier tool for specific scaling needs.

The mediator

Endpoints shouldn't need to know which class handles a request. A mediator dispatches each request to its registered handler:

app.MapPost("/orders", async (PlaceOrder command, IMediator mediator) =>
    Results.Created($"/orders/{await mediator.Send(command)}", null));

MediatR is the popular library; it also supports pipeline behaviors — decorators around every handler for validation, logging or transactions. In the exercise you'll build the core idea in a few lines.

Domain events

When an order is placed, several things should follow — email, stock reservation, analytics. Rather than calling them all from the handler, the domain raises an event:

public class Order
{
    private readonly List<object> events = [];
    public IReadOnlyList<object> Events => events;
 
    public static Order Create(...) { var o = new Order(...); o.events.Add(new OrderPlaced(o.Id)); return o; }
}

After saving, the events are dispatched to handlers — in-process, or via a message broker to other services.

Don't over-architect

Four projects, a mediator and domain events for a CRUD app with five tables is overkill. Start simple (a single project with feature folders), and introduce structure as complexity actually appears.

Key takeaways

  • Clean Architecture keeps the domain at the center; dependencies point inward, infrastructure implements inner interfaces.
  • Vertical slices organize code by feature, keeping each use case together.
  • CQRS separates commands (change state) from queries (read state).
  • A mediator dispatches requests to handlers; domain events decouple side effects. Use as much structure as the problem needs.

Exercise

Implement a tiny mediator, the idea behind libraries like MediatR. Register stores a handler for one request type; Send finds the handler for the request's runtime type and returns its result, or throws InvalidOperationException if no handler is registered.

Program.cs
Loading editor…

C# runtime not loaded

Hint 1

Keep a Dictionary<Type, Func<object, object>> keyed by typeof(TRequest).

Hint 2

Register: handlers[typeof(TRequest)] = request => handler((TRequest)request); — the lambda adapts the typed handler to object.

Hint 3

Send: if (handlers.TryGetValue(request.GetType(), out var handler)) return handler(request); otherwise throw new InvalidOperationException(...).

Show solution
var mediator = new Mediator();
mediator.Register<GetOrder>(q => $"Order {q.Id}");
mediator.Register<PlaceOrder>(c => $"Placed {c.Quantity} × {c.Product}");

Console.WriteLine(mediator.Send(new GetOrder(42)));
Console.WriteLine(mediator.Send(new PlaceOrder("Pen", 3)));

record GetOrder(int Id);
record PlaceOrder(string Product, int Quantity);
record CancelOrder(int Id);

class Mediator
{
    private readonly Dictionary<Type, Func<object, object>> handlers = new();

    public void Register<TRequest>(Func<TRequest, object> handler) =>
        handlers[typeof(TRequest)] = request => handler((TRequest)request);

    public object Send(object request) =>
        handlers.TryGetValue(request.GetType(), out var handler)
            ? handler(request)
            : throw new InvalidOperationException($"No handler registered for {request.GetType().Name}");
}

Quiz

4 questions · 3 correct to pass
  1. 1.What is the dependency rule in Clean Architecture?

  2. 2.How does vertical slice architecture organize code?

  3. 3.What does CQRS separate?

  4. 4.What is a domain event?

To complete this lesson

  • ○ Solve the exercise
  • ○ Pass the quiz (3/4 correct)