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 methodCreate() replaces the public constructor to ensure valid initial state
  • Domain events — collected in the entity and dispatched by the infrastructure after persistence
  • Value objectsMoney encapsulates amount + currency instead of using a raw decimal

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 IDistributedCache with a unified L1/L2 cache that’s trivial to add at the Application layer
  • Built-in OpenAPI — no more Swashbuckle dependency; Microsoft.AspNetCore.OpenApi generates 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.