Clean Architecture in ASP.NET Core: A Practical Guide Without the Over-Engineering
How to apply Clean Architecture in ASP.NET Core pragmatically: project structure, dependency rules, use cases, and when to keep things simple.
Clean Architecture has become something of a default template for new ASP.NET Core solutions. Spin up a project and you'll often find four or five layers, a mediator, repositories wrapping repositories, and a dozen folders before a single feature exists.
I'm a strong believer in Clean Architecture, but I've also seen it turn into ceremony that slows teams down. In this post I'll explain the parts that really matter, how I structure ASP.NET Core solutions in practice, and where I deliberately keep things simple.
The One Rule That Matters Most
Strip away the diagrams and Clean Architecture comes down to one idea: source code dependencies point inward, toward the business rules.
Your domain logic should not know about ASP.NET Core, Entity Framework, message brokers, or third-party APIs. Those are details. The business rules are the reason the software exists, so they sit at the centre and everything else plugs into them.
If you keep only this rule and ignore the rest, you'll still get most of the benefit: testable logic, swappable infrastructure, and code that survives framework upgrades.
A Practical Project Structure
For most business applications, I use four projects:
src/
MyApp.Domain/ // Entities, value objects, domain rules. No dependencies.
MyApp.Application/ // Use cases, interfaces (ports), DTOs. Depends on Domain.
MyApp.Infrastructure/ // EF Core, email, storage, external APIs. Implements Application interfaces.
MyApp.Api/ // ASP.NET Core endpoints, DI wiring. Composition root.
tests/
MyApp.Domain.Tests/
MyApp.Application.Tests/
MyApp.Api.IntegrationTests/The dependency direction is Api → Application → Domain, and Infrastructure → Application. The Api project references Infrastructure only to register services at startup.
The Domain: Put Behaviour Where the Data Is
A common smell is an "anaemic" domain: entities with public setters and all the logic scattered across services. I prefer entities that protect their own invariants:
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
public Guid Id { get; private set; } = Guid.NewGuid();
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public decimal Total => _lines.Sum(l => l.Quantity * l.UnitPrice);
public void AddLine(Guid productId, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be modified.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
public void Submit()
{
if (_lines.Count == 0)
throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
}
}It's now impossible to put an order into an invalid state from anywhere in the codebase, and these rules can be unit tested without a database or web server.
The Application Layer: One Class per Use Case
The application layer orchestrates: load data, call domain behaviour, persist, and notify. It defines interfaces for what it needs from the outside world, and Infrastructure implements them.
public interface IOrderRepository
{
Task<Order?> GetAsync(Guid id, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
public sealed class SubmitOrderHandler(IOrderRepository orders, IEmailSender email)
{
public async Task<Result> HandleAsync(Guid orderId, CancellationToken ct)
{
var order = await orders.GetAsync(orderId, ct);
if (order is null) return Result.NotFound();
order.Submit();
await orders.SaveChangesAsync(ct);
await email.SendOrderConfirmationAsync(order.Id, ct);
return Result.Success();
}
}Do you need MediatR or a similar mediator library for this? Not necessarily. A mediator is useful when you want a consistent pipeline for cross-cutting concerns such as validation, logging, and transactions. For smaller applications, injecting handlers directly is perfectly fine and easier to navigate. Choose deliberately rather than by default.
Infrastructure: Details Behind Interfaces
Infrastructure is where Entity Framework Core, file storage, and HTTP clients live. Each piece registers itself through an extension method so the Api project stays tidy:
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(config.GetConnectionString("Default")));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IEmailSender, SmtpEmailSender>();
return services;
}
}A note on repositories: EF Core's DbContext is already a unit of work and DbSet is already a repository. I add repository interfaces mainly for aggregates that have meaningful persistence rules, not as a generic wrapper around every table. For read-heavy queries, it's often cleaner to let a query handler use the DbContext (or Dapper) directly with projections, an approach sometimes called "CQRS-lite."
The API Layer: Thin and Boring
Endpoints should translate HTTP into use-case calls and nothing more. Minimal APIs work well here:
app.MapPost("/orders/{id:guid}/submit", async (
Guid id, SubmitOrderHandler handler, CancellationToken ct) =>
{
var result = await handler.HandleAsync(id, ct);
return result.IsNotFound ? Results.NotFound() : Results.NoContent();
});If an endpoint contains if statements about business rules, that logic belongs further in.
Enforce the Rules Automatically
Architecture erodes quietly. One "temporary" reference from Domain to EF Core and the boundary is gone. I add architecture tests that run in CI:
[Fact]
public void Domain_should_not_depend_on_infrastructure()
{
var result = Types.InAssembly(typeof(Order).Assembly)
.ShouldNot().HaveDependencyOnAny("Microsoft.EntityFrameworkCore", "MyApp.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful);
}This turns an architectural intention into a guarantee.
Where I Keep It Simple
Clean Architecture is a means, not a goal. I scale it to the problem:
- Small CRUD apps or internal tools: a single project with clear folders is often enough. The dependency rule can still be respected within one assembly.
- Vertical slices: organising the Application layer by feature (
Orders/Submit,Orders/Cancel) rather than by type (Handlers,Validators) keeps related code together and reduces jumping between folders. - No speculative abstractions: I don't add an interface "in case we switch databases." I add it when there's a real second implementation or a real testing need.
Common Mistakes I See
- Too many layers and mapping steps, so a simple change touches eight files.
- Generic repositories that hide EF Core's capabilities and leak
IQueryableanyway. - Domain entities used directly as API request/response models.
- Business logic in controllers "just this once."
Final Thoughts
Clean Architecture in ASP.NET Core pays off when it protects what matters: your business rules. Keep dependencies pointing inward, make use cases explicit, keep the edges thin, and automate the rules. Everything else is optional and should earn its place.
Applied pragmatically, it gives you a codebase that's easy to test, easy to change, and still pleasant to work in years later.