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)
Could you elaborate on this topic in a follow-up post?
Good question, I'd like to know too.
Exactly! I had the same thought.
This is exactly what I was looking for, thank you!
I have a question: does this also apply to older versions?
Leave a comment