Clean Architecture in C# — A Complete Guide with Real Code (Why, How, and When NOT to Use It)
Deep-dive into Clean Architecture for .NET: the Dependency Rule, the four layers, full project structure with EF Core + ASP.NET Core + MediatR, real C# code, advantages, trade-offs, and the pragmatic version that actually ships.
- Author
- Randhir Jassal
- Published
- Reading time
- 18 min read
Clean Architecture is a way of organising a .NET solution so that your business logic is completely independent of your framework, your database, your UI, and any third-party library. The result: code you can unit-test without spinning up a web server or a database, that survives framework rewrites, and where every layer has one obvious job.
This guide is the complete picture: the Dependency Rule (the one rule that makes it all work), the four layers, a real .NET 8 project structure with code you can copy, advantages, honest trade-offs, and the pragmatic version most teams actually ship.
The problem Clean Architecture solves
In a typical .NET project, business logic sneaks into everywhere it shouldn't:
- The controller calls
_db.Orders.Add(order)directly → controller is coupled to EF Core - The service uses
HttpContext.User→ can't test without an HTTP context - The domain entity has
[Table]and[Column]attributes → coupled to your ORM - Validation logic is inside the form binder → can't reuse from a queue consumer
- A new requirement says "also accept commands from RabbitMQ" → half the code is tied to HTTP
Six months in, the codebase is one big tangle. Want to upgrade EF Core? Touch 300 files. Want to swap MSSQL for Postgres? Half your repository methods break. Want to unit-test a business rule? You need a database.
Clean Architecture solves this by inverting dependencies: business logic depends on nothing, and everything else depends on business logic. The framework, the database, the UI — they become details, not foundations.
The Dependency Rule — the one rule
Source code dependencies can only point inward.
That's it. Memorise that single sentence. Every other rule follows from it.
In a Clean Architecture solution, draw concentric circles. The innermost circle has zero references to anything outside. Each outer circle can reference circles inside it, but nothing outside it.
Clean Architecture — Dependency Rule
────────────────────────────────────
┌─────────────────────────────────────────────────────────┐
│ Presentation / Web │
│ (Controllers, Razor pages, gRPC services) │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Infrastructure │ │
│ │ (EF Core, file system, HTTP clients, │ │
│ │ queues, email senders, third-party APIs) │ │
│ │ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ Application │ │ │
│ │ │ (Use cases, command handlers, │ │ │
│ │ │ interfaces for what Infra MUST │ │ │
│ │ │ implement: IOrderRepository, │ │ │
│ │ │ IEmailSender, IClock, etc.) │ │ │
│ │ │ │ │ │
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ Domain │ │ │ │
│ │ │ │ (Entities, value objects,│ │ │ │
│ │ │ │ pure business rules, │ │ │ │
│ │ │ │ ZERO framework code) │ │ │ │
│ │ │ └───────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
- Dependencies point INWARD only
- Domain references NOTHING outside itself
- Application defines interfaces; Infrastructure implements them
- Presentation + Infrastructure can both reference Application + Domain
- Domain + Application know nothing about EF Core, ASP.NET, anything
The trick that makes the inversion work: Application defines interfaces for what it needs (IOrderRepository, IEmailSender). Infrastructure implements them. When the controller calls a use case, the use case talks to IOrderRepository — and at runtime the DI container injects the EF Core implementation. The use case never references EF Core. It can be tested with a Mock<IOrderRepository>.
The four layers — what goes where
1. Domain (the innermost circle)
Pure business concepts. Entities, value objects, domain exceptions, domain events. Zero references to anything else — not even other layers of your own app.
// Domain/Entities/Order.cs
public class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
public DateTimeOffset PlacedAt { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items;
// Private ctor so callers MUST go through the factory
private Order() { }
public static Order Place(Guid customerId, IEnumerable<OrderItem> items, DateTimeOffset now)
{
var itemsList = items.ToList();
if (itemsList.Count == 0)
throw new DomainException("Order must have at least one item");
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Placed,
PlacedAt = now,
Total = Money.Sum(itemsList.Select(i => i.LineTotal))
};
order._items.AddRange(itemsList);
return order;
}
public void Cancel(string reason, DateTimeOffset now)
{
if (Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel a shipped order");
Status = OrderStatus.Cancelled;
}
}
public record OrderItem(Guid ProductId, int Quantity, Money UnitPrice)
{
public Money LineTotal => UnitPrice * Quantity;
}
public record Money(decimal Amount, string Currency)
{
public static Money Sum(IEnumerable<Money> values) => /* ... */;
public static Money operator *(Money m, int n) => new(m.Amount * n, m.Currency);
}
public enum OrderStatus { Placed, Paid, Shipped, Cancelled }
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
}
Notice:
- No EF Core attributes. No
[Key], no[Table], no virtual nav properties. - No
ICloneable, noINotifyPropertyChanged— those are infra concerns. - Constructors are private + a static factory
Place()enforces invariants ("at least one item"). - Business rules live as methods on the entity (
Cancel), not in a service.
This file would survive porting the app from .NET to Java or from SQL Server to Mongo. That's the point.
2. Application (orchestration)
Use cases — one class per business operation. Defines interfaces for things it needs from outside (repositories, email, time).
// Application/UseCases/PlaceOrder/IOrderRepository.cs
public interface IOrderRepository
{
Task AddAsync(Order order, CancellationToken ct);
Task<Order?> GetAsync(Guid id, CancellationToken ct);
}
// Application/UseCases/PlaceOrder/PlaceOrderCommand.cs
public record PlaceOrderCommand(Guid CustomerId, List<OrderLineDto> Items) : IRequest<Guid>;
public record OrderLineDto(Guid ProductId, int Quantity, decimal UnitPrice);
// Application/UseCases/PlaceOrder/PlaceOrderHandler.cs
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
private readonly IOrderRepository _orders;
private readonly IClock _clock;
public PlaceOrderHandler(IOrderRepository orders, IClock clock)
{
_orders = orders;
_clock = clock;
}
public async Task<Guid> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var items = cmd.Items.Select(i =>
new OrderItem(i.ProductId, i.Quantity, new Money(i.UnitPrice, "INR")));
var order = Order.Place(cmd.CustomerId, items, _clock.UtcNow);
await _orders.AddAsync(order, ct);
return order.Id;
}
}
// Application/Abstractions/IClock.cs
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
Notice:
IOrderRepositoryis defined in Application (the use case decides what it needs).IClockabstraction wrapsDateTimeOffset.UtcNowso tests can inject a fixed time.- The handler is testable in isolation — no EF Core, no HTTP, no
DateTime.UtcNow.
3. Infrastructure (the implementations)
Implements the interfaces that Application defined. This is where your ORM, your HTTP clients, your file system access, your email sender live.
// Infrastructure/Persistence/AppDbContext.cs
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> opts) : base(opts) { }
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder mb)
{
// Configuration is DONE HERE, away from the domain
var order = mb.Entity<Order>();
order.HasKey(o => o.Id);
order.Property(o => o.Status).HasConversion<string>();
order.OwnsOne(o => o.Total, t =>
{
t.Property(p => p.Amount).HasColumnName("total_amount");
t.Property(p => p.Currency).HasColumnName("total_currency");
});
order.OwnsMany(o => o.Items, items =>
{
items.WithOwner().HasForeignKey("OrderId");
items.OwnsOne(i => i.UnitPrice);
});
}
}
// Infrastructure/Persistence/EfOrderRepository.cs
public class EfOrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public EfOrderRepository(AppDbContext db) => _db = db;
public async Task AddAsync(Order order, CancellationToken ct)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
}
public Task<Order?> GetAsync(Guid id, CancellationToken ct) =>
_db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
}
// Infrastructure/Time/SystemClock.cs
public class SystemClock : IClock
{
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}
EF Core configuration uses fluent API in OnModelCreating, not attributes on the entity. That way the domain stays pristine. If you switch to Dapper or Postgres-only, you change Infrastructure, the domain stays untouched.
4. Presentation (Web / API)
HTTP plumbing. Validates the request, dispatches to a use case, formats the response. Has no business logic of its own.
// Web/Controllers/OrdersController.cs
[ApiController]
[Route("orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<ActionResult<Guid>> Place(
[FromBody] PlaceOrderCommand cmd,
CancellationToken ct)
{
var id = await _mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id }, id);
}
}
Three lines of meaningful code. No if, no try, no business rules. The controller is dumb glue.
Composition root — wire it up
// Web/Program.cs
var builder = WebApplication.CreateBuilder(args);
// Application: register MediatR + use cases
builder.Services.AddMediatR(c =>
c.RegisterServicesFromAssemblies(typeof(PlaceOrderHandler).Assembly));
// Infrastructure: register the implementations
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseNpgsql(builder.Configuration.GetConnectionString("Db")!));
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
// Presentation: controllers, OpenAPI
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapControllers();
app.Run();
Program.cs is the ONE place where everything meets. It binds the abstractions to the concrete implementations. This is called the composition root — it's the only file that knows about all four layers.
Solution structure (.csproj layout)
src/
├── Domain/
│ ├── Domain.csproj (no dependencies)
│ ├── Entities/
│ ├── ValueObjects/
│ └── Exceptions/
├── Application/
│ ├── Application.csproj (refs Domain)
│ ├── Abstractions/ (interfaces: IClock, etc.)
│ ├── UseCases/
│ │ ├── PlaceOrder/
│ │ ├── CancelOrder/
│ │ └── GetOrder/
│ └── Common/ (pipeline behaviors, DTOs)
├── Infrastructure/
│ ├── Infrastructure.csproj (refs Application + Domain)
│ ├── Persistence/ (DbContext, repos, migrations)
│ ├── Time/ (SystemClock)
│ ├── Email/ (SendGrid impl)
│ └── Http/ (third-party clients)
└── Web/
├── Web.csproj (refs Application + Infrastructure)
├── Controllers/
├── Middleware/
└── Program.cs
The compiler enforces the Dependency Rule for you:
Domain.csprojhas no<ProjectReference>— it can't even mention EF Core if you wanted it toApplication.csprojreferencesDomainonlyInfrastructure.csprojreferencesApplication+DomainWeb.csprojreferences everything
If a junior dev tries to using Microsoft.EntityFrameworkCore inside the Application project, the build fails. Architecture enforced by csc, not by code review.
Testing — the payoff
Testing the use case is now trivial:
[Fact]
public async Task PlaceOrder_with_items_creates_order()
{
var fakeRepo = new FakeOrderRepository(); // your own InMemory implementation
var clock = new FixedClock(DateTimeOffset.Parse("2026-05-22T10:00:00Z"));
var handler = new PlaceOrderHandler(fakeRepo, clock);
var cmd = new PlaceOrderCommand(
CustomerId: Guid.NewGuid(),
Items: new List<OrderLineDto> { new(Guid.NewGuid(), 2, 500m) });
var orderId = await handler.Handle(cmd, default);
var saved = await fakeRepo.GetAsync(orderId, default);
Assert.NotNull(saved);
Assert.Equal(OrderStatus.Placed, saved.Status);
Assert.Equal(1000m, saved.Total.Amount);
}
[Fact]
public async Task PlaceOrder_with_no_items_throws()
{
var handler = new PlaceOrderHandler(new FakeOrderRepository(), new FixedClock(DateTimeOffset.UtcNow));
var cmd = new PlaceOrderCommand(Guid.NewGuid(), new List<OrderLineDto>());
await Assert.ThrowsAsync<DomainException>(() => handler.Handle(cmd, default));
}
No WebApplicationFactory, no database, no HTTP. Hundreds of tests run in seconds.
Compare to a typical "fat controller" codebase where you spin up the whole web host + a test database for every assertion. Clean Architecture pays for itself the first time you write a 10-test feature in 5 minutes instead of an hour.
Advantages
- Testable — pure business logic with no framework dependencies; tests run in milliseconds
- Framework-independent — upgrade EF Core 7 → 9, swap MSSQL → Postgres, swap MVC → Minimal APIs — none of it touches the Domain or Application layers
- Clear ownership — every line of code lives in exactly one obvious place
- New developer onboarding — "business rules live in Domain" is enough orientation
- Multiple delivery channels — same use case serves HTTP, queue consumer, gRPC, cron — just wire it up at the composition root
- Compile-time architectural enforcement — the project references prevent dependency violations
- Aligns with CQRS, DDD, event-driven — these patterns plug in without re-architecting
Disadvantages — the honest trade-offs
- More files — 4 projects, more folders, more interfaces. A trivial "GET /products" endpoint touches 4-6 files vs 1-2 in a flat ASP.NET MVC app.
- Onboarding cost — juniors must learn the layers + the Dependency Rule. ~2 days of friction.
- Indirection — finding "what does this controller actually do" takes 2-3 clicks (controller → mediator → handler).
- Over-abstraction risk — easy to create
IPaymentServicewith one implementation and never use the seam. - Mapping overhead — Domain entity ↔ Application DTO ↔ Persistence model. AutoMapper helps but adds magic.
- Doesn't help with small CRUD apps — the structure is overhead when the business logic is "store this JSON".
When Clean Architecture is the right call
- The app has real business rules (orders, payments, approvals, workflows, calculations with edge cases)
- The team is 3+ engineers — the structure prevents stepping on each other
- The expected lifetime is 3+ years — pays back the upfront cost
- You will likely change the framework or DB at some point
- You need fast, isolated tests — financial calculations, business rules, multi-step workflows
- The team is doing CQRS, DDD, or event-driven — Clean Architecture aligns naturally
When Clean Architecture is the wrong call
- CRUD admin tools — table of rows + edit modal. The structure is dead weight.
- MVPs / pre-product-market-fit — ship fast, refactor later. Clean Architecture optimizes for the wrong axis (correctness over speed).
- Single-developer side projects — the overhead never pays back.
- 3-month projects — you'll never reach the inflection where it pays.
- Apps whose business logic is "transform JSON, call third party, return JSON" — there are no domain invariants to protect.
Clean Architecture vs Hexagonal vs Onion — same thing?
These are three names for the same idea with different visual metaphors:
| Name | Coined by | Visual metaphor |
|---|---|---|
| Hexagonal / Ports & Adapters | Alistair Cockburn (2005) | Hexagon with ports on each side |
| Onion Architecture | Jeffrey Palermo (2008) | Concentric onion layers |
| Clean Architecture | Robert C. Martin (2012) | Concentric circles with dependency arrows |
All three say: business logic in the center, dependencies point inward, abstractions defined at the boundary. The differences are pedagogical, not structural. If you understand one, you understand all three.
Common pitfalls
Pitfall 1 — Anaemic domain model
Putting all business logic in services + handlers, leaving domain entities as bags of public setters. Result: scattered logic, can't enforce invariants.
// ❌ Anaemic
public class Order {
public Guid Id { get; set; }
public OrderStatus Status { get; set; }
}
// Caller does: order.Status = OrderStatus.Cancelled; — no validation possible
// ✅ Rich domain
public class Order {
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public void Cancel(string reason, DateTimeOffset now) {
if (Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel shipped");
Status = OrderStatus.Cancelled;
}
}
Pitfall 2 — Putting EF Core attributes on domain entities
// ❌ Domain coupled to EF
[Table("orders")]
public class Order {
[Key]
public Guid Id { get; set; }
public virtual ICollection<OrderItem> Items { get; set; }
}
Move all EF configuration to Infrastructure via fluent API (OnModelCreating).
Pitfall 3 — Leaking IQueryable across layers
// ❌ Repository exposes EF leak
public interface IOrderRepository {
IQueryable<Order> Query();
}
// Caller can now do .Include(), .Where() — Application is now coupled to LINQ-to-EF
Repositories return collections / single entities, never IQueryable. If you genuinely need flexible queries, that's CQRS territory — use a dedicated query service that lives in Infrastructure.
Pitfall 4 — Mapping for the sake of mapping
Three mapping layers (Domain → Application DTO → Persistence model → Web response) is the most over-engineered version. For most apps, two is enough: Domain ↔ Web response DTO. The persistence model = the Domain entity, with EF configured to map it cleanly.
Pitfall 5 — Generic repositories
// ❌ Repository<T> for every entity
public interface IRepository<T> {
Task<T> GetAsync(Guid id);
Task AddAsync(T entity);
}
Defeats the purpose — every entity gets the same operations regardless of business meaning. Prefer specific repositories per aggregate with operations named after intent (GetByIdWithItems, ListRecentForCustomer).
Pitfall 6 — Putting interfaces in the wrong layer
IOrderRepository belongs in Application (the use case decides what it needs). Beginners often put it in Domain or Infrastructure. Get the layer wrong and the Dependency Rule breaks.
Pitfall 7 — Composition root sprawl
The DI registration grows to 500 lines. Split it: one extension method per layer (AddApplication, AddInfrastructure, AddWeb), called from Program.cs.
Pragmatic Clean Architecture — what most teams actually ship
Pure Clean Architecture with 4 projects + strict separation is overkill for ~80% of .NET apps. The pragmatic version that most successful teams use:
src/
├── App/ (Domain + Application together)
│ ├── Entities/
│ ├── UseCases/ (or "Features/")
│ ├── Abstractions/ (interfaces)
│ └── ApplicationServices.cs (DI registration)
├── Infrastructure/ (EF, email, clock impls)
│ └── InfrastructureServices.cs
└── Web/ (controllers, Program.cs)
Two projects instead of four. Still:
- Domain entities have no framework attributes
- Use cases live as MediatR handlers
- Repositories implemented in Infrastructure
- Compile-time enforcement holds (Web can't directly
usingPersistence types)
Saves project sprawl while keeping the architectural seams. Recommend this for single-product teams under 15 engineers.
For multi-team monorepos or regulated domains, keep the strict 4-project version.
Real-world fit
| Domain | Clean Architecture fit |
|---|---|
| E-commerce platform | ✅ Strong — orders, payments, inventory all have real invariants |
| Banking core / fintech | ✅ Strong — strict business rules, regulatory audit needs |
| Insurance underwriting | ✅ Strong — complex rule engines, multi-step workflows |
| SaaS B2B with complex permissions | ✅ Strong — many use cases, multiple delivery channels |
| Content management (CMS) | ⚠️ Modest — CRUD-heavy, simpler architecture often enough |
| Internal admin tools | ❌ Skip — overhead doesn't pay back |
| API gateway / proxy | ❌ Skip — no real domain logic |
| MVP / prototype | ❌ Skip — premature; refactor toward Clean later |
Production checklist
- ✅ Domain entities have no framework attributes (
[Table],[Key], etc.) - ✅ All EF configuration in
OnModelCreatingvia fluent API - ✅ Repositories don't expose
IQueryable - ✅ Application defines interfaces; Infrastructure implements them
- ✅ Use case handlers are testable without DB or HTTP
- ✅ Composition root (
Program.cs) is the ONE place that wires layers - ✅ Cross-cutting concerns (logging, auth, validation) via MediatR pipeline behaviors
- ✅ Domain events emitted via the outbox pattern in the same transaction
- ✅ Test pyramid: 80% unit (domain + handlers), 15% integration (real DB), 5% e2e
- ✅ Solution structure makes the Dependency Rule visible to any new developer in 30 seconds
Summary
Clean Architecture is the right tool for serious .NET applications with real business logic that need to live for years and be testable without spinning up infrastructure. The Dependency Rule (dependencies point inward) is the one rule that makes everything work, and it's enforced by the compiler via project references.
For .NET specifically, the standard stack is:
- Domain — pure POCOs with rich behavior
- Application — MediatR command/query handlers + interfaces for what Infrastructure must provide
- Infrastructure — EF Core, HTTP clients, file system, email
- Web — thin ASP.NET Core controllers +
Program.cscomposition root
The trade-off is real: more files, more indirection, longer onboarding. But for the apps where it fits, the testability + framework-independence + clarity pay back enormously.
Pair it with CQRS for the read/write split, the Outbox pattern for reliable event emission, and the SAGA pattern for cross-service workflows. Those four patterns together are the backbone of any serious .NET service-oriented architecture in 2026.
📚 Test your knowledge → Practice with our Clean Architecture interview questions — the Dependency Rule, common pitfalls, when to use vs avoid, and how Clean stacks compare with Hexagonal and Onion.
Get the next issue
A short, curated email with the newest posts and questions.