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 wiringBusiness 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.csEach 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, useAsNoTracking, 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.
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 pass1.What is the dependency rule in Clean Architecture?
2.How does vertical slice architecture organize code?
3.What does CQRS separate?
4.What is a domain event?
To complete this lesson
- ○ Solve the exercise
- ○ Pass the quiz (3/4 correct)