Clean Architecture in ASP.NET Core (.NET 10): A Practical Guide
Most .NET codebases don't fail because of a bad framework choice. They fail slowly: business rules leak into controllers, Entity Framework entities get serialised straight to the browser, and after two years every change touches six projects. Clean Architecture is a set of rules that stops that drift. This guide shows how I structure ASP.NET Core applications on .NET 10 for clients, which parts of Clean Architecture are worth the ceremony, and which parts you can safely skip.
What Clean Architecture actually means
Strip away the diagrams and Clean Architecture is one rule: source code dependencies point inwards. The business core knows nothing about the web, the database or any external service. Outer layers depend on inner layers, never the other way round.
In a typical ASP.NET Core solution that gives you four projects:
- Domain: entities, value objects, domain events and business rules. No references to EF Core, ASP.NET Core or any infrastructure package.
- Application: use cases (commands and queries), interfaces for what the use cases need (
IOrderRepository,IEmailSender,IClock), validation and DTOs. It references Domain only. - Infrastructure: EF Core
DbContext, repository implementations, email, blob storage, message brokers, third-party APIs. It implements the interfaces declared in Application. - Web (or Api): ASP.NET Core endpoints, authentication, OpenAPI, middleware and the composition root that wires everything together.
The payoff is practical, not academic: you can test every business rule without a database, swap SQL Server for PostgreSQL without touching a use case, and new developers know exactly where a piece of code belongs.
A solution structure that scales
This is the layout I start most client projects with:
src/
Shop.Domain/ // Entities, value objects, domain errors
Shop.Application/ // Use cases, interfaces, validators, DTOs
Shop.Infrastructure/ // EF Core, external services, background jobs
Shop.Api/ // Endpoints, auth, DI composition root
tests/
Shop.Domain.Tests/
Shop.Application.Tests/
Shop.Api.IntegrationTests/ // WebApplicationFactory + Testcontainers
Keep the project references honest: Api → Application, Infrastructure, Infrastructure → Application, Application → Domain. If Domain ever needs a NuGet package other than something tiny and dependency-free, stop and ask why.
You can enforce the rule automatically with an architecture test, so a tired developer at 6 pm cannot break it:
[Fact]
public void Domain_does_not_depend_on_infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot()
.HaveDependencyOnAny("Microsoft.EntityFrameworkCore", "Shop.Infrastructure", "Shop.Api")
.GetResult();
Assert.True(result.IsSuccessful);
}
That test uses the NetArchTest library; ArchUnitNET works just as well.
The Domain layer: rich models, not property bags
An entity with public setters on every property is a database row, not a domain model. Put the rules where the data is:
public sealed class Order
{
private readonly List<OrderLine> _lines = [];
public Guid Id { get; private set; } = Guid.CreateVersion7();
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(l => l.Quantity * l.UnitPrice);
private Order() { } // for EF Core
public static Order Create(Guid customerId) => new() { CustomerId = customerId };
public Result AddLine(Guid productId, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
return Result.Failure(OrderErrors.NotEditable);
if (quantity <= 0)
return Result.Failure(OrderErrors.InvalidQuantity);
_lines.Add(new OrderLine(productId, quantity, unitPrice));
return Result.Success();
}
public Result Submit()
{
if (_lines.Count == 0)
return Result.Failure(OrderErrors.Empty);
Status = OrderStatus.Submitted;
return Result.Success();
}
}
Two choices in that snippet matter. First, invariants (an order can only be edited while it is a draft) live in one place, so no controller or background job can bypass them. Second, expected failures are returned as a Result instead of thrown as exceptions. Exceptions stay for things that are truly exceptional, and your API can map a Result to a clean 400 or 409 response.
The Application layer: one class per use case
Each use case gets a small handler with a single public method. You don't need a mediator library for this. Since MediatR moved to a commercial licence in 2025, many teams simply register handlers directly; the code is just as clean:
public sealed record SubmitOrderCommand(Guid OrderId);
public sealed class SubmitOrderHandler(IOrderRepository orders, IUnitOfWork unitOfWork)
{
public async Task<Result> HandleAsync(SubmitOrderCommand command, CancellationToken ct)
{
var order = await orders.GetByIdAsync(command.OrderId, ct);
if (order is null)
return Result.Failure(OrderErrors.NotFound);
var result = order.Submit();
if (result.IsFailure)
return result;
await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
}
Queries are allowed to be simpler. Reading data rarely needs the domain model at all, so a query handler can project straight into a DTO with EF Core (or Dapper) and skip repositories completely. That split, rich models for writes and lean projections for reads, is the most useful part of CQRS and costs almost nothing.
The Infrastructure layer: EF Core without leaking it
Configure persistence with IEntityTypeConfiguration<T> classes instead of attributes, so the Domain stays free of database concerns:
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
builder.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
builder.Navigation(o => o.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);
builder.Ignore(o => o.Total);
}
}
Should you wrap EF Core in a generic repository? Usually not. DbContext already is a unit of work and DbSet already is a repository. I add a specific repository (IOrderRepository) only for aggregates with real loading rules, such as "always include the order lines". A generic IRepository<T> with twenty methods adds code and hides EF Core features you will want later.
The API layer: thin endpoints
Endpoints translate HTTP into a use case call and the result back into HTTP. Nothing else:
app.MapPost("/orders/{id:guid}/submit", async (Guid id, SubmitOrderHandler handler, CancellationToken ct) =>
{
var result = await handler.HandleAsync(new SubmitOrderCommand(id), ct);
return result.IsSuccess ? Results.NoContent() : result.ToProblemDetails();
})
.RequireAuthorization()
.WithName("SubmitOrder");
Minimal APIs and controllers both work. Pick one per project and keep endpoints this thin either way.
Testing strategy
- Domain tests: plain unit tests, thousands of them, running in milliseconds.
- Application tests: handlers with fakes or in-memory implementations of the interfaces.
- Integration tests:
WebApplicationFactoryplus a real database in a container (Testcontainers). Don't use the EF Core in-memory provider for these; it behaves differently from a relational database and hides real bugs.
When Clean Architecture is overkill
I don't recommend four projects for everything. A small internal tool, a prototype or a CRUD admin panel with no real business rules is better served by a single project organised by feature folders. You can still keep the dependency rule inside one project with folders and internal types, and split into projects later when the domain earns it.
A good test: if most of your "business logic" is mapping a request to a table row, you don't need a rich domain model yet. If you have rules that change often, involve money, or must be the same across the API, background jobs and imports, you do.
Common mistakes I see in client codebases
- Anaemic domain plus fat services: all rules in "manager" classes, entities as property bags. You get the ceremony without the benefit.
- Returning entities from the API: it couples your public contract to your database schema and invites over-posting attacks. Always map to response DTOs.
- Infrastructure types in Application:
IQueryable,DbContextorHttpContextin a handler signature means the boundary is already broken. - Too many layers of mapping: one mapping at the edge (entity ↔ DTO) is enough. Hand-written mapping methods are faster and easier to debug than reflection-based mappers.
- A "Common" or "Shared" project that everything references: it becomes a junk drawer and creates hidden coupling between features.
Summary
Clean Architecture in ASP.NET Core comes down to a few habits: keep business rules in the Domain, give every use case its own small handler, keep EF Core and other infrastructure behind interfaces you actually need, and keep endpoints thin. Enforce the dependency rule with a test, and only pay for the structure when the domain is complex enough to need it.
If you're planning a new .NET product or untangling an existing one, I help teams design and review architectures like this. See how I work as a .NET solution architect, or get in touch to talk about your codebase.