Clean Architecture in .NET: Why It Matters and How to Start
Randall Clapper

by Randall Clapper

24 Jun, 2025

Clean Architecture in .NET: Why It Matters and How to Start

The Problem Clean Architecture Solves

You've seen it before: a .NET application where:

  • Business logic is scattered across controllers, services, and repositories
  • Changing the database requires touching dozens of files
  • Unit tests require spinning up a database
  • Nobody wants to touch the codebase because everything is connected to everything

Clean Architecture solves this by enforcing dependency rules that keep your business logic isolated from infrastructure concerns.


The Core Principle

Clean Architecture has one rule:

Dependencies point inward. Inner layers know nothing about outer layers.

Here's what that looks like:

┌─────────────────────────────────────────┐
│            Infrastructure               │  ← Database, APIs, UI
├─────────────────────────────────────────┤
│              Application                │  ← Use cases, orchestration
├─────────────────────────────────────────┤
│                Domain                   │  ← Entities, business rules
└─────────────────────────────────────────┘
  • Domain knows nothing about Application or Infrastructure
  • Application knows about Domain, but not Infrastructure
  • Infrastructure knows about everything (it's the "dirty" layer)

Project Structure

Here's how we typically structure a Clean Architecture .NET solution:

src/
├── Domain/
│   ├── Entities/
│   ├── ValueObjects/
│   ├── Enums/
│   └── Exceptions/
├── Application/
│   ├── Common/
│   │   ├── Interfaces/
│   │   └── Behaviors/
│   ├── Features/
│   │   └── Orders/
│   │       ├── Commands/
│   │       └── Queries/
│   └── DependencyInjection.cs
├── Infrastructure/
│   ├── Persistence/
│   ├── ExternalServices/
│   └── DependencyInjection.cs
└── WebApi/
    ├── Controllers/
    └── Program.cs

The Domain Layer

This is your business logic. It should have zero dependencies on other projects.

// 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 void AddLine(Product product, int quantity)
    {
        if (Status != OrderStatus.Draft)
            throw new OrderAlreadySubmittedException(Id);
        
        _lines.Add(new OrderLine(product.Id, product.Price, quantity));
    }

    public void Submit()
    {
        if (!_lines.Any())
            throw new EmptyOrderException(Id);
        
        Status = OrderStatus.Submitted;
    }
}

Notice:

  • No [Required] attributes or EF Core annotations
  • Business rules are in the entity, not in a service
  • Private setters enforce invariants

The Application Layer

This layer orchestrates use cases. It defines interfaces for infrastructure and contains commands/queries.

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

// Application/Features/Orders/Commands/SubmitOrder.cs
public record SubmitOrderCommand(Guid OrderId) : IRequest<Result>;

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

    public SubmitOrderHandler(IOrderRepository orders)
    {
        _orders = orders;
    }

    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();
        return Result.Success();
    }
}

The handler doesn't know about databases, HTTP, or anything else. It just orchestrates.


The Infrastructure Layer

This is where the "dirty" work happens. Implement the interfaces defined in Application.

// Infrastructure/Persistence/OrderRepository.cs
public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;

    public OrderRepository(AppDbContext db) => _db = db;

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

    public async Task AddAsync(Order order, CancellationToken ct)
    {
        await _db.Orders.AddAsync(order, ct);
    }
}

Why This Matters

With Clean Architecture:

  1. Business logic is testable - Unit test the domain without a database
  2. Infrastructure is swappable - Change from SQL Server to PostgreSQL by swapping one project
  3. The codebase scales - New features follow the same pattern
  4. Onboarding is faster - Developers know where to find things

Getting Started

If you're adding Clean Architecture to an existing codebase:

  1. Start with one feature - Don't refactor everything at once
  2. Extract domain entities - Move business rules out of services
  3. Define interfaces - Create boundaries between layers
  4. Add tests - Verify behavior before and after

Want to Go Deeper?

We run hands-on workshops on Clean Architecture for .NET teams. If your codebase has grown unwieldy and you want a structured approach to fixing it, reach out and let's talk.