Skip to content

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

Core Concept LearningJuly 4, 20266 min readUpdated July 21, 2026

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. Each layer gets a before/after in real .NET code, along with how CQRS with MediatR fits naturally into the structure.

The dependency direction becomes easier to enforce with the SOLID principles in .NET. For request-level access decisions at the API edge, review ASP.NET Core authorization types.

Dependencies point inward — Domain is the core, not the parent of Infra
Dependencies point inward — Domain is the core, not the parent of Infra

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.

Zoom into one Clean Architecture command crossing an interface
Zoom into one Clean Architecture command crossing an interface

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.
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}
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 read and write models at the application boundary. Commands change state and may return nothing, an ID, or a result needed by the caller; queries return data and should not introduce business side effects. The useful rule is explicit intent, not a universal return-type restriction.

MediatR is one popular in-process dispatcher for this style, not a requirement or .NET standard. Direct application-service calls can preserve the same boundaries with less indirection. Whichever mechanism you choose, keep the database mutation and outbox insert in one transaction; publish external side effects from the outbox after commit.

A command travels through MediatR to a focused handler
A command travels through MediatR to a focused handler

Quick reference

  • Command: requests a state change and returns only what its contract needs — void, ID, or an explicit result are all valid.
  • Query: returns data, no mutation. IRequest<OrderDto> — separate read model from write model.
  • MediatR pipeline behaviors can add cross-cutting concerns; direct decorators or middleware are valid alternatives.
  • Commit aggregate changes and an outbox record atomically; publish email or broker messages after commit with idempotent retries.
  • 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.
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}
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 IOutbox _outbox;11    private readonly IUnitOfWork _unitOfWork;12 13    public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)14    {15        var product = await _products.GetAsync(cmd.ProductId, ct)16            ?? throw new NotFoundException(nameof(Product), cmd.ProductId);17 18        var order = new Order();19        order.AddItem(product, cmd.Quantity); // domain logic here20        order.Place();21 22        await _unitOfWork.ExecuteAsync(async () =>23        {24            await _orders.AddAsync(order, ct);25            await _outbox.AddRangeAsync(order.PopEvents(), ct);26        }, ct); // one DB transaction; a relay publishes after commit27 28        return order.Id;29    }30}31 32// Controller (Presentation layer) — HTTP translation only33[ApiController, Route("api/orders")]34public class OrdersController : ControllerBase35{36    private readonly IMediator _mediator;37 38    [HttpPost]39    public async Task<IActionResult> PlaceOrder(40        [FromBody] PlaceOrderRequest req, CancellationToken ct)41    {42        var orderId = await _mediator.Send(43            new PlaceOrderCommand(req.ProductId, req.Quantity, req.Email), ct);44        return CreatedAtAction(nameof(GetOrder), new { id = orderId }, new { orderId });45    }46}

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.

Application defines the contract; Infrastructure implements it
Application defines the contract; Infrastructure implements it

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.
  • Expose AddInfrastructure from Infrastructure; call it in WebApi's Program.cs composition root.
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}
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// MyApp.WebApi/Program.cs — composition root27// WebApi references Infrastructure so it can select concrete adapters.28builder.Services.AddInfrastructure(builder.Configuration);29 30// Unit test — no EF Core, no database31var repo = new FakeOrderRepository();32var handler = new PlaceOrderHandler(repo, fakeProducts, fakeOutbox, fakeUnitOfWork);33var 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

Separate class-library projects make dependency direction visible through explicit project references. The compiler prevents Domain from using EF Core only while Domain has no EF package/project reference; adding that reference would compile, so enforce the intended graph in review or with architecture tests.

A typical solution has MyApp.Domain (no outer references), MyApp.Application (references Domain), MyApp.Infrastructure (references Application and Domain), MyApp.WebApi (the composition root, references Application and Infrastructure to register concrete adapters), and tests with deliberate references.

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 and Infrastructure at the composition root; controllers depend on application contracts.
  • 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 expose dependency direction; architecture tests or CI rules catch forbidden references someone could otherwise add.

Key takeaway

Clean Architecture is a discipline of dependency management, not a promise that every feature needs MediatR or that infrastructure is cost-free to replace. Keep business rules independent, choose explicit use-case boundaries, and let the WebApi composition root select concrete adapters. Use transactions and an outbox when a command couples database state to external side effects.

Practice (25 min): run dotnet list reference for Domain, Application, Infrastructure, and WebApi. Assert Domain has no outer reference, Application references only Domain, and WebApi references Infrastructure only for composition. Add an architecture test that fails when a Domain type depends on an EF Core namespace, then run it once with a deliberate forbidden dependency to prove the guard works.

Share:

Related Articles

A customer sees “payment pending” after checkout, retries, and is charged twice. The design question is not whether the

Read

In enterprise .NET applications, traditional Create-Read-Update-Delete (CRUD) architectures suffer when handling complex

Read

Shipping faster in .NET is less about memorizing NuGet packages and more about knowing which job needs a tool: identity,

Read

Explore this topic

Keep learning

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