Clean Architecture in .NET: Structuring Enterprise Applications for Change
How to implement Clean Architecture in .NET enterprise apps — domain layers, use cases, dependency inversion, and CQRS with MediatR.
Enterprise applications live for years, sometimes decades. The frameworks they depend on evolve, business requirements shift, and the teams that maintain them turn over. Clean Architecture is a response to this reality: structure your application so that the most important code — your business logic — is insulated from the details that change most frequently.
This is not an academic exercise. We have applied Clean Architecture on .NET projects ranging from 10 to 200+ developers, and the patterns described here come from what works in practice, not what looks elegant in diagrams.
The Core Principle: Dependency Inversion
Clean Architecture has one non-negotiable rule: dependencies point inward. The domain layer depends on nothing. The application layer depends on the domain. Infrastructure and presentation depend on the application layer. Never the reverse.
Why this matters in practice:
- Your domain logic can be tested without a database, without an HTTP server, without Azure.
- You can replace your ORM, your message broker, or your cloud provider without touching business rules.
- New developers can understand the domain by reading pure C# classes, not by navigating framework configuration.
The litmus test: Can you run your business rules in a unit test that starts in under one second and requires no external resources? If not, your dependencies are pointing the wrong way.
The Four Layers in Practice
Layer 1: Domain
The domain layer contains entities, value objects, domain events, and domain services. It has zero dependencies on external packages — no EF Core, no MediatR, no System.Text.Json.
What belongs here:
- Entities with behavior, not anemic data bags. An
Orderentity should know how toAddLineItem,ApplyDiscount, andCalculateTotal. Business rules live on the entity. - Value objects for concepts with equality based on properties, not identity:
Money,Address,EmailAddress,DateRange. - Domain events that capture significant business occurrences:
OrderPlaced,PaymentReceived,InventoryThresholdBreached. - Domain services for operations that do not belong to a single entity:
PricingService,InventoryAllocationService.
What does not belong here:
- Repository interfaces (those go in the application layer)
- DTOs or API models
- Anything with the word "Handler", "Controller", or "Middleware"
// Domain entity with behavior — not an anemic model
public class Order
{
private readonly List<LineItem> _lineItems = new();
public IReadOnlyCollection<LineItem> LineItems => _lineItems.AsReadOnly();
public OrderStatus Status { get; private set; }
public void AddLineItem(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a submitted order.");
var existing = _lineItems.FirstOrDefault(li => li.ProductId == product.Id);
if (existing is not null)
existing.IncreaseQuantity(quantity);
else
_lineItems.Add(new LineItem(product, quantity));
}
}Layer 2: Application (Use Cases)
The application layer orchestrates domain objects to fulfill use cases. It defines commands, queries, their handlers, and the interfaces that infrastructure must implement.
What belongs here:
- Command and query definitions:
PlaceOrderCommand,GetOrderByIdQuery - Handlers: orchestrate domain logic, call repository interfaces, publish domain events
- Interface definitions:
IOrderRepository,IPaymentGateway,IEmailService - Application-level validation: input validation that is not a domain rule (e.g., "this field is required")
- DTOs for output: response models that the presentation layer consumes
Key design decisions:
- One handler per use case. Do not create "OrderService" god classes with 30 methods. Each use case is its own class.
- Handlers are thin. They load domain objects, invoke domain behavior, persist changes, and publish events. If a handler contains business logic, that logic should move to the domain.
- No framework dependencies. The application layer should not reference ASP.NET Core, EF Core, or Azure SDKs. It references interfaces that infrastructure implements.
Layer 3: Infrastructure
The infrastructure layer provides concrete implementations of the interfaces defined in the application layer.
What belongs here:
- Repository implementations using EF Core, Dapper, or any other data access technology
- External service integrations: payment gateways, email providers, cloud storage
- Message broker producers and consumers: Azure Service Bus, RabbitMQ
- Caching implementations: Redis, in-memory cache
- File system access, HTTP clients, and other I/O operations
Critical rule: Infrastructure classes implement application-layer interfaces. They never expose framework-specific types to the application layer.
// Infrastructure implements the application-layer interface
public class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public SqlOrderRepository(AppDbContext context) => _context = context;
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
=> await _context.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct)
{
await _context.Orders.AddAsync(order, ct);
await _context.SaveChangesAsync(ct);
}
}Layer 4: Presentation (API / UI)
The presentation layer is the entry point — ASP.NET Core controllers or minimal APIs, Blazor components, or a console application. It is the thinnest layer.
Responsibilities:
- HTTP request/response mapping
- Authentication and authorization middleware
- Input deserialization and output serialization
- Swagger/OpenAPI configuration
- Dependency injection container setup (the composition root)
The composition root is where all layers come together. This is the only place that references all projects:
// Program.cs — the composition root
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<IPaymentGateway, StripePaymentGateway>();
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(PlaceOrderCommand).Assembly));CQRS with MediatR: A Practical Implementation
MediatR is not required for Clean Architecture, but it is an excellent fit for implementing CQRS within the application layer.
Commands (Write Operations)
public record PlaceOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items) : IRequest<OrderResult>;
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, OrderResult>
{
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _unitOfWork;
public async Task<OrderResult> Handle(
PlaceOrderCommand request, CancellationToken ct)
{
var order = Order.Create(new CustomerId(request.CustomerId));
foreach (var item in request.Items)
order.AddLineItem(item.ProductId, item.Quantity);
await _orders.AddAsync(order, ct);
await _unitOfWork.CommitAsync(ct);
return new OrderResult(order.Id, order.Status);
}
}Queries (Read Operations)
public record GetOrderQuery(Guid OrderId) : IRequest<OrderDetailDto?>;
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDetailDto?>
{
private readonly IReadOnlyOrderRepository _orders;
public async Task<OrderDetailDto?> Handle(
GetOrderQuery request, CancellationToken ct)
=> await _orders.GetDetailByIdAsync(request.OrderId, ct);
}Cross-Cutting Concerns via Pipeline Behaviors
MediatR pipeline behaviors handle concerns that span multiple handlers:
- Validation: validate commands before they reach the handler using FluentValidation
- Logging: log every command and query with execution time
- Transaction management: wrap command handlers in a database transaction
- Caching: cache query results with configurable expiration
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var failures = _validators
.Select(v => v.Validate(request))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
throw new ValidationException(failures);
return await next();
}
}Testability: The Payoff
Clean Architecture makes testing straightforward because dependencies are injectable and domain logic is pure.
Testing strategy by layer:
| Layer | Test Type | Dependencies | Speed |
|---|---|---|---|
| Domain | Unit tests | None | Milliseconds |
| Application | Unit tests | Mocked interfaces | Milliseconds |
| Infrastructure | Integration tests | Real database (Testcontainers) | Seconds |
| Presentation | Integration tests | TestServer + real pipeline | Seconds |
The key metric: 80% of your business logic tests should be fast unit tests with no external dependencies. If most of your tests require a running database, your architecture has a dependency inversion problem.
Common Mistakes We See
- Anemic domain model: Entities that are just property bags, with all logic in handlers. This negates the entire purpose of the domain layer.
- Leaking infrastructure types: EF Core
DbSet<T>exposed through repository interfaces. The application layer should not know which ORM you use. - Over-engineering for small projects: A 3-page CRUD app does not need four projects and MediatR. Clean Architecture pays off when complexity is high and change is constant.
- Shared kernel abuse: A "Common" project that every layer references, slowly becoming a dumping ground for everything.
When Clean Architecture Is Worth the Investment
Clean Architecture adds structural overhead. It is worth it when:
- The application will be maintained for 3+ years
- Multiple teams contribute to the codebase
- Business rules are complex and evolving
- You need to swap infrastructure (e.g., migrating from on-premises to cloud)
- Testability is a business requirement, not just a nice-to-have
For short-lived applications, prototypes, or simple CRUD systems, a simpler layered architecture is more pragmatic.
At CC Conceptualise, we help enterprise teams adopt Clean Architecture in a way that fits their scale, complexity, and team maturity. If you are structuring a new .NET application or refactoring an existing one, reach out.