"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.

Key takeaways
  • 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:

bash
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, value objects Application use cases, interfaces Infrastructure & Api EF Core, HTTP, cloud SDKs dependencies always point inward Api / Infrastructure ASP.NET Core, EF Core, message queues, cloud SDKs — the only layers allowed to know concrete tech. Application Use-case classes (commands/queries) and the interfaces Infrastructure implements. Domain Entities and value objects. No NuGet dependency beyond the BCL.
Dependencies always point inward — only the outer ring knows about concrete frameworks and infrastructure

Domain: entities that protect their own invariants

Keep entities honest — they should never be constructible in an invalid state.

csharp
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.

csharp
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

csharp
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

csharp
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.

Testing payoffBecause 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.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.