Clean Architecture in .NET
Reviewed & published by Brayan K
By the end of this lesson you'll be able to split a .NET app into layers where the business rules sit at the centre, the database and email and web framework sit at the edges, and every dependency points inward — giving you a core you can unit-test in milliseconds and swap technologies without rewriting your logic.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Name the four layers — Domain, Application, Infrastructure, Presentation — and what belongs in each
- Apply the Dependency Rule: source-code dependencies only ever point inward
- Use ports (interfaces) and adapters to invert dependencies at the boundary
- Explain why the Domain must have zero framework or database references
- Wire a use case to a real adapter at the composition root with dependency injection
- See why this structure makes the core trivially testable with fakes
💡 Real-World Analogy
Think of your app as an onion. The very centre is the part that can never change just because the world around it does — your business rules. Around it you wrap layers: use cases, then frameworks, then the database and the web. The outer layers are the skin: they get peeled off and replaced all the time (you switch from SQL Server to Postgres, from REST to gRPC, from email to push notifications). But the core stays put. The rule that keeps the onion healthy is simple — an inner layer must never know about an outer one. The heart of the onion doesn't depend on its skin.
📊 The Four Layers & the Dependency Rule
| Layer | Holds | May depend on | Never references |
|---|---|---|---|
| Domain | Entities, value objects, business rules | Nothing (pure C#) | EF Core, ASP.NET, any other layer |
| Application | Use cases, ports (interfaces), DTOs | Domain | Infrastructure, the web |
| Infrastructure | DB, email, http — the adapters | Application, Domain | Presentation |
| Presentation | Controllers, UI, the composition root | Application (+ wires up the rest) | Domain rules directly |
The Dependency Rule reads down this list: each layer may only point at layers above it (toward the centre). Domain points at nothing; Presentation points inward through everything. If an arrow ever points outward — Domain referencing Infrastructure — the architecture is broken.
1. The Layers & the Dependency Rule
A layer is just a group of classes with the same job, kept in its own project. The Domain is the heart — your entities and the rules they enforce, written in plain C# with no framework in sight. The Application layer holds use cases: thin orchestrators that drive the Domain to get something done. Infrastructure is the messy outside world — databases, email, HTTP. Presentation is the entry point — a controller or console Main. The one law tying them together is the Dependency Rule: source-code references only ever point inward. Read this worked example and run it.
using System.Globalization;
using System;
// ═══════════════════════════════════════════════════════════════
// One file here for the runner, but imagine FOUR separate projects.
// The arrows show who is ALLOWED to reference whom. The golden rule:
// dependencies always point INWARD, toward the Domain. Nothing in
// the centre ever knows about the layers around it.
//
// Presentation -> Application -> Domain <- Infrastructure
// (controllers) (use cases) (rules) (db, email, http)
// ═══════════════════════════════════════════════════════════════
// ---------- DOMAIN (centre): pure rules, ZERO framework refs ----------
class Order
{
public string Customer { get; }
public decimal Total { get; private set; }
public Order(string customer)
{
// The Domain enforces its own invariants (things that must stay true).
if (string.IsNullOrWhiteSpace(customer))
throw new Exception("Customer is required");
Customer = customer;
}
public void AddItem(decimal price) => Total += price; // a business rule
}
// ---------- APPLICATION: orchestrates the Domain, no db/http here ----------
class PlaceOrder
{
public Order Handle(string customer, decimal price)
{
var order = new Order(customer); // use the Domain entity
order.AddItem(price); // let the Domain do the work
return order; // hand the result back outward
}
}
class Program
{
static void Main()
{
// Currency formatting follows the thread's culture: {value:C} prints
// £ in London, $ in Boston and € in Paris. Pin it when the output has
// to be the same everywhere — as it does on a page that shows you the
// result.
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-GB");
// ---------- PRESENTATION: the outer edge calls inward ----------
var useCase = new PlaceOrder();
var order = useCase.Handle("Alice", 19.99m);
Console.WriteLine($"Order for {order.Customer}"); // Order for Alice
Console.WriteLine($"Total: {order.Total:C}"); // Total: £19.99
// Notice: Domain never mentioned a database, a controller, or .NET
// plumbing. It just knows the rules. That is what makes it testable.
}
}
// ✅ Expected output:
// Order for Alice
// Total: £19.992. Ports & Adapters (Dependency Inversion)
Here's the puzzle: a use case needs to send email, but email is an outer concern — and inner layers can't depend on outer ones. The fix is the port & adapter pattern. A port is an interface defined in the inner layer ("someone must be able to notify a user"). An adapter is a class in Infrastructure that implements it (the real SendGrid call). The use case depends on the port, never the adapter — so even though data flows outward at runtime, the source-code dependency still points inward. That's dependency inversion, and it's the trick that makes the whole thing work.
using System;
// ═══════════════════════════════════════════════════════════════
// PORTS & ADAPTERS. A "port" is an interface the inner layers OWN.
// An "adapter" is an outer-layer class that implements it. The use
// case depends on the PORT (interface), never on the adapter — so
// the dependency arrow points INWARD, even though data flows out.
// ═══════════════════════════════════════════════════════════════
// ---------- APPLICATION owns the PORT (an interface = a promise) ----------
interface INotificationPort
{
void Send(string to, string message); // "someone must be able to notify"
}
// ---------- INFRASTRUCTURE owns the ADAPTER (the real implementation) ----------
class ConsoleEmailAdapter : INotificationPort
{
// In real life this would call SendGrid / SMTP. Here we just print,
// but the use case can't tell the difference — that's the point.
public void Send(string to, string message)
=> Console.WriteLine($"📧 to {to}: {message}");
}
// ---------- APPLICATION use case depends on the PORT, NOT the adapter ----------
class WelcomeUser
{
private readonly INotificationPort _notify; // interface, not a concrete class
// The adapter is INJECTED from outside (dependency injection). The use
// case has no idea whether it's email, SMS, or a fake — it only sees the port.
public WelcomeUser(INotificationPort notify) => _notify = notify;
public void Run(string user)
=> _notify.Send(user, $"Welcome aboard, {user}!");
}
class Program
{
static void Main()
{
// PRESENTATION wires the adapter to the use case (composition root).
INotificationPort adapter = new ConsoleEmailAdapter();
var useCase = new WelcomeUser(adapter);
useCase.Run("Bob"); // 📧 to Bob: Welcome aboard, Bob!
// Swap ConsoleEmailAdapter for an SmsAdapter and WelcomeUser
// never changes. The core stayed put; only the edge moved.
}
}
// ✅ Expected output:
// 📧 to Bob: Welcome aboard, Bob!Your turn. The program below is almost complete — define a Domain entity that validates its input, and declare an Application port. Fill in the blanks marked ___ using the hints, then run it.
using System;
// 🎯 YOUR TURN — define a Domain entity and an Application port (interface).
// Replace each ___ then press "Try it Yourself".
// 1) DOMAIN entity: a User with a Name. The constructor must reject empty names.
class User
{
public string Name { get; }
public User(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new Exception("Name is required");
Name = ___; // 👉 store the validated name (just: name)
}
}
// 2) APPLICATION port: an interface the inner layer OWNS. Name it
// INotificationPort with one method Send(string message).
interface ___ // 👉 name it: INotificationPort
{
void Send(string message); // the promise the core relies on
}
class Program
{
static void Main()
{
var user = new User("Carol");
Console.WriteLine($"Created user: {user.Name}");
Console.WriteLine($"Port defined: {nameof(INotificationPort)}");
// ✅ Expected output:
// Created user: Carol
// Port defined: INotificationPort
}
}3. Adapters & Use Cases That Point Inward
Now build the other half: an adapter that implements the port, and a use case that holds the interface type — not the concrete adapter. The adapter gets handed in from outside (this is dependency injection), so the use case stays blissfully ignorant of which implementation it's talking to. Get this right and you can drop in a fake adapter in your tests and a real one in production, changing nothing in between. Fill in the two blanks.
using System;
// 🎯 YOUR TURN — build an Infrastructure adapter and a use case that
// depends on the PORT, not the adapter. Fill in each ___.
// APPLICATION owns the port (given to you):
interface INotificationPort
{
void Send(string message);
}
// 1) INFRASTRUCTURE adapter: implement the port. Just print the message.
class ConsoleAdapter : ___ // 👉 implement INotificationPort
{
public void Send(string message)
=> Console.WriteLine($"🔔 {message}");
}
// 2) APPLICATION use case: it should hold an INotificationPort field,
// NOT a ConsoleAdapter. That keeps the dependency pointing inward.
class NotifyUser
{
private readonly ___ _port; // 👉 the INTERFACE type, not ConsoleAdapter
public NotifyUser(INotificationPort port) => _port = port;
public void Run() => _port.Send("Account created");
}
class Program
{
static void Main()
{
// PRESENTATION wires the concrete adapter into the use case.
INotificationPort adapter = new ConsoleAdapter();
var useCase = new NotifyUser(adapter);
useCase.Run();
// ✅ Expected output:
// 🔔 Account created
}
}🔎 Deep Dive: why the Domain has no framework references
If your Order entity carries EF Core attributes like [Key] or inherits DbContext, your business rules are now welded to a specific database technology. Change ORMs and you rewrite your core. Worse, you can't unit-test the rules without spinning up a database.
Keeping the Domain pure flips that around. The rules are just C#, so a test runs in microseconds with no setup. And because the Domain defines the ports (interfaces) it needs, Infrastructure depends on the Domain — never the reverse. The arrows stay pointing inward.
// ❌ Domain coupled to the framework — avoid
public class Order { [Key] public int Id { get; set; } } // EF attribute leaks in
// ✅ Domain stays pure — the mapping lives in Infrastructure instead
public class Order { public Guid Id { get; private set; } } // plain C#Putting It Together: testability is the payoff
Because the use case depends on a port, your test can supply a tiny fake adapter instead of a real database — no mocking framework, no I/O. This is the same WelcomeUser use case from section 2, now driven by a fake that records calls so the test can assert on them.
// A FAKE adapter — lives only in the test project
class FakeNotify : INotificationPort
{
public string? LastMessage;
public void Send(string to, string message) => LastMessage = message;
}
// The test: zero database, runs in microseconds
var fake = new FakeNotify();
var useCase = new WelcomeUser(fake); // inject the fake instead of email
useCase.Run("Bob");
Assert.Equal("Welcome aboard, Bob!", fake.LastMessage); // ✅ passesNothing in the core changed to make this testable — the inward-pointing dependency is the test seam. That's the whole return on the architecture's investment.
Pro Tips
- 💡 Enforce the rule with project references: make Domain, Application, Infrastructure, and Api separate .csproj files. If Domain has no reference to Infrastructure, a bad dependency simply won't compile.
- 💡 Name ports for the need, not the tech: INotificationPort, not ISendGridClient. The core states what it needs; Infrastructure decides how.
- 💡 Wire everything in one place — the composition root (usually Program.cs). That's the only spot allowed to know every concrete type.
- 💡 Don't over-apply it. A small CRUD app rarely needs four projects. Clean Architecture earns its keep in domains with real, changing business rules.
Common Errors (and the fix)
- Domain referencing EF Core / ASP.NET: if your entities use [Key], DbContext, or IActionResult, the centre now depends on the edge. Keep the Domain plain C# and put the mapping in Infrastructure.
- Anemic domain model: entities that are just public getters/setters with all the logic sitting in services. Push the rules into the entity (like Order.AddItem) so the Domain actually domains.
- Leaking infrastructure into the core: a use case that news up a SqlConnection directly. Depend on a port instead and inject the adapter — otherwise the core can't be tested or swapped.
- Circular dependencies between layers: Application referencing Infrastructure and Infrastructure referencing Application gives you a reference cycle that won't build. Define the port in the inner layer so only one arrow exists, pointing inward.
- "CS0246: type or namespace 'IOrderRepository' could not be found": the port is defined in a layer your current project doesn't reference. Move the interface inward (Application/Domain) and reference that project.
📋 Quick Reference
| Concept | In one line |
|---|---|
| Dependency Rule | source-code refs point inward only |
| Port | interface owned by the inner layer |
| Adapter | outer class implementing a port |
| Use case | Application class driving the Domain |
| Composition root | Program.cs wires adapters to ports |
| Test seam | inject a fake adapter for the port |
Frequently Asked Questions
Q: Where do interfaces like IOrderRepository actually live?
In the inner layer that needs them — Application or Domain — not in Infrastructure. Infrastructure implements them. That's what inverts the dependency so the arrow points inward.
Q: Isn't this just a lot of extra interfaces and projects?
For a throwaway CRUD app, yes — skip it. For a system with real business rules that must survive database and framework changes, the layers pay for themselves in testability and flexibility.
Q: How is this different from the classic three-tier (UI → Business → Data) layering?
In three-tier, Business depends on Data, so the core still knows about the database. Clean Architecture inverts that bottom arrow with a port, so Data depends on Business — the core depends on nothing.
Q: Where do DTOs and mapping go?
DTOs belong to the Application layer (the shape data crosses the boundary in). Mapping between a Domain entity and a database row lives in Infrastructure, keeping the Domain ignorant of persistence.
Q: Can a controller call the Domain directly?
It shouldn't. Controllers call use cases in the Application layer, which orchestrate the Domain. Keeping that path consistent is what lets you reuse a use case from a web API, a CLI, or a background job.
Mini-Challenge: a core with zero infra dependencies
No blanks this time — just a brief and an outline. Wire a use case, an entity, and an in-memory adapter so the core depends only on the port. Build it, run it, and check your output against the example in the comments. This is the smallest complete Clean Architecture slice there is.
using System;
// 🎯 MINI-CHALLENGE: a self-contained core with ZERO infra dependencies.
//
// Build these three pieces, then wire them in Main:
// 1. DOMAIN entity Task(string title) — reject an empty title.
// 2. APPLICATION port ITaskStore with void Save(Task task);
// 3. INFRASTRUCTURE in-memory adapter InMemoryTaskStore : ITaskStore
// — keep a List<Task> and print "Saved: {title}" inside Save.
// 4. APPLICATION use case AddTask — its constructor takes an
// ITaskStore (the PORT, not the adapter). A Run(string title)
// method should create the Task and call store.Save(task).
// 5. In Main: new up the adapter, inject it into AddTask, call
// Run("Buy milk"). The use case must never mention InMemoryTaskStore.
//
// ✅ Expected output:
// Saved: Buy milk
// your code here🎉 Lesson Complete
- ✅ Four layers: Domain (rules), Application (use cases), Infrastructure (db/email/http), Presentation (entry point)
- ✅ The Dependency Rule: source-code references point inward only
- ✅ Ports are interfaces the inner layers own; adapters implement them on the edge
- ✅ A use case depends on the port, never the adapter — that's dependency inversion
- ✅ A pure Domain (no framework refs) is what makes the core fast to unit-test with fakes
Practice quiz
What does the Dependency Rule state in Clean Architecture?
- Dependencies point outward, toward the database
- Every layer depends on every other layer
- Source-code dependencies only ever point inward, toward the Domain
- The Domain depends on Infrastructure
Answer: Source-code dependencies only ever point inward, toward the Domain. The Dependency Rule says source-code references only point inward, toward the centre. The Domain depends on nothing.
Which are the four layers, from centre outward?
- Domain, Application, Infrastructure, Presentation
- Database, Service, Controller, View
- Model, View, Controller, Router
- Entity, Repository, Service, API
Answer: Domain, Application, Infrastructure, Presentation. The four layers are Domain (rules), Application (use cases), Infrastructure (db/email/http), and Presentation (entry point).
What belongs in the Domain layer?
- Entity Framework DbContext and SQL
- ASP.NET controllers
- The composition root
- Entities, value objects, and business rules in pure C# with no framework references
Answer: Entities, value objects, and business rules in pure C# with no framework references. The Domain holds entities, value objects, and business rules as pure C# — it never references EF Core, ASP.NET, or any other layer.
In ports and adapters, what is a 'port'?
- A concrete class in Infrastructure
- An interface defined and owned by an inner layer
- A network socket number
- A database connection string
Answer: An interface defined and owned by an inner layer. A port is an interface the inner layer owns ('someone must be able to notify a user'); the adapter implements it in Infrastructure.
What is an 'adapter'?
- An outer-layer class (e.g. in Infrastructure) that implements a port
- An interface owned by the Domain
- A use case in the Application layer
- The composition root
Answer: An outer-layer class (e.g. in Infrastructure) that implements a port. An adapter is the real implementation of a port, living in an outer layer such as Infrastructure (e.g. a SendGrid email adapter).
A use case should depend on which of these?
- The concrete adapter class
- The database driver directly
- The port (interface), never the adapter
- The web framework
Answer: The port (interface), never the adapter. The use case depends on the port, so the dependency arrow points inward even though data flows outward at runtime — that's dependency inversion.
Why must the Domain have no framework or database references?
- To make it run faster on the GPU
- So the business rules aren't welded to a technology and can be unit-tested in microseconds with no database
- Because frameworks are not allowed in C#
- To reduce the binary size only
Answer: So the business rules aren't welded to a technology and can be unit-tested in microseconds with no database. A pure Domain isn't coupled to any ORM or framework, so its rules are testable in microseconds and survive technology changes.
Where is the composition root, where adapters are wired to ports?
- In the Domain layer
- Inside each entity
- In the database
- Usually Program.cs in the Presentation layer — the only place allowed to know every concrete type
Answer: Usually Program.cs in the Presentation layer — the only place allowed to know every concrete type. The composition root (typically Program.cs) is the single place that wires concrete adapters to the ports via dependency injection.
How does Clean Architecture differ from classic three-tier (UI to Business to Data) layering?
- It removes the Business layer
- It inverts the Business-to-Data arrow with a port, so Data depends on Business and the core depends on nothing
- It makes the UI depend on the database directly
- There is no difference
Answer: It inverts the Business-to-Data arrow with a port, so Data depends on Business and the core depends on nothing. In three-tier, Business depends on Data so the core knows the database. Clean Architecture inverts that with a port, so Data depends on Business.
What makes the inward-pointing dependency a useful 'test seam'?
- It requires a mocking framework
- It forces all tests to hit the database
- A test can inject a tiny fake adapter for the port instead of a real database, with no I/O
- It disables the Domain during tests
Answer: A test can inject a tiny fake adapter for the port instead of a real database, with no I/O. Because the use case depends on a port, a test supplies a fake adapter implementing it — no database, no mocking framework, runs in microseconds.
Continue this course
- Previous: Test-Driven Development in Real Projects
- Next: Domain-Driven Design (Aggregates, Value Objects, Repositories) — Apply DDD tactical patterns to model complex business domains in C#
- Quick reference: C# cheat sheet