Skip to content
Back to blog
.NETArchitectureClean ArchitectureCQRSDDD

Clean Architecture in .NET: Layers, CQRS, and the Dependency Rule

July 4, 202616 min read

Most .NET projects start clean and become entangled within six months. Controllers call repositories that call other services that reach back into controllers. Business logic lives in HTTP action methods. Tests require spinning up a database. A new developer can't tell where to put new code without reading existing code first.

Clean Architecture — popularized by Robert Martin and refined by the .NET community — is a set of structural rules that prevent this entanglement. The central rule is simple: dependencies only point inward. Domain code knows nothing about databases, HTTP, or infrastructure. Infrastructure knows everything about domain, but not the other way around. This article walks through each layer, shows the before/after in real .NET code, and explains how CQRS with MediatR fits naturally into the structure.

The Four Layers

Clean Architecture organizes code into four concentric layers. The innermost layer is Domain: entities, value objects, domain events, and business rules. Domain code has zero dependencies on frameworks, databases, or external services. It's pure C# classes with behavior.

The Application layer contains use cases — what the system can do. It orchestrates domain objects to fulfill user intentions: PlaceOrder, CancelSubscription, GenerateReport. It defines interfaces (IOrderRepository, IEmailSender) that it needs, but doesn't implement them. The Infrastructure layer implements those interfaces: EfOrderRepository, SendGridEmailSender, StripePaymentGateway. The outermost Presentation layer is HTTP controllers, gRPC services, background jobs — the entry points that turn external requests into application commands.

Quick reference

  • Domain: entities, value objects, domain events, domain services. Zero external dependencies.
  • Application: use cases (commands/queries), interfaces, DTOs, validation. Depends on Domain only.
  • Infrastructure: EF Core DbContext, external HTTP clients, file system, email providers. Depends on Application + Domain.
  • Presentation: ASP.NET Core controllers, minimal API endpoints, SignalR hubs. Depends on Application only.
  • The Dependency Rule: source code dependencies point inward. Outer layers know about inner layers. Inner layers know nothing about outer layers.
  • Project structure: one .csproj per layer, with explicit project references enforcing the dependency rule at compile time.

Remember this

The dependency rule is the architecture. If an inner layer needs to reference an outer layer, you're violating the structure.

Domain Layer: Entities and Value Objects

Domain entities are not just data bags. They encapsulate behavior and enforce their own invariants. An Order doesn't let you add a negative quantity — the domain object rejects it. A Product knows how to reserve stock and throws a domain exception when stock is insufficient. The domain layer is where the core business rules live, isolated from all infrastructure concerns.

Value Objects represent concepts with no identity — they're equal when their data is equal. Money, Address, Email, OrderStatus are natural value objects. Using proper value objects instead of primitives (strings and ints) eliminates entire classes of bugs: you can't accidentally compare a price in USD with a price in EUR, or pass a shipping address where a billing address is expected.

Quick reference

  • Entities have identity (Id). Two entities with the same Id are the same entity, even with different field values.
  • Value objects have no identity. Two Money(10, 'USD') objects are equal regardless of reference.
  • Domain exceptions (DomainException) signal business rule violations — not infrastructure failures.
  • Aggregate root: the entry point to a cluster of entities. External code only interacts with the root (Order, not OrderItem).
  • Domain events: publish what happened within the domain. Collected and dispatched by the application layer.
  • Keep the domain layer dependency-free. If you're importing EF Core or ASP.NET in your domain project, stop.
Before
Anemic model — no behavior, logic scattered everywhere
1// Just a data bag — behavior lives in services outside2public class Order3{4    public Guid Id { get; set; }5    public string Status { get; set; } = string.Empty;6    public List<OrderItem> Items { get; set; } = new();7    public decimal Total { get; set; }8}9 10// Business logic buried in a service — hard to test in isolation11public class OrderService12{13    public void AddItem(Order order, Product product, int quantity)14    {15        if (quantity <= 0) throw new Exception("Invalid quantity");16        if (product.Stock < quantity) throw new Exception("Out of stock");17        order.Items.Add(new OrderItem { ProductId = product.Id, Quantity = quantity });18        order.Total += product.Price * quantity;19        product.Stock -= quantity;20    }21}
After
Rich domain model — behavior and invariants in the entity
1// Rich entity — encapsulates behavior and protects invariants2public class Order3{4    public Guid Id { get; private set; } = Guid.NewGuid();5    public OrderStatus Status { get; private set; } = OrderStatus.Pending;6    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();7    public Money Total { get; private set; } = Money.Zero;8 9    private readonly List<OrderItem> _items = new();10    private readonly List<IDomainEvent> _events = new();11 12    public void AddItem(Product product, int quantity)13    {14        if (quantity <= 0)15            throw new DomainException("Quantity must be positive");16 17        product.ReserveStock(quantity); // domain logic stays in domain18 19        _items.Add(OrderItem.Create(product.Id, quantity, product.Price));20        Total += product.Price * quantity;21    }22 23    public void Place()24    {25        if (_items.Count == 0)26            throw new DomainException("Cannot place an empty order");27 28        Status = OrderStatus.Placed;29        _events.Add(new OrderPlacedEvent(Id, Total));30    }31 32    public IReadOnlyList<IDomainEvent> PopEvents()33    {34        var events = _events.ToList();35        _events.Clear();36        return events;37    }38}39 40// Value object — equal by value, immutable41public record Money(decimal Amount, string Currency)42{43    public static Money Zero => new(0, "USD");44 45    public static Money operator +(Money a, Money b)46    {47        if (a.Currency != b.Currency)48            throw new DomainException("Cannot add different currencies");49        return a with { Amount = a.Amount + b.Amount };50    }51}

Remember this

Rich domain models encode business rules as behavior on entities. They're self-validating, framework-free, and trivially testable in isolation.

CQRS with MediatR

CQRS (Command Query Responsibility Segregation) separates reads (queries) from writes (commands) at the application layer. Commands change state and return nothing (or just an ID). Queries return data and change nothing. This separation keeps handlers focused — a command handler doesn't accidentally return data; a query handler doesn't accidentally mutate state.

MediatR is the standard .NET library for this pattern. Each use case is a command or query class, and a handler class. The controller dispatches a command to MediatR, which finds the registered handler and executes it. Controllers become thin — they translate HTTP to commands and commands to HTTP responses, with no business logic.

Quick reference

  • Command: mutates state, returns void or an ID. IRequest<Guid> or IRequest (no return).
  • Query: returns data, no mutation. IRequest<OrderDto> — separate read model from write model.
  • MediatR pipeline behaviors: add cross-cutting concerns (logging, validation, transactions) without touching handlers.
  • FluentValidation + MediatR: add a ValidationBehavior that runs validators before every command handler.
  • One handler per command/query — no god-service with 30 methods.
  • Unit test handlers directly — inject mock repositories, assert on domain behavior. No HTTP setup needed.
Before
Fat controller — HTTP, business logic, and DB all mixed
1[ApiController]2[Route("api/orders")]3public class OrdersController : ControllerBase4{5    private readonly AppDbContext _db;6    private readonly IEmailClient _email;7 8    [HttpPost]9    public async Task<IActionResult> PlaceOrder([FromBody] PlaceOrderRequest req)10    {11        // Business logic in the controller — hard to test, hard to reuse12        var product = await _db.Products.FindAsync(req.ProductId);13        if (product is null) return NotFound();14        if (product.Stock < req.Quantity) return BadRequest("Insufficient stock");15 16        product.Stock -= req.Quantity;17 18        var order = new Order19        {20            Id = Guid.NewGuid(),21            ProductId = req.ProductId,22            Quantity = req.Quantity,23            Total = product.Price * req.Quantity,24            Status = "Placed"25        };26        _db.Orders.Add(order);27        await _db.SaveChangesAsync();28 29        await _email.SendAsync(req.Email, "Your order is confirmed!");30        return Ok(new { order.Id });31    }32}
After
CQRS — thin controller, focused command handler
1// Command (Application layer) — no framework references2public record PlaceOrderCommand(Guid ProductId, int Quantity, string CustomerEmail)3    : IRequest<Guid>;4 5// Handler (Application layer) — pure business logic6public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>7{8    private readonly IOrderRepository _orders;9    private readonly IProductRepository _products;10    private readonly IPublisher _publisher;11 12    public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)13    {14        var product = await _products.GetAsync(cmd.ProductId, ct)15            ?? throw new NotFoundException(nameof(Product), cmd.ProductId);16 17        var order = new Order();18        order.AddItem(product, cmd.Quantity); // domain logic here19        order.Place();20 21        await _orders.AddAsync(order, ct);22 23        foreach (var evt in order.PopEvents())24            await _publisher.Publish(evt, ct); // dispatch domain events25 26        return order.Id;27    }28}29 30// Controller (Presentation layer) — HTTP translation only31[ApiController, Route("api/orders")]32public class OrdersController : ControllerBase33{34    private readonly IMediator _mediator;35 36    [HttpPost]37    public async Task<IActionResult> PlaceOrder(38        [FromBody] PlaceOrderRequest req, CancellationToken ct)39    {40        var orderId = await _mediator.Send(41            new PlaceOrderCommand(req.ProductId, req.Quantity, req.Email), ct);42        return CreatedAtAction(nameof(GetOrder), new { id = orderId }, new { orderId });43    }44}

Remember this

CQRS with MediatR makes every use case a named, testable class. Controllers become HTTP adapters with zero business logic.

Repository Pattern and Infrastructure

The application layer defines interfaces for what it needs from infrastructure — IOrderRepository, IEmailSender, IStorageService. The Infrastructure layer implements them using concrete technology: EF Core, SendGrid, Azure Blob Storage. Domain and Application layers never reference EF Core, never call HttpClient directly, never know if data lives in Postgres or an in-memory store.

This inversion enables testing: in unit tests, inject a fake in-memory repository. In integration tests, inject the real EF Core repository. The application handler is the same code in both cases.

Quick reference

  • Define repository interfaces in the Application layer. Implement them in Infrastructure.
  • Keep repositories focused: one aggregate root per repository. Don't create a generic IRepository<T>.
  • EF Core DbContext lives entirely in the Infrastructure project — domain and application never reference it.
  • For read queries (CQRS), bypass the repository and query directly from EF using projections. Repositories are for aggregate writes.
  • Avoid the N+1 problem in read queries: use .Include() or projections (.Select()), not lazy loading.
  • Register implementations with AddScoped (per HTTP request) in Program.cs using DI.
Before
EF Core leaking into application layer — hard to swap or test
1// Application handler directly uses EF Core2public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto>3{4    private readonly AppDbContext _db; // EF Core reference in Application layer!5 6    public async Task<OrderDto> Handle(GetOrderQuery query, CancellationToken ct)7    {8        return await _db.Orders9            .Include(o => o.Items)10            .Where(o => o.Id == query.OrderId)11            .Select(o => new OrderDto { ... })12            .FirstOrDefaultAsync(ct);13    }14}
After
Interface in Application, implementation in Infrastructure
1// Application layer — defines the contract, knows nothing about EF2public interface IOrderRepository3{4    Task<Order?> GetAsync(Guid id, CancellationToken ct = default);5    Task AddAsync(Order order, CancellationToken ct = default);6    Task<IReadOnlyList<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default);7}8 9// Infrastructure layer — EF Core implementation (references Application + Domain)10public class EfOrderRepository : IOrderRepository11{12    private readonly AppDbContext _db;13 14    public async Task<Order?> GetAsync(Guid id, CancellationToken ct)15        => await _db.Orders16            .Include(o => o.Items)17            .FirstOrDefaultAsync(o => o.Id == id, ct);18 19    public async Task AddAsync(Order order, CancellationToken ct)20    {21        _db.Orders.Add(order);22        await _db.SaveChangesAsync(ct);23    }24}25 26// Registration (Program.cs — Infrastructure layer)27builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();28 29// Unit test — no EF Core, no database30var repo = new FakeOrderRepository();31var handler = new PlaceOrderHandler(repo, fakeProducts, fakePublisher);32var result = await handler.Handle(new PlaceOrderCommand(...), CancellationToken.None);

Remember this

Interfaces in Application, implementations in Infrastructure. The application never knows if it's talking to EF Core, Dapper, or an in-memory fake.

Project Structure and Solution Layout

The cleanest way to enforce Clean Architecture in .NET is with separate class library projects, each with explicit project references. The compiler enforces the dependency rule: if you accidentally add an EF Core reference to the Domain project, the build fails.

A typical solution has four or five projects: MyApp.Domain (no references), MyApp.Application (references Domain), MyApp.Infrastructure (references Application and Domain, contains EF Core, external SDKs), MyApp.WebApi (references Application, wires up DI and HTTP), and MyApp.Tests (references all layers, uses test fakes and integration test helpers).

Quick reference

  • MyApp.Domain: no project references. Classes only — entities, value objects, domain events, exceptions.
  • MyApp.Application: references Domain. MediatR handlers, interfaces, validators, DTOs.
  • MyApp.Infrastructure: references Application + Domain. EF Core, HttpClient wrappers, third-party SDKs.
  • MyApp.WebApi: references Application (not Infrastructure directly — uses DI). Controllers, middleware, Swagger.
  • Infrastructure registration: use extension methods (AddInfrastructure(services, config)) called from WebApi startup.
  • Vertical slices variant: organize by feature (Orders/, Products/) within layers instead of horizontal layers. Works well with CQRS.

Remember this

Separate projects per layer and let project references enforce the dependency rule at compile time. No runtime architecture checks needed.

Key takeaway

Share:

Clean Architecture is not a framework — it's a discipline of dependency management. The rules are few: dependencies point inward, business logic lives in the domain, infrastructure is swappable, use cases are named objects. Following these rules has compounding returns: every new feature is a new handler class, every refactor is scoped to one layer, every test runs without a database.

The biggest mistake teams make is treating it as a folder structure rather than a dependency structure. A folder called 'Domain' that imports EF Core is not Clean Architecture. The architecture lives in the project references, the interfaces, and the discipline of keeping business rules free of infrastructure concerns. Get that right, and the folder names become secondary.

Related Articles

Event SourcingCQRS

Event Sourcing and CQRS are two patterns that often appear together in microservices and domain-driven design — but they

Read

SOLID is five principles for writing object-oriented code that's easy to extend without breaking existing behavior. They

Read

Artificial intelligence is not here to replace .NET developers. It is here to extend what they can build. The fundamenta

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.