Clean Architecture with .NET cover image

Clean Architecture with .NET

By Julian Krause · Tuesday, July 1, 2025 · ~3 min read

Clean Architecture, popularized by Robert C. Martin, provides a dependency structure that keeps your business logic independent from frameworks, databases, and external services. Here is how I implement it in .NET projects, along with the trade-offs you need to consider.

The Layer Structure

In a typical .NET Clean Architecture project, I use four layers with strict dependency rules. Dependencies always point inward.

src/
  MyApp.Domain/          # Entities, value objects, domain events (no dependencies)
  MyApp.Application/     # Use cases, interfaces, DTOs (depends on Domain)
  MyApp.Infrastructure/  # EF Core, external APIs, file system (depends on Application)
  MyApp.Api/             # Controllers, middleware, DI composition (depends on all)

The critical rule: Domain and Application layers have zero references to infrastructure concerns. No EF Core attributes on entities. No HttpClient in use cases. No IConfiguration in domain logic.

Domain Layer

The domain layer contains your entities and business rules with no external dependencies whatsoever.

// Domain/Entities/Order.cs
public class Order
{
    public Guid Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    public Money Total => _lines.Aggregate(
        Money.Zero("EUR"),
        (sum, line) => sum + line.LineTotal);

    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Cannot modify a submitted order.");

        _lines.Add(new OrderLine(productId, quantity, unitPrice));
    }

    public void Submit()
    {
        if (!_lines.Any())
            throw new DomainException("Cannot submit an empty order.");

        Status = OrderStatus.Submitted;
    }
}

Application Layer

The application layer defines use cases and the interfaces that infrastructure must implement.

// Application/Interfaces/IOrderRepository.cs
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    Task SaveChangesAsync(CancellationToken ct = default);
}

// Application/UseCases/SubmitOrderCommand.cs
public record SubmitOrderCommand(Guid OrderId) : IRequest<Result>;

public class SubmitOrderHandler : IRequestHandler<SubmitOrderCommand, Result>
{
    private readonly IOrderRepository _orders;
    private readonly IEventPublisher _events;

    public SubmitOrderHandler(IOrderRepository orders, IEventPublisher events)
    {
        _orders = orders;
        _events = events;
    }

    public async Task<Result> Handle(SubmitOrderCommand request, CancellationToken ct)
    {
        var order = await _orders.GetByIdAsync(request.OrderId, ct);
        if (order is null) return Result.NotFound();

        order.Submit();
        await _orders.SaveChangesAsync(ct);
        await _events.PublishAsync(new OrderSubmitted(order.Id), ct);

        return Result.Success();
    }
}

Infrastructure Layer

Infrastructure implements the interfaces defined by the application layer.

// Infrastructure/Persistence/OrderRepository.cs
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);

    public async Task SaveChangesAsync(CancellationToken ct)
        => await _context.SaveChangesAsync(ct);
}

The DI Composition Root

The API project serves as the composition root where everything is wired together.

// Api/Program.cs
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IEventPublisher, MediatREventPublisher>();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(SubmitOrderHandler).Assembly));

When Clean Architecture Is Overkill

Not every project needs this level of separation. For a simple CRUD API with minimal business logic, Clean Architecture adds ceremony without proportional benefit. I reach for it when the domain is complex enough that I genuinely need to test business rules in isolation, or when I expect the infrastructure to change over time.

The goal is not architectural purity. The goal is maintainable software that can evolve with changing requirements.


Comments (3)

David Sunday, February 22, 2026 5:08 PM

Could you elaborate on this topic in a follow-up post?

Greta Thursday, February 19, 2026 8:08 PM

Good question, I'd like to know too.

Hannah Saturday, March 7, 2026 11:08 PM

Exactly! I had the same thought.

Elena Monday, February 23, 2026 5:08 PM

This is exactly what I was looking for, thank you!

Felix Tuesday, February 24, 2026 5:08 PM

I have a question: does this also apply to older versions?

Leave a Comment