Clean Architecture with ASP.NET 9
After architecting dozens of .NET applications — from microservices at CA-CIB to enterprise platforms at Michelin — I’ve settled on a Clean Architecture approach that balances pragmatism with maintainability. This article walks through the structure I use in production, including integration testing with TestContainers.
Why Clean Architecture?
Every developer has experienced the pain of a project that started simple and became unmaintainable. Controllers calling repositories directly. Business logic scattered across services, DB queries, and API handlers. Tests that require a full database and a prayer.
Clean Architecture solves this by enforcing dependency inversion: the core business logic has zero dependencies on frameworks, databases, or external services. Everything points inward.
┌─────────────────────────────────────────────┐
│ Presentation │
│ (API Controllers, gRPC) │
├─────────────────────────────────────────────┤
│ Infrastructure │
│ (EF Core, HTTP Clients, Azure SDK) │
├─────────────────────────────────────────────┤
│ Application │
│ (Use Cases, DTOs, Interfaces) │
├─────────────────────────────────────────────┤
│ Domain │
│ (Entities, Value Objects, Rules) │
└─────────────────────────────────────────────┘
↑ Dependencies point INWARD ↑
The Solution Structure
Here’s the project structure I use for new ASP.NET 9 applications:
src/
├── MyApp.Domain/ # Core entities and business rules
├── MyApp.Application/ # Use cases and application logic
├── MyApp.Infrastructure/ # Database, external services
├── MyApp.Api/ # ASP.NET controllers and middleware
tests/
├── MyApp.UnitTests/ # Fast, isolated tests
├── MyApp.IntegrationTests/ # TestContainers-based tests
└── MyApp.ArchTests/ # Architecture rule enforcement
Let’s explore each layer.
Domain Layer
This is the heart of your application. It contains entities, value objects, domain events, and business rules. It has no dependencies — not even on NuGet packages if you can avoid it.
namespace MyApp.Domain.Entities;
public class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
private readonly List<OrderLine> _lines = [];
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private Order() { } // EF Core
public static Order Create(Guid customerId)
{
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Draft,
TotalAmount = Money.Zero("EUR")
};
order._domainEvents.Add(new OrderCreatedEvent(order.Id));
return order;
}
public void AddLine(Product product, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a submitted order.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
var line = new OrderLine(product.Id, product.Name,
product.Price, quantity);
_lines.Add(line);
RecalculateTotal();
}
public void Submit()
{
if (!_lines.Any())
throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
_domainEvents.Add(new OrderSubmittedEvent(Id, TotalAmount));
}
public void ClearDomainEvents() => _domainEvents.Clear();
private void RecalculateTotal()
{
TotalAmount = _lines.Aggregate(
Money.Zero("EUR"),
(sum, line) => sum + line.LineTotal);
}
}
Key principles here:
- Private setters — state changes only through methods that enforce business rules
- Static factory method —
Create()replaces the public constructor to ensure valid initial state - Domain events — collected in the entity and dispatched by the infrastructure after persistence
- Value objects —
Moneyencapsulates amount + currency instead of using a rawdecimal
Value Objects
Value objects are dramatically underused in most .NET projects. They eliminate entire categories of bugs:
public record Money(decimal Amount, string Currency)
{
public static Money Zero(string currency) => new(0, currency);
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new DomainException(
$"Cannot add {a.Currency} and {b.Currency}");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money operator *(Money money, int quantity)
=> new(money.Amount * quantity, money.Currency);
}
Using record in C# gives you structural equality for free. Two Money instances with the same amount and currency are equal — no need to override Equals.
Application Layer
The application layer orchestrates domain logic. It defines use cases (commands and queries), DTOs, and interfaces that the infrastructure must implement.
I use the CQRS pattern with MediatR:
namespace MyApp.Application.Orders.Commands;
public record SubmitOrderCommand(Guid OrderId) : IRequest<Result>;
public class SubmitOrderHandler(
IOrderRepository orderRepository,
IUnitOfWork unitOfWork) : IRequestHandler<SubmitOrderCommand, Result>
{
public async Task<Result> Handle(
SubmitOrderCommand request,
CancellationToken cancellationToken)
{
var order = await orderRepository
.GetByIdAsync(request.OrderId, cancellationToken);
if (order is null)
return Result.NotFound($"Order {request.OrderId} not found.");
order.Submit();
await unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success();
}
}
Notice:
- The handler uses interfaces (
IOrderRepository,IUnitOfWork) — no mention of EF Core - Domain logic lives in
order.Submit(), not in the handler - The handler is a thin orchestrator: load, invoke domain logic, persist
The Result Pattern
Avoid throwing exceptions for expected failures. I use a Result type:
public class Result
{
public bool IsSuccess { get; }
public string? Error { get; }
public int? StatusCode { get; }
private Result(bool isSuccess, string? error, int? statusCode)
{
IsSuccess = isSuccess;
Error = error;
StatusCode = statusCode;
}
public static Result Success() => new(true, null, null);
public static Result NotFound(string error) => new(false, error, 404);
public static Result BadRequest(string error) => new(false, error, 400);
}
Infrastructure Layer
This is where the abstractions meet reality. EF Core, HTTP clients, message brokers — all the messy external stuff lives here.
namespace MyApp.Infrastructure.Persistence;
public class AppDbContext(DbContextOptions<AppDbContext> options)
: DbContext(options), IUnitOfWork
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Product> Products => Set<Product>();
public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
// Dispatch domain events before saving
var entities = ChangeTracker.Entries<Order>()
.Where(e => e.Entity.DomainEvents.Any())
.Select(e => e.Entity)
.ToList();
var events = entities
.SelectMany(e => e.DomainEvents)
.ToList();
entities.ForEach(e => e.ClearDomainEvents());
var result = await base.SaveChangesAsync(cancellationToken);
// Publish events after successful save
foreach (var domainEvent in events)
{
await _mediator.Publish(domainEvent, cancellationToken);
}
return result;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(AppDbContext).Assembly);
}
}
Integration Testing with TestContainers
This is where it gets really exciting. TestContainers spins up a real PostgreSQL (or SQL Server) database in a Docker container for each test run. No more mocking DbContext. No more “works in tests, fails in production.”
namespace MyApp.IntegrationTests;
public class IntegrationTestBase : IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder()
.WithImage("postgres:16-alpine")
.WithDatabase("test_db")
.WithUsername("test")
.WithPassword("test")
.Build();
protected HttpClient Client { get; private set; } = null!;
private WebApplicationFactory<Program> _factory = null!;
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove the existing DbContext registration
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(
DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
// Add DbContext pointing to the container
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_dbContainer
.GetConnectionString()));
});
});
Client = _factory.CreateClient();
// Apply migrations
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await _dbContainer.DisposeAsync();
await _factory.DisposeAsync();
}
}
Now your tests run against a real database:
public class OrderEndpointTests(
IntegrationTestBase fixture) : IClassFixture<IntegrationTestBase>
{
[Fact]
public async Task SubmitOrder_WithValidOrder_ReturnsOk()
{
// Arrange - create an order via the API
var createResponse = await fixture.Client
.PostAsJsonAsync("/api/orders", new
{
CustomerId = Guid.NewGuid()
});
var orderId = await createResponse.Content
.ReadFromJsonAsync<Guid>();
// Add a line
await fixture.Client.PostAsJsonAsync(
$"/api/orders/{orderId}/lines",
new { ProductId = TestData.ProductId, Quantity = 2 });
// Act
var response = await fixture.Client
.PutAsync($"/api/orders/{orderId}/submit", null);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var order = await fixture.Client
.GetFromJsonAsync<OrderDto>($"/api/orders/{orderId}");
order!.Status.Should().Be("Submitted");
}
}
The beauty of this approach: you’re testing the entire stack — routing, validation, serialization, database queries, domain logic — in a test that runs in 2–3 seconds.
Architecture Tests
Here’s a bonus layer that I add to every project: architecture tests using NetArchTest. These enforce that your Clean Architecture boundaries aren’t violated:
public class ArchitectureTests
{
[Fact]
public void Domain_ShouldNotDependOn_Application()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Application")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
[Fact]
public void Domain_ShouldNotDependOn_Infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
[Fact]
public void Application_ShouldNotDependOn_Infrastructure()
{
var result = Types.InAssembly(
typeof(SubmitOrderCommand).Assembly)
.ShouldNot()
.HaveDependencyOn("MyApp.Infrastructure")
.GetResult();
result.IsSuccessful.Should().BeTrue();
}
}
These tests run in CI and prevent anyone from accidentally introducing a dependency that violates your architecture. They’ve caught issues many times in code reviews that humans missed.
Pragmatic Advice
After applying this pattern across many projects, here are the lessons I keep coming back to:
Don’t over-engineer the Domain layer. Not every entity needs domain events and value objects. Start simple. Extract value objects when you catch the same validation logic duplicated in multiple places.
CQRS doesn’t require Event Sourcing. You can use the command/query separation pattern with a regular relational database. Don’t add Event Sourcing until you actually need an audit trail or temporal queries.
TestContainers changes the testing game. Once you have real-database integration tests, you can delete most of your repository mocks. The confidence gain is enormous.
The architecture should serve the team, not the other way around. If your team is three developers building an internal tool, four projects with MediatR handlers might be overkill. Start with two projects (API + Tests) and split when complexity warrants it.
Use record types aggressively. C# records are perfect for commands, queries, DTOs, and value objects. They’re immutable by default and give you structural equality for free.
The .NET 9 Additions
ASP.NET 9 brings a few features that pair beautifully with Clean Architecture:
- HybridCache — replaces
IDistributedCachewith a unified L1/L2 cache that’s trivial to add at the Application layer - Built-in OpenAPI — no more Swashbuckle dependency;
Microsoft.AspNetCore.OpenApigenerates your docs - Improved AOT support — minimal APIs now work fully with Native AOT, which matters for container startup times in microservices
Getting Started
I’ve made a template repository based on this architecture available on my GitHub. Clone it, rename the namespaces, and you have a production-ready starting point with all the patterns described here.
The key takeaway: Clean Architecture isn’t about following rules religiously. It’s about making your codebase easy to change. When the business pivot comes — and it always does — you want to modify business logic without touching database queries, and swap infrastructure without rewriting your domain.
Questions or feedback? Let’s connect on LinkedIn.