Clean Architecture in .NET: Beyond the Todo App
Clean Architecture promises separation of concerns and testability. But most tutorials show trivial examples — a Todo app doesn't reveal the real trade-offs. Here's how I apply Clean Architecture in production .NET projects, including the parts that aren't in the textbooks.
The Layers, Practically
┌─────────────────────────────┐
│ Presentation / API │ ← Controllers, Middleware
├─────────────────────────────┤
│ Application │ ← Use Cases, DTOs, Interfaces
├─────────────────────────────┤
│ Domain │ ← Entities, Value Objects, Rules
├─────────────────────────────┤
│ Infrastructure │ ← EF Core, Service Bus, HTTP
└─────────────────────────────┘The dependency rule is simple: inner layers don't reference outer layers. The Domain layer knows nothing about Entity Framework, HTTP, or Azure Service Bus.
Domain Layer: Keep It Pure
The domain layer contains entities, value objects, and business rules. No framework dependencies. No `[JsonProperty]` attributes. No `IServiceProvider`.
public class Order
{
private readonly List<OrderLine> _lines = [];
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total => _lines.Aggregate(
Money.Zero, (sum, line) => sum + line.Total);
public void AddLine(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a submitted order.");
_lines.Add(new OrderLine(product, quantity));
}
public void Submit()
{
if (!_lines.Any())
throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
}
}Notice: no `DbSet`, no `async`, no infrastructure concerns. This code is trivially testable.
Application Layer: Orchestration, Not Logic
Use cases coordinate domain objects and infrastructure. They should be thin — if your use case has complex `if/else` logic, that logic probably belongs in the domain.
public class SubmitOrderHandler
: IRequestHandler<SubmitOrderCommand, OrderResult>
{
private readonly IOrderRepository _orders;
private readonly IEventPublisher _events;
public async Task<OrderResult> Handle(
SubmitOrderCommand cmd, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(cmd.OrderId, ct)
?? throw new NotFoundException(cmd.OrderId);
order.Submit();
await _orders.SaveAsync(order, ct);
await _events.PublishAsync(
new OrderSubmittedEvent(order.Id), ct);
return new OrderResult(order.Id, order.Status);
}
}The handler doesn't know *how* the event is published (Service Bus? In-memory?) or *how* the order is persisted (SQL? Cosmos?). That's the power of the dependency inversion.
Where It Gets Real
The Mapping Problem
In production, you have mappings everywhere: domain ↔ persistence, domain ↔ API, domain ↔ events. This is the unglamorous reality of Clean Architecture:
// EF Core configuration for the domain entity
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Status).HasConversion<string>();
builder.OwnsMany(o => o.Lines, lines =>
{
lines.WithOwner();
lines.Property(l => l.Quantity);
});
}
}Yes, it's more code than putting `[Column]` on your domain entity. The trade-off is that your domain stays clean and your persistence strategy is changeable.
Cross-Cutting Concerns
Logging, validation, and authorization don't fit neatly into any layer. I use MediatR pipeline behaviors to handle them:
public class ValidationBehavior<TReq, TRes>
: IPipelineBehavior<TReq, TRes>
{
private readonly IEnumerable<IValidator<TReq>> _validators;
public async Task<TRes> Handle(TReq request,
RequestHandlerDelegate<TRes> next, CancellationToken ct)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}When Clean Architecture Is Overkill
Not every project needs this. A simple CRUD API with 5 endpoints and no business logic? A vertical slice or minimal API approach is perfectly fine. Clean Architecture earns its complexity when:
- Business rules are complex and change frequently
- Multiple infrastructure integrations are involved
- The project will be maintained for years
- Multiple teams work on different parts
Key Takeaways
- Keep the domain layer pure — no framework dependencies
- Application layer orchestrates, domain layer decides
- Accept the mapping overhead as the cost of decoupling
- Use pipeline behaviors for cross-cutting concerns
- Don't apply Clean Architecture to simple CRUD — match complexity to the problem
Share this article
About the Author
Georg is a senior solution architect specializing in .NET, Azure, and Dynamics 365. He helps organizations design and build scalable, maintainable enterprise systems. When he's not writing code, he's writing about it here.
Learn more about Georg