Advanced Track
Clean Architecture in .NET
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.
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.
Worked example: four layers, one inward-pointing flow
Notice the Domain mentions no database, no framework — just rules.
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)
// ════════════════
...2. 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.
Worked example: a port the core owns, an adapter the edge supplies
The use case sees only INotificationPort — swap the adapter freely.
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) ----------
in
...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.
🎯 Your turn: a Domain entity + an Application port
Validate the entity, then name the port interface correctly.
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) APPLICA
...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.
🎯 Your turn: an adapter + an inward-pointing use case
Implement the port, then store it by its INTERFACE type in the use case.
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) APPLICATI
...🔎 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, andApiseparate.csprojfiles. If Domain has no reference to Infrastructure, a bad dependency simply won't compile. - 💡 Name ports for the need, not the tech:
INotificationPort, notISendGridClient. 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, orIActionResult, 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 aSqlConnectiondirectly. 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.
🎯 Mini-Challenge: entity + port + in-memory adapter + use case
Inject the adapter through the port so the use case never names it.
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
// ITaskS
...🎉 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
- ✅ Next lesson: Domain-Driven Design — model that core layer with real richness
Sign up for free to track which lessons you've completed and get learning reminders.