Clean Architecture in .NET: Structuring Projects That Last

Clean Architecture, popularised by Robert C. Martin, is one of the most widely adopted architectural patterns in the .NET ecosystem. Its central idea is simple: dependencies point inward, and your domain logic never depends on infrastructure concerns. Let's look at how to implement it properly in a .NET solution.

The Layers

A typical Clean Architecture solution has four projects:

The dependency rule is strict: each layer may only reference layers closer to the centre. Infrastructure and Presentation both depend on Application, which depends on Domain. Domain depends on nothing.

Project References

MyApp.Domain          → (no references)
MyApp.Application     → MyApp.Domain
MyApp.Infrastructure  → MyApp.Application
MyApp.Api             → MyApp.Application, MyApp.Infrastructure

The API project references Infrastructure only to wire up dependency injection. It never calls infrastructure types directly.

Domain Layer

Your domain layer contains entities and repository interfaces — not implementations.

Order.cs
namespace MyApp.Domain.Entities;

public class Order
{
    public Guid Id { get; private set; }
    public string CustomerEmail { get; private set; }
    public List<OrderLine> Lines { get; private set; } = [];
    public DateTime CreatedAt { get; private set; }

    public Order(string customerEmail)
    {
        Id = Guid.NewGuid();
        CustomerEmail = customerEmail;
        CreatedAt = DateTime.UtcNow;
    }

    public void AddLine(string product, int quantity, decimal unitPrice)
    {
        Lines.Add(new OrderLine(product, quantity, unitPrice));
    }

    public decimal Total => Lines.Sum(l => l.Quantity * l.UnitPrice);
}
IOrderRepository.cs
namespace MyApp.Domain.Interfaces;

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
}

Application Layer

The application layer orchestrates use cases. It defines commands, queries, and their handlers.

CreateOrderHandler.cs
namespace MyApp.Application.Orders.Commands;

public record CreateOrderCommand(string CustomerEmail, List<OrderLineDto> Lines);

public record OrderLineDto(string Product, int Quantity, decimal UnitPrice);

public class CreateOrderHandler
{
    private readonly IOrderRepository _orders;
    private readonly IUnitOfWork _unitOfWork;

    public CreateOrderHandler(IOrderRepository orders, IUnitOfWork unitOfWork)
    {
        _orders = orders;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> HandleAsync(CreateOrderCommand command, CancellationToken ct)
    {
        var order = new Order(command.CustomerEmail);

        foreach (var line in command.Lines)
        {
            order.AddLine(line.Product, line.Quantity, line.UnitPrice);
        }

        await _orders.AddAsync(order, ct);
        await _unitOfWork.SaveChangesAsync(ct);

        return order.Id;
    }
}

Notice that the handler depends on IOrderRepository and IUnitOfWork — both defined in the Domain or Application layer. It has no knowledge of Entity Framework, SQL, or any other infrastructure detail.

Infrastructure Layer

This is where you implement the interfaces defined in inner layers.

OrderRepository.cs
namespace MyApp.Infrastructure.Persistence;

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context) => _context = context;

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct) =>
        await _context.Orders
            .Include(o => o.Lines)
            .FirstOrDefaultAsync(o => o.Id == id, ct);

    public async Task AddAsync(Order order, CancellationToken ct) =>
        await _context.Orders.AddAsync(order, ct);
}

Wiring It Up

In your API project's Program.cs, register everything:

Program.cs
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
builder.Services.AddScoped<CreateOrderHandler>();

Common Mistakes

Leaking infrastructure into application logic. If your handler references DbContext directly, you've broken the dependency rule. Always go through an abstraction.

Anaemic domain models. If your entities are just property bags and all logic lives in handlers, you're missing the point. Push business rules into entities where they belong.

Over-engineering small projects. Clean Architecture adds ceremony. For a simple CRUD API with three endpoints, a single-project structure with folders is perfectly fine. Reach for Clean Architecture when your domain is complex enough to justify the separation.

When to Use It

Clean Architecture shines when you have complex business logic that benefits from isolation, when multiple teams work on the same codebase, or when you need to swap infrastructure components (say, moving from SQL Server to PostgreSQL). It's not a universal default — it's a tool for managing complexity at scale.

The key takeaway: the dependency rule is everything. If your inner layers never reference outer layers, you can test, refactor, and evolve your domain independently of any framework or database. That's the real value.