Clean Architecture in .NET: A Complete Guide
Master Uncle Bob's Clean Architecture with concentric circles, the dependency rule, and a full CreateOrder use case implemented end-to-end in .NET 8.
Clean Architecture in .NET: A Complete Guide
Clean Architecture, popularized by Robert C. Martin (Uncle Bob), is an architectural approach that separates concerns into concentric layers. The core idea is deceptively simple: dependencies only point inward. The inner layers know nothing about the outer layers. This makes the system independently testable, independently deployable, and resilient to framework churn.
The Concentric Circles
Think of Clean Architecture as four rings on a target:
┌────────────────────────────────────────┐
│ Frameworks & Drivers (outermost) │
│ ┌──────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ ┌────────────────────────────┐ │ │
│ │ │ Application / Use Cases │ │ │
│ │ │ ┌──────────────────────┐ │ │ │
│ │ │ │ Domain (Entities) │ │ │ │
│ │ │ └──────────────────────┘ │ │ │
│ │ └────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
└────────────────────────────────────────┘Each circle represents a different area of software:
- Domain (Entities) — Enterprise business rules. Pure C# objects, no framework dependencies.
- Application (Use Cases) — Application-specific business rules. Orchestrates entities.
- Interface Adapters — Converts data between use cases and external systems (controllers, presenters, gateways).
- Frameworks & Drivers — The outermost ring. EF Core, ASP.NET Core, external APIs, databases.
The Dependency Rule
This is the only rule you must never break:
Source code dependencies must point only inward. Nothing in an inner circle can know anything at all about something in an outer circle.
That means your Domain project has zero references to EF Core, ASP.NET Core, or any NuGet package. Your Application project references only Domain. Your Infrastructure project references Application and Domain. Your API project wires everything together.
Solution Structure
Here is how we structure a Clean Architecture solution in .NET 8:
MyApp/
├── src/
│ ├── MyApp.Domain/ ← No dependencies. Pure C#.
│ ├── MyApp.Application/ ← Depends on Domain only.
│ ├── MyApp.Infrastructure/ ← Depends on Application + Domain.
│ └── MyApp.API/ ← Depends on everything. Entry point.
└── tests/
├── MyApp.Domain.Tests/
├── MyApp.Application.Tests/
└── MyApp.Integration.Tests/Let's build this step by step using an Order management example.
Layer 1: The Domain Layer
The Domain layer is the heart of the application. It contains your business objects and business rules. No framework code. No database code. No HTTP code.
Entities
An entity has a unique identity that runs through time and across multiple representations.
// src/MyApp.Domain/Entities/Order.cs
namespace MyApp.Domain.Entities;
public class Order
{
private readonly List<OrderItem> _items = new();
private Order() { } // For EF Core
public Order(Guid customerId, string shippingAddress)
{
if (customerId == Guid.Empty)
throw new ArgumentException("Customer ID cannot be empty.", nameof(customerId));
if (string.IsNullOrWhiteSpace(shippingAddress))
throw new ArgumentException("Shipping address is required.", nameof(shippingAddress));
Id = Guid.NewGuid();
CustomerId = customerId;
ShippingAddress = shippingAddress;
Status = OrderStatus.Draft;
CreatedAt = DateTime.UtcNow;
}
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public string ShippingAddress { get; private set; } = string.Empty;
public OrderStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public Money TotalAmount => new(_items.Sum(i => i.TotalPrice.Amount),
_items.FirstOrDefault()?.UnitPrice.Currency ?? "USD");
public void AddItem(Guid productId, string productName, Money unitPrice, int quantity)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot add items to a non-draft order.");
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive.", nameof(quantity));
var existingItem = _items.FirstOrDefault(i => i.ProductId == productId);
if (existingItem is not null)
{
existingItem.IncreaseQuantity(quantity);
return;
}
_items.Add(new OrderItem(Id, productId, productName, unitPrice, quantity));
}
public void RemoveItem(Guid productId)
{
var item = _items.FirstOrDefault(i => i.ProductId == productId)
?? throw new InvalidOperationException($"Product {productId} not found in this order.");
_items.Remove(item);
}
public DomainEvent Submit()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Only draft orders can be submitted.");
if (!_items.Any())
throw new InvalidOperationException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
return new OrderSubmittedEvent(Id, CustomerId, TotalAmount);
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Delivered)
throw new InvalidOperationException("Delivered orders cannot be cancelled.");
if (Status == OrderStatus.Cancelled)
return;
Status = OrderStatus.Cancelled;
}
}// src/MyApp.Domain/Entities/OrderItem.cs
namespace MyApp.Domain.Entities;
public class OrderItem
{
private OrderItem() { } // For EF Core
internal OrderItem(Guid orderId, Guid productId, string productName,
Money unitPrice, int quantity)
{
Id = Guid.NewGuid();
OrderId = orderId;
ProductId = productId;
ProductName = productName;
UnitPrice = unitPrice;
Quantity = quantity;
}
public Guid Id { get; private set; }
public Guid OrderId { get; private set; }
public Guid ProductId { get; private set; }
public string ProductName { get; private set; } = string.Empty;
public Money UnitPrice { get; private set; } = null!;
public int Quantity { get; private set; }
public Money TotalPrice => new(UnitPrice.Amount * Quantity, UnitPrice.Currency);
internal void IncreaseQuantity(int additionalQuantity)
{
if (additionalQuantity <= 0)
throw new ArgumentException("Additional quantity must be positive.");
Quantity += additionalQuantity;
}
}// src/MyApp.Domain/Enums/OrderStatus.cs
namespace MyApp.Domain.Enums;
public enum OrderStatus
{
Draft = 0,
Submitted = 1,
Confirmed = 2,
Shipped = 3,
Delivered = 4,
Cancelled = 5
}Value Objects
Value objects have no identity. Two value objects with the same data are equal.
// src/MyApp.Domain/ValueObjects/Money.cs
namespace MyApp.Domain.ValueObjects;
public sealed record Money
{
public decimal Amount { get; init; }
public string Currency { get; init; }
public Money(decimal amount, string currency)
{
if (amount < 0)
throw new ArgumentException("Amount cannot be negative.", nameof(amount));
if (string.IsNullOrWhiteSpace(currency))
throw new ArgumentException("Currency is required.", nameof(currency));
if (currency.Length != 3)
throw new ArgumentException("Currency must be a 3-letter ISO code.", nameof(currency));
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public static Money Zero(string currency) => new(0, currency);
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException(
$"Cannot add {Currency} and {other.Currency}.");
return new Money(Amount + other.Amount, Currency);
}
public Money Subtract(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException(
$"Cannot subtract {other.Currency} from {Currency}.");
if (Amount < other.Amount)
throw new InvalidOperationException("Insufficient funds.");
return new Money(Amount - other.Amount, Currency);
}
public Money Multiply(int multiplier)
{
if (multiplier < 0)
throw new ArgumentException("Multiplier cannot be negative.");
return new Money(Amount * multiplier, Currency);
}
public override string ToString() => $"{Amount:F2} {Currency}";
}// src/MyApp.Domain/ValueObjects/Address.cs
namespace MyApp.Domain.ValueObjects;
public sealed record Address
{
public string Street { get; init; }
public string City { get; init; }
public string PostalCode { get; init; }
public string Country { get; init; }
public Address(string street, string city, string postalCode, string country)
{
if (string.IsNullOrWhiteSpace(street)) throw new ArgumentException("Street required.");
if (string.IsNullOrWhiteSpace(city)) throw new ArgumentException("City required.");
if (string.IsNullOrWhiteSpace(postalCode)) throw new ArgumentException("PostalCode required.");
if (string.IsNullOrWhiteSpace(country)) throw new ArgumentException("Country required.");
Street = street.Trim();
City = city.Trim();
PostalCode = postalCode.Trim();
Country = country.Trim();
}
public override string ToString() => $"{Street}, {City}, {PostalCode}, {Country}";
}Domain Events
Domain events communicate that something meaningful happened in the domain.
// src/MyApp.Domain/Events/DomainEvent.cs
namespace MyApp.Domain.Events;
public abstract record DomainEvent
{
public Guid EventId { get; } = Guid.NewGuid();
public DateTime OccurredOn { get; } = DateTime.UtcNow;
}// src/MyApp.Domain/Events/OrderSubmittedEvent.cs
namespace MyApp.Domain.Events;
public sealed record OrderSubmittedEvent(
Guid OrderId,
Guid CustomerId,
Money TotalAmount
) : DomainEvent;// src/MyApp.Domain/Events/OrderCancelledEvent.cs
namespace MyApp.Domain.Events;
public sealed record OrderCancelledEvent(
Guid OrderId,
string Reason
) : DomainEvent;Repository Interfaces (defined in Domain)
The interface lives in the Domain layer. The implementation lives in Infrastructure. This enforces the dependency rule.
// src/MyApp.Domain/Repositories/IOrderRepository.cs
namespace MyApp.Domain.Repositories;
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IEnumerable<Order>> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default);
Task<bool> ExistsAsync(Guid id, CancellationToken ct = default);
void Add(Order order);
void Update(Order order);
void Remove(Order order);
}// src/MyApp.Domain/Repositories/ICustomerRepository.cs
namespace MyApp.Domain.Repositories;
public interface ICustomerRepository
{
Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<bool> ExistsAsync(Guid id, CancellationToken ct = default);
void Add(Customer customer);
}Layer 2: The Application Layer
The Application layer orchestrates domain objects to fulfill use cases. It defines the application's capabilities. It does not contain business logic — that lives in the Domain.
The Unit of Work Interface
// src/MyApp.Application/Interfaces/IUnitOfWork.cs
namespace MyApp.Application.Interfaces;
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}The CreateOrder Use Case
We implement use cases as commands and queries (CQRS light). Even without MediatR, the pattern is the same.
// src/MyApp.Application/Orders/CreateOrder/CreateOrderCommand.cs
namespace MyApp.Application.Orders.CreateOrder;
public sealed record CreateOrderCommand(
Guid CustomerId,
string ShippingAddress,
IReadOnlyList<CreateOrderItemDto> Items
);
public sealed record CreateOrderItemDto(
Guid ProductId,
string ProductName,
decimal UnitPrice,
string Currency,
int Quantity
);// src/MyApp.Application/Orders/CreateOrder/CreateOrderResult.cs
namespace MyApp.Application.Orders.CreateOrder;
public sealed record CreateOrderResult(
Guid OrderId,
decimal TotalAmount,
string Currency,
int ItemCount
);// src/MyApp.Application/Orders/CreateOrder/CreateOrderHandler.cs
namespace MyApp.Application.Orders.CreateOrder;
public sealed class CreateOrderHandler
{
private readonly IOrderRepository _orderRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IUnitOfWork _unitOfWork;
private readonly IEventPublisher _eventPublisher;
public CreateOrderHandler(
IOrderRepository orderRepository,
ICustomerRepository customerRepository,
IUnitOfWork unitOfWork,
IEventPublisher eventPublisher)
{
_orderRepository = orderRepository;
_customerRepository = customerRepository;
_unitOfWork = unitOfWork;
_eventPublisher = eventPublisher;
}
public async Task<CreateOrderResult> HandleAsync(
CreateOrderCommand command,
CancellationToken ct = default)
{
// Validate customer exists
var customerExists = await _customerRepository.ExistsAsync(command.CustomerId, ct);
if (!customerExists)
throw new NotFoundException($"Customer {command.CustomerId} not found.");
// Create order aggregate
var order = new Order(command.CustomerId, command.ShippingAddress);
// Add items
foreach (var item in command.Items)
{
var unitPrice = new Money(item.UnitPrice, item.Currency);
order.AddItem(item.ProductId, item.ProductName, unitPrice, item.Quantity);
}
// Submit the order — returns domain event
var orderSubmittedEvent = order.Submit();
// Persist
_orderRepository.Add(order);
await _unitOfWork.SaveChangesAsync(ct);
// Publish domain event AFTER persistence
await _eventPublisher.PublishAsync(orderSubmittedEvent, ct);
return new CreateOrderResult(
OrderId: order.Id,
TotalAmount: order.TotalAmount.Amount,
Currency: order.TotalAmount.Currency,
ItemCount: order.Items.Count
);
}
}// src/MyApp.Application/Interfaces/IEventPublisher.cs
namespace MyApp.Application.Interfaces;
public interface IEventPublisher
{
Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
where TEvent : DomainEvent;
}// src/MyApp.Application/Exceptions/NotFoundException.cs
namespace MyApp.Application.Exceptions;
public sealed class NotFoundException : Exception
{
public NotFoundException(string message) : base(message) { }
}// src/MyApp.Application/Exceptions/ValidationException.cs
namespace MyApp.Application.Exceptions;
public sealed class ValidationException : Exception
{
public IReadOnlyDictionary<string, string[]> Errors { get; }
public ValidationException(Dictionary<string, string[]> errors)
: base("One or more validation errors occurred.")
{
Errors = errors;
}
}GetOrder Query
// src/MyApp.Application/Orders/GetOrder/GetOrderQuery.cs
namespace MyApp.Application.Orders.GetOrder;
public sealed record GetOrderQuery(Guid OrderId);// src/MyApp.Application/Orders/GetOrder/OrderDto.cs
namespace MyApp.Application.Orders.GetOrder;
public sealed record OrderDto(
Guid Id,
Guid CustomerId,
string ShippingAddress,
string Status,
DateTime CreatedAt,
decimal TotalAmount,
string Currency,
IReadOnlyList<OrderItemDto> Items
);
public sealed record OrderItemDto(
Guid ProductId,
string ProductName,
decimal UnitPrice,
int Quantity,
decimal TotalPrice,
string Currency
);// src/MyApp.Application/Orders/GetOrder/GetOrderHandler.cs
namespace MyApp.Application.Orders.GetOrder;
public sealed class GetOrderHandler
{
private readonly IOrderRepository _orderRepository;
public GetOrderHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<OrderDto> HandleAsync(GetOrderQuery query, CancellationToken ct = default)
{
var order = await _orderRepository.GetByIdAsync(query.OrderId, ct)
?? throw new NotFoundException($"Order {query.OrderId} not found.");
return MapToDto(order);
}
private static OrderDto MapToDto(Order order) => new(
Id: order.Id,
CustomerId: order.CustomerId,
ShippingAddress: order.ShippingAddress,
Status: order.Status.ToString(),
CreatedAt: order.CreatedAt,
TotalAmount: order.TotalAmount.Amount,
Currency: order.TotalAmount.Currency,
Items: order.Items.Select(i => new OrderItemDto(
ProductId: i.ProductId,
ProductName: i.ProductName,
UnitPrice: i.UnitPrice.Amount,
Quantity: i.Quantity,
TotalPrice: i.TotalPrice.Amount,
Currency: i.UnitPrice.Currency
)).ToList()
);
}Layer 3: The Infrastructure Layer
The Infrastructure layer provides implementations for interfaces defined in the inner layers.
EF Core DbContext
// src/MyApp.Infrastructure/Persistence/AppDbContext.cs
namespace MyApp.Infrastructure.Persistence;
public class AppDbContext : DbContext, IUnitOfWork
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}// src/MyApp.Infrastructure/Persistence/Configurations/OrderConfiguration.cs
namespace MyApp.Infrastructure.Persistence.Configurations;
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.ShippingAddress)
.IsRequired()
.HasMaxLength(500);
builder.Property(o => o.Status)
.HasConversion<string>()
.HasMaxLength(50);
builder.OwnsOne(o => o.TotalAmount, money =>
{
money.Property(m => m.Amount).HasColumnName("TotalAmount").HasPrecision(18, 2);
money.Property(m => m.Currency).HasColumnName("TotalCurrency").HasMaxLength(3);
});
builder.HasMany(o => o.Items)
.WithOne()
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(o => o.Items).UsePropertyAccessMode(PropertyAccessMode.Field);
builder.ToTable("Orders");
}
}// src/MyApp.Infrastructure/Persistence/Configurations/OrderItemConfiguration.cs
namespace MyApp.Infrastructure.Persistence.Configurations;
public class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
public void Configure(EntityTypeBuilder<OrderItem> builder)
{
builder.HasKey(i => i.Id);
builder.Property(i => i.ProductName)
.IsRequired()
.HasMaxLength(200);
builder.OwnsOne(i => i.UnitPrice, money =>
{
money.Property(m => m.Amount).HasColumnName("UnitPrice").HasPrecision(18, 2);
money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
});
builder.ToTable("OrderItems");
}
}Repository Implementation
// src/MyApp.Infrastructure/Persistence/Repositories/OrderRepository.cs
namespace MyApp.Infrastructure.Persistence.Repositories;
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task<IEnumerable<Order>> GetByCustomerIdAsync(
Guid customerId, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Items)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(ct);
}
public async Task<bool> ExistsAsync(Guid id, CancellationToken ct = default)
{
return await _context.Orders.AnyAsync(o => o.Id == id, ct);
}
public void Add(Order order) => _context.Orders.Add(order);
public void Update(Order order) => _context.Orders.Update(order);
public void Remove(Order order) => _context.Orders.Remove(order);
}Event Publisher
// src/MyApp.Infrastructure/Events/InMemoryEventPublisher.cs
namespace MyApp.Infrastructure.Events;
public class InMemoryEventPublisher : IEventPublisher
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<InMemoryEventPublisher> _logger;
public InMemoryEventPublisher(
IServiceProvider serviceProvider,
ILogger<InMemoryEventPublisher> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task PublishAsync<TEvent>(TEvent domainEvent, CancellationToken ct = default)
where TEvent : DomainEvent
{
_logger.LogInformation("Publishing domain event {EventType} with ID {EventId}",
typeof(TEvent).Name, domainEvent.EventId);
var handlers = _serviceProvider
.GetServices<IDomainEventHandler<TEvent>>();
var tasks = handlers.Select(h => h.HandleAsync(domainEvent, ct));
await Task.WhenAll(tasks);
}
}// src/MyApp.Infrastructure/Events/Handlers/OrderSubmittedEmailHandler.cs
namespace MyApp.Infrastructure.Events.Handlers;
public class OrderSubmittedEmailHandler : IDomainEventHandler<OrderSubmittedEvent>
{
private readonly IEmailService _emailService;
private readonly ILogger<OrderSubmittedEmailHandler> _logger;
public OrderSubmittedEmailHandler(
IEmailService emailService,
ILogger<OrderSubmittedEmailHandler> logger)
{
_emailService = emailService;
_logger = logger;
}
public async Task HandleAsync(OrderSubmittedEvent domainEvent, CancellationToken ct)
{
_logger.LogInformation("Sending confirmation email for order {OrderId}",
domainEvent.OrderId);
await _emailService.SendOrderConfirmationAsync(
orderId: domainEvent.OrderId,
customerId: domainEvent.CustomerId,
total: domainEvent.TotalAmount,
ct: ct);
}
}Layer 4: The Presentation Layer (API)
The API layer is the entry point. It translates HTTP requests into commands/queries and calls handlers.
API Controller
// src/MyApp.API/Controllers/OrdersController.cs
namespace MyApp.API.Controllers;
[ApiController]
[Route("api/[controller]")]
[Produces("application/json")]
public class OrdersController : ControllerBase
{
private readonly CreateOrderHandler _createOrderHandler;
private readonly GetOrderHandler _getOrderHandler;
public OrdersController(
CreateOrderHandler createOrderHandler,
GetOrderHandler getOrderHandler)
{
_createOrderHandler = createOrderHandler;
_getOrderHandler = getOrderHandler;
}
[HttpPost]
[ProducesResponseType(typeof(CreateOrderResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> CreateOrder(
[FromBody] CreateOrderRequest request,
CancellationToken ct)
{
var command = new CreateOrderCommand(
CustomerId: request.CustomerId,
ShippingAddress: request.ShippingAddress,
Items: request.Items.Select(i => new CreateOrderItemDto(
ProductId: i.ProductId,
ProductName: i.ProductName,
UnitPrice: i.UnitPrice,
Currency: i.Currency,
Quantity: i.Quantity
)).ToList()
);
var result = await _createOrderHandler.HandleAsync(command, ct);
return CreatedAtAction(
actionName: nameof(GetOrder),
routeValues: new { id = result.OrderId },
value: new CreateOrderResponse(
OrderId: result.OrderId,
TotalAmount: result.TotalAmount,
Currency: result.Currency,
ItemCount: result.ItemCount
));
}
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
var query = new GetOrderQuery(id);
var order = await _getOrderHandler.HandleAsync(query, ct);
return Ok(order);
}
}Request/Response Models
// src/MyApp.API/Models/Orders/CreateOrderRequest.cs
namespace MyApp.API.Models.Orders;
public sealed record CreateOrderRequest(
Guid CustomerId,
string ShippingAddress,
IReadOnlyList<CreateOrderItemRequest> Items
);
public sealed record CreateOrderItemRequest(
Guid ProductId,
string ProductName,
decimal UnitPrice,
string Currency,
int Quantity
);
public sealed record CreateOrderResponse(
Guid OrderId,
decimal TotalAmount,
string Currency,
int ItemCount
);Exception Handling Middleware
// src/MyApp.API/Middleware/ExceptionHandlingMiddleware.cs
namespace MyApp.API.Middleware;
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (NotFoundException ex)
{
_logger.LogWarning(ex, "Resource not found: {Message}", ex.Message);
await WriteProblemsAsync(context, StatusCodes.Status404NotFound, ex.Message);
}
catch (ValidationException ex)
{
_logger.LogWarning("Validation failed: {@Errors}", ex.Errors);
context.Response.StatusCode = StatusCodes.Status400BadRequest;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc9110#section-15.5.1",
title = "Validation Error",
status = 400,
errors = ex.Errors
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
await WriteProblemsAsync(context, StatusCodes.Status500InternalServerError,
"An unexpected error occurred.");
}
}
private static async Task WriteProblemsAsync(
HttpContext context, int statusCode, string detail)
{
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Detail = detail
});
}
}Dependency Injection Setup
// src/MyApp.API/Program.cs
using MyApp.API.Middleware;
using MyApp.Application.Orders.CreateOrder;
using MyApp.Application.Orders.GetOrder;
using MyApp.Infrastructure.Persistence;
using MyApp.Infrastructure.Persistence.Repositories;
using MyApp.Infrastructure.Events;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Infrastructure — Database
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Infrastructure — Unit of Work (DbContext implements IUnitOfWork)
builder.Services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
// Infrastructure — Repositories
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
// Infrastructure — Events
builder.Services.AddScoped<IEventPublisher, InMemoryEventPublisher>();
builder.Services.AddScoped<IDomainEventHandler<OrderSubmittedEvent>, OrderSubmittedEmailHandler>();
// Application — Handlers
builder.Services.AddScoped<CreateOrderHandler>();
builder.Services.AddScoped<GetOrderHandler>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();Testing the Application Layer
Because the Application layer depends only on interfaces, we can unit test handlers without touching the database:
// tests/MyApp.Application.Tests/Orders/CreateOrderHandlerTests.cs
namespace MyApp.Application.Tests.Orders;
public class CreateOrderHandlerTests
{
private readonly Mock<IOrderRepository> _orderRepo = new();
private readonly Mock<ICustomerRepository> _customerRepo = new();
private readonly Mock<IUnitOfWork> _uow = new();
private readonly Mock<IEventPublisher> _publisher = new();
private CreateOrderHandler CreateSut() =>
new(_orderRepo.Object, _customerRepo.Object, _uow.Object, _publisher.Object);
[Fact]
public async Task HandleAsync_ValidCommand_CreatesAndReturnsOrder()
{
// Arrange
var customerId = Guid.NewGuid();
_customerRepo
.Setup(r => r.ExistsAsync(customerId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var command = new CreateOrderCommand(
CustomerId: customerId,
ShippingAddress: "123 Main St, New York, NY 10001",
Items: new[]
{
new CreateOrderItemDto(
ProductId: Guid.NewGuid(),
ProductName: "Widget Pro",
UnitPrice: 29.99m,
Currency: "USD",
Quantity: 2
)
}
);
// Act
var result = await CreateSut().HandleAsync(command);
// Assert
result.OrderId.Should().NotBeEmpty();
result.TotalAmount.Should().Be(59.98m);
result.Currency.Should().Be("USD");
result.ItemCount.Should().Be(1);
_orderRepo.Verify(r => r.Add(It.IsAny<Order>()), Times.Once);
_uow.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Once);
_publisher.Verify(
p => p.PublishAsync(It.IsAny<OrderSubmittedEvent>(), It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task HandleAsync_CustomerNotFound_ThrowsNotFoundException()
{
// Arrange
_customerRepo
.Setup(r => r.ExistsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var command = new CreateOrderCommand(
CustomerId: Guid.NewGuid(),
ShippingAddress: "123 Main St",
Items: new[] { new CreateOrderItemDto(Guid.NewGuid(), "Widget", 10m, "USD", 1) }
);
// Act
var act = () => CreateSut().HandleAsync(command);
// Assert
await act.Should().ThrowAsync<NotFoundException>()
.WithMessage("*not found*");
_orderRepo.Verify(r => r.Add(It.IsAny<Order>()), Times.Never);
_uow.Verify(u => u.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task HandleAsync_EmptyItemsList_ThrowsInvalidOperationException()
{
// Arrange
var customerId = Guid.NewGuid();
_customerRepo
.Setup(r => r.ExistsAsync(customerId, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var command = new CreateOrderCommand(
CustomerId: customerId,
ShippingAddress: "123 Main St",
Items: Array.Empty<CreateOrderItemDto>()
);
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(
() => CreateSut().HandleAsync(command));
}
}The Dependency Rule in Action
Here is a cheat sheet of what each project is allowed to reference:
| Project | Can Reference |
|---|---|
| Domain | Nothing (pure C#) |
| Application | Domain only |
| Infrastructure | Application, Domain, EF Core, external packages |
| API | Infrastructure, Application, Domain, ASP.NET Core |
Enforce this mechanically by setting up your .csproj files:
<!-- src/MyApp.Domain/MyApp.Domain.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<!-- NO PackageReference to EF Core, ASP.NET, etc. -->
</Project><!-- src/MyApp.Application/MyApp.Application.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
</ItemGroup>
</Project><!-- src/MyApp.Infrastructure/MyApp.Infrastructure.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
<ProjectReference Include="..\MyApp.Domain\MyApp.Domain.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
</ItemGroup>
</Project>Common Mistakes
Mistake 1: Putting business logic in controllers. Controllers should be thin. They map requests to commands and commands to responses. Period.
Mistake 2: Referencing infrastructure from Application. The Application layer defines IOrderRepository. It does not reference OrderRepository or DbContext.
Mistake 3: Returning domain entities from handlers. Map to DTOs at the Application layer. Never expose domain entities through the API boundary.
Mistake 4: Anemic domain model. If your entities are just bags of properties with all logic in services, you do not have Clean Architecture — you have a service-oriented transaction script with extra layers.
Mistake 5: Over-engineering small apps. Clean Architecture shines at medium-to-large scale. For a CRUD API with 5 endpoints, a single project is fine.
Summary
Clean Architecture enforces one golden rule: dependencies point inward. This gives you:
- A Domain and Application layer that are framework-agnostic and easily testable
- An Infrastructure layer that can be swapped (e.g., replace SQL with Cosmos DB) without touching business logic
- A Presentation layer that is a thin translator from HTTP to your application model
The discipline of having IOrderRepository in the Domain and OrderRepository in Infrastructure feels ceremonial at first. But when you need to swap your ORM, add a caching layer, or test business rules without spinning up a database, you will be glad you drew those lines.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.