"Clean Architecture" gets a bad reputation from projects that turn four layers into forty folders and a dependency graph nobody can reason about. Used well, it's simpler than that: keep your business rules free of framework and infrastructure concerns, and make the dependencies point inward. Everything else is a detail.
- Only Infrastructure and the API layer are allowed to know about EF Core, HTTP, and the cloud. Domain and Application stay framework-free.
- Application defines the interfaces; Infrastructure implements them — never the other way around.
- Use MediatR (or a hand-rolled equivalent) to keep controllers thin and use cases explicit.
- Don't add a layer you can't justify with a concrete requirement — three well-run layers beat six theoretical ones.
The four layers
A typical .NET 10 solution we ship looks like this:
src/
Domain/ # entities, value objects, domain events. No NuGet deps beyond the BCL.
Application/ # use cases (commands/queries), interfaces for infrastructure, validation.
Infrastructure/ # EF Core, external APIs, file storage, message queues.
Api/ # ASP.NET Core minimal APIs, DI composition root.
tests/
Domain.Tests/
Application.Tests/
The rule that matters: dependencies only point inward. Domain knows nothing about Application. Application knows nothing about Infrastructure or Api. Infrastructure and Api both depend on Application and Domain, never the reverse.
Domain: entities that protect their own invariants
Keep entities honest — they should never be constructible in an invalid state.
namespace Domain.Orders;
public class Order
{
private readonly List<OrderLine> _lines = new();
public Guid Id { get; }
public CustomerId CustomerId { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Order(Guid id, CustomerId customerId)
{
Id = id;
CustomerId = customerId;
Status = OrderStatus.Draft;
}
public void AddLine(Sku sku, int quantity)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify a submitted order.");
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
_lines.Add(new OrderLine(sku, quantity));
}
public void Submit()
{
if (_lines.Count == 0)
throw new InvalidOperationException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
}
}
Application: use cases, not services
Instead of a generic OrderService with a dozen methods, define one class per use case. It reads better, tests better, and makes the blast radius of a change obvious.
public record SubmitOrderCommand(Guid OrderId) : IRequest;
public class SubmitOrderHandler : IRequestHandler<SubmitOrderCommand>
{
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _unitOfWork;
public SubmitOrderHandler(IOrderRepository orders, IUnitOfWork unitOfWork)
{
_orders = orders;
_unitOfWork = unitOfWork;
}
public async Task Handle(SubmitOrderCommand request, CancellationToken ct)
{
var order = await _orders.GetAsync(request.OrderId, ct)
?? throw new NotFoundException(nameof(Order), request.OrderId);
order.Submit();
await _unitOfWork.SaveChangesAsync(ct);
}
}
IOrderRepository and IUnitOfWork are defined in Application — Infrastructure provides the EF Core implementation. Application never references Microsoft.EntityFrameworkCore.
Infrastructure: the only layer allowed to get its hands dirty
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public Task<Order?> GetAsync(Guid id, CancellationToken ct) =>
_db.Orders.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
Wiring it together
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<SubmitOrderHandler>());
var app = builder.Build();
app.MapPost("/orders/{id}/submit", async (Guid id, ISender sender) =>
{
await sender.Send(new SubmitOrderCommand(id));
return Results.NoContent();
});
app.Run();
The Api project is the composition root — the only place that knows every concrete type exists. Endpoints stay thin: parse the request, send a command or query, translate the result to an HTTP response.
SubmitOrderHandler only depends on interfaces, you can unit test it with an in-memory fake repository and no database, no web server, and no test containers. Save the integration tests (real Postgres via Testcontainers) for the handful of paths where the SQL itself is the risk.When to stop adding layers
We don't reach for a separate domain services layer, a generic repository base class, or a fully event-sourced write model unless there's a concrete reason — multiple write models, an audit requirement, or genuinely divergent read/write performance needs. Four layers and use-case classes cover the large majority of business applications cleanly. Add complexity when the system asks for it, not before.