
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:
- Business logic is testable - Unit test the domain without a database
- Infrastructure is swappable - Change from SQL Server to PostgreSQL by swapping one project
- The codebase scales - New features follow the same pattern
- Onboarding is faster - Developers know where to find things
Getting Started
If you're adding Clean Architecture to an existing codebase:
- Start with one feature - Don't refactor everything at once
- Extract domain entities - Move business rules out of services
- Define interfaces - Create boundaries between layers
- 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.