Clean Architecture in .NET
Clean Architecture is one of those ideas that sounds obvious in a conference talk and deeply confusing the moment you try to apply it to a real project. Every team that adopts it seems to end up with a slightly different folder structure, a slightly different interpretation of what belongs in the “domain,” and the same recurring argument about whether repositories belong in the application layer or the infrastructure layer.
This post is a practical guide — not a theory lecture. We’ll build a small but realistic .NET solution, make intentional decisions at each layer, and explain the why behind every choice.
The Four Layers
Clean Architecture defines four concentric layers. The inner layers know nothing about the outer ones. This is the only rule that actually matters.
- Domain — your business entities, value objects, and domain events. No dependencies. Period.
- Application — use cases, commands, queries, and interfaces your application needs. Depends only on Domain.
- Infrastructure — EF Core, third-party APIs, file system, email. Implements application interfaces.
- Presentation — ASP.NET controllers, Blazor components, minimal API endpoints.
The dependency rule: outer rings depend inward. Infrastructure depends on Application. Application depends on Domain. Nothing depends on Infrastructure except the composition root.
Setting Up the Solution
dotnet new sln -n MyApp
dotnet new classlib -n MyApp.Domain
dotnet new classlib -n MyApp.Application
dotnet new classlib -n MyApp.Infrastructure
dotnet new webapi -n MyApp.Api
dotnet sln add MyApp.Domain MyApp.Application MyApp.Infrastructure MyApp.Api
dotnet add MyApp.Application reference MyApp.Domain
dotnet add MyApp.Infrastructure reference MyApp.Application
dotnet add MyApp.Api reference MyApp.Infrastructure MyApp.Application
The API project is the composition root. It’s the only place that knows about everything — and that’s intentional.
The Domain Layer
Keep this ruthlessly clean. No EF Core. No MediatR. No nothing.
// MyApp.Domain/Entities/Order.cs
public class Order
{
public Guid Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
private readonly List<OrderLine> _lines = [];
private Order() { } // EF needs this, but it's hidden
public static Order Create(CustomerId customerId)
{
return new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId
};
}
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (quantity <= 0) throw new DomainException("Quantity must be positive.");
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
public Money Total => _lines.Aggregate(Money.Zero, (sum, l) => sum + l.Subtotal);
}
Notice: no setters. State changes go through methods that enforce invariants. This is the point of putting logic in the domain.
The Application Layer
This is where use cases live. Each use case is a command or query handler.
// MyApp.Application/Orders/CreateOrder/CreateOrderCommand.cs
public record CreateOrderCommand(Guid CustomerId, List<OrderLineDto> Lines) : IRequest<Guid>;
// MyApp.Application/Orders/CreateOrder/CreateOrderHandler.cs
public class CreateOrderHandler(IOrderRepository orders, IUnitOfWork uow)
: IRequestHandler<CreateOrderCommand, Guid>
{
public async Task<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(new CustomerId(cmd.CustomerId));
foreach (var line in cmd.Lines)
order.AddLine(new ProductId(line.ProductId), line.Quantity, new Money(line.Price));
orders.Add(order);
await uow.CommitAsync(ct);
return order.Id;
}
}
The IOrderRepository and
IUnitOfWork interfaces are defined here in Application.
Infrastructure implements them. The handler never touches
DbContext directly.
The Infrastructure Layer
// MyApp.Infrastructure/Persistence/OrderRepository.cs
public class OrderRepository(AppDbContext db) : IOrderRepository
{
public void Add(Order order) => db.Orders.Add(order);
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
=> await db.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id.Value, ct);
}
EF Core configuration (Fluent API) lives in
Infrastructure/Persistence/Configurations/. EF Core
knows nothing about Application or Domain business logic —
it’s just a persistence mechanism.
Common Pitfalls
Pitfall 1: Putting business logic in handlers.
The handler orchestrates. The domain enforces rules. If you’re
writing if (quantity > stock) throw in a handler,
that rule belongs on the aggregate.
Pitfall 2: Exposing IQueryable from repositories.
This leaks persistence details into Application. Use specification
objects or dedicated query services instead.
Pitfall 3: One repository per entity.
Not every entity needs a repository. Aggregates have repositories.
OrderLine is accessed through Order, not
through its own IOrderLineRepository.
Wiring It Up
In Program.cs (the composition root):
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(CreateOrderCommand).Assembly));
That’s it. The API project is the only place that knows
OrderRepository exists. Every other layer works through
interfaces.
Closing Thoughts
Clean Architecture isn’t about the number of projects or following a specific folder naming convention. It’s about one thing: keeping business logic testable and independent of infrastructure decisions. Whether you use EF Core today and switch to Dapper tomorrow, your domain and application layers shouldn’t care. If they do, the architecture isn’t clean — it’s just layered.
Start simple. Add layers when the codebase demands it, not before.