Articles › Software Architecture
Clean Architecture in ASP.NET Core: A Practical Guide with Project Structure
Structure large ASP.NET Core projects with Clean Architecture: Domain, Application, Infrastructure and API layers, the dependency rule, use cases, testing and when it's overkill.
When a project grows, the first thing to suffer is structure: controllers full of business logic, services talking directly to the database, entities scattered everywhere, and a small change in one place breaking something unrelated. Clean Architecture is a way of organizing code so business rules stay independent, testable and easy to change — no matter which database, UI or framework sits around them.
The core idea: the dependency rule
Picture concentric circles. The innermost circle holds the business rules; outer circles hold technical details (database, web framework, external services).
Source-code dependencies always point inward. Inner layers know nothing about outer layers.
The domain doesn't reference EF Core. The application layer doesn't reference ASP.NET Core. Details depend on policies — never the other way around.
The four layers
1. Domain (the center)
Entities, value objects, domain events, enums and business rules. No dependencies on frameworks or other projects.
public class Order
{
private readonly List<OrderItem> _items = [];
public int Id { get; private set; }
public int CustomerId { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyList<OrderItem> Items => _items;
public decimal Total => _items.Sum(i => i.Quantity * i.UnitPrice);
public void AddItem(int productId, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Items can only be added to draft orders.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
public void Submit()
{
if (_items.Count == 0) throw new DomainException("Cannot submit an empty order.");
Status = OrderStatus.Submitted;
}
}
Notice that rules live inside the entity. Nobody can put the order into an invalid state from outside.
2. Application (use cases)
Orchestrates the domain to fulfill use cases: "place an order", "publish an article". It defines interfaces for what it needs from the outside world — persistence, email, clock — but doesn't implement them.
public interface IOrderRepository
{
Task<Order?> GetAsync(int id, CancellationToken ct);
void Add(Order order);
}
public interface IUnitOfWork { Task SaveChangesAsync(CancellationToken ct); }
public interface IEmailSender { Task SendAsync(string to, string subject, string body, CancellationToken ct); }
public record SubmitOrderCommand(int OrderId);
public class SubmitOrderHandler(IOrderRepository orders, IUnitOfWork uow, IEmailSender email)
{
public async Task<Result> HandleAsync(SubmitOrderCommand cmd, CancellationToken ct)
{
var order = await orders.GetAsync(cmd.OrderId, ct);
if (order is null) return Result.Failure("Order not found.");
order.Submit();
await uow.SaveChangesAsync(ct);
await email.SendAsync("sales@shop.ir", "New order", $"Order #{order.Id} submitted.", ct);
return Result.Success();
}
}
This is the Dependency Inversion Principle at architectural scale: the application layer owns the abstractions.
3. Infrastructure (details)
Implements the application's interfaces using real technology: EF Core DbContext, repositories, SMTP or SMS clients, file storage, external APIs.
public class OrderRepository(AppDbContext db) : IOrderRepository
{
public Task<Order?> GetAsync(int id, CancellationToken ct) =>
db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id, ct);
public void Add(Order order) => db.Orders.Add(order);
}
4. Presentation / API (entry point)
ASP.NET Core controllers or minimal APIs. They translate HTTP into use cases and results back into HTTP — nothing more.
[HttpPost("{id:int}/submit")]
public async Task<IActionResult> Submit(int id, SubmitOrderHandler handler, CancellationToken ct)
{
var result = await handler.HandleAsync(new SubmitOrderCommand(id), ct);
return result.IsSuccess ? NoContent() : BadRequest(result.Error);
}
The API project is also the composition root: it wires everything together in Program.cs.
Solution structure
src/
Shop.Domain/ → no project references
Shop.Application/ → references Domain
Shop.Infrastructure/ → references Application (and Domain)
Shop.Api/ → references Application and Infrastructure
tests/
Shop.Domain.Tests/
Shop.Application.Tests/
Shop.Api.IntegrationTests/
Project references enforce the dependency rule at compile time: if someone tries to use EF Core inside Shop.Domain, the build fails.
// Shop.Api/Program.cs
builder.Services
.AddApplication() // handlers, validators
.AddInfrastructure(builder.Configuration); // DbContext, repositories, email
Why it's worth it
- Testability — domain rules and use cases can be tested in milliseconds without a database or web server, using fakes for the interfaces.
- Replaceable details — moving from SQL Server to PostgreSQL, or from SMTP to an email API, touches only Infrastructure.
- Clear boundaries — every developer knows where new code belongs.
- Business logic is visible — the core of the system isn't buried inside controllers and SQL.
Testing a use case
[Fact]
public async Task Submitting_empty_order_fails()
{
var order = new Order(customerId: 7);
var repo = new FakeOrderRepository(order);
var handler = new SubmitOrderHandler(repo, new FakeUnitOfWork(), new FakeEmailSender());
await Assert.ThrowsAsync<DomainException>(() =>
handler.HandleAsync(new SubmitOrderCommand(order.Id), default));
}
Common variations
- CQRS — split commands (writes, through the domain model) from queries (reads, often projected directly with EF Core or Dapper for speed).
- MediatR / pipeline behaviors — dispatch commands and queries through a mediator and add validation, logging and transactions as cross-cutting behaviors.
- Vertical slices — organize by feature (
Features/Orders/Submit) inside the Application layer instead of by technical type.
Common mistakes
- Anemic domain — entities with only getters/setters and all logic in services. Put invariants in the domain.
- Leaking infrastructure —
DbContextorIQueryablein the Application layer's public contracts. - Duplicating models without need — not every layer needs its own copy of every class; DTOs at the API boundary are enough for many projects.
- Over-engineering small apps — four projects, MediatR, specifications and mappers for a CRUD app with five tables.
When is Clean Architecture overkill?
For a small API with a few endpoints, a single project with folders (Endpoints, Data, Services) is simpler and perfectly maintainable. My rule of thumb: when a project will live for more than a few months, has real business rules, and more than one developer works on it, the investment in Clean Architecture pays off.
Summary
Clean Architecture puts business rules at the center and pushes frameworks, databases and external services to the edges, with all dependencies pointing inward. Domain holds the rules, Application orchestrates use cases through interfaces it owns, Infrastructure implements those interfaces, and the API translates HTTP. The result is code that's testable, adaptable and easy to navigate — as long as you apply it where the complexity justifies it.
FAQ
Is Clean Architecture the same as Onion or Hexagonal Architecture?
They share the same core idea — business logic in the center, dependencies pointing inward, ports and adapters at the edges. The naming and emphasis differ slightly.
Where do DTOs go?
Request/response DTOs for the API live in the API or Application layer (as command/query models). The domain never depends on DTOs.
Can the Application layer use EF Core directly?
Purists say no. Many pragmatic teams allow an IAppDbContext interface in Application for queries. Decide consciously and keep it consistent.