This series builds a production-shaped .NET backend from the ground up — one concern per part, one growing codebase throughout. We start where every maintainable backend starts: with an architecture that keeps business rules independent of frameworks, databases, and delivery mechanisms. That's Clean Architecture, and on .NET it maps to a small set of projects with strictly one-way references.

All code in this series lives in a single companion repository and is verified — every command output you see was actually captured from the running solution on .NET 10.
The dependency rule
Clean Architecture is one rule wearing several diagrams: source code dependencies point inward. The domain knows nothing about the database; the application layer knows nothing about ASP.NET Core; only the outermost layer knows the concrete technologies. In .NET, project references enforce this at compile time:
ModernDotNetBackend.sln
└── src/
├── Roadmap.Domain (no references)
├── Roadmap.Application → Domain
├── Roadmap.Infrastructure → Application
└── Roadmap.Api → Infrastructure
The API can see everything at composition time, but your business code physically cannot using Microsoft.EntityFrameworkCore from the domain — the compiler stops you. That is the practical advantage over "we'll be disciplined": the architecture is checked on every build.
The domain: entities with behavior
The domain holds entities and the rules that must never be broken. Note this is not a bag of getters and setters — the invariant lives on the entity:
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
public void Reserve(int quantity)
{
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
if (quantity > Stock) throw new InvalidOperationException($"Only {Stock} left in stock.");
Stock -= quantity;
}
}
No controller, no repository, no service can put a product into negative stock, because the only door is Reserve, and Reserve says no.
The application layer: use cases and ports
The application layer orchestrates domain objects into use cases, and — crucially — defines the interfaces it needs without implementing them. These interfaces are the "ports":
public interface IProductRepository
{
Task<Product?> GetAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<Product>> ListAsync(CancellationToken ct = default);
Task AddAsync(Product product, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
public class ProductService(IProductRepository repository)
{
public async Task<Product> ReserveAsync(int id, int quantity, CancellationToken ct)
{
var product = await repository.GetAsync(id, ct)
?? throw new KeyNotFoundException($"Product {id} not found.");
product.Reserve(quantity);
await repository.SaveChangesAsync(ct);
return product;
}
}
ProductService reads like the requirement: fetch, apply the domain rule, persist. It compiles without EF Core, SQL Server, or ASP.NET Core anywhere in sight — which also means it unit-tests with an in-memory fake and zero infrastructure.
Infrastructure: adapters
Infrastructure implements the ports with real technology. Part 2 dives into the repository implementation; the shape is:
public class EfProductRepository(AppDbContext db) : IProductRepository
{
public Task<Product?> GetAsync(int id, CancellationToken ct = default) =>
db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
// ...
}
Swap SQLite for PostgreSQL, or EF Core for Dapper (part 3 does exactly that for reads), and neither the domain nor the application layer changes a line.
The API: composition root
The outermost project wires ports to adapters and exposes HTTP:
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlite(connectionString));
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
builder.Services.AddScoped<ProductService>();
Controllers stay thin — translate HTTP to use-case calls, and domain exceptions to status codes:
[HttpPost("{id:int}/reserve")]
public async Task<IActionResult> Reserve(int id, [FromQuery] int quantity, CancellationToken ct)
{
try { return Ok(await products.ReserveAsync(id, quantity, ct)); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
}
Verified against the running API — the domain rule surfaces as a clean 409:
POST /products/2/reserve?quantity=99
HTTP 409 {"error":"Only 2 left in stock."}
POST /products/2/reserve?quantity=1
HTTP 200 {"id":2,"name":"4K monitor","price":329.99,"stock":1}
Testing falls out of the architecture
The most immediate payoff arrives the first time you write a test. Because ProductService depends on an interface, a test doubles the repository in ten lines and exercises real business logic with zero infrastructure:
public class FakeProductRepository : IProductRepository
{
public readonly List<Product> Items = [];
public Task<Product?> GetAsync(int id, CancellationToken ct = default) =>
Task.FromResult(Items.FirstOrDefault(p => p.Id == id));
public Task<IReadOnlyList<Product>> ListAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<Product>>(Items);
public Task AddAsync(Product product, CancellationToken ct = default)
{ Items.Add(product); return Task.CompletedTask; }
public Task SaveChangesAsync(CancellationToken ct = default) => Task.CompletedTask;
}
[Fact]
public async Task Reserve_fails_when_stock_is_insufficient()
{
var repo = new FakeProductRepository();
repo.Items.Add(new Product { Id = 1, Name = "Monitor", Stock = 2 });
var service = new ProductService(repo);
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.ReserveAsync(1, 5, CancellationToken.None));
}
No test database, no in-memory EF provider with subtly different semantics, no mocking framework gymnastics. The tests run in milliseconds, which is the difference between a suite developers run before every commit and one they run before every release. Keep integration tests too — a handful against real infrastructure catches mapping and SQL issues — but the pyramid gets its wide, fast base from this seam.
Projects or folders?
A fair challenge: couldn't these be four folders in one project? They could — and for a small service, folders with an architecture test (NetArchTest or ArchUnitNET asserting "Domain must not reference EF Core") are a legitimate lightweight alternative. Separate projects buy you three things folders don't:
- The compiler enforces the rule with no extra tooling — an illegal
usingis a build error, not a review comment. - Dependency hygiene per layer — the Domain csproj has no NuGet packages at all, and that emptiness is visible and auditable.
- Reuse boundaries — a background worker or AdminTool CLI can reference Application + Infrastructure without dragging in ASP.NET Core.
The cost is solution ceremony. Four projects is the sweet spot for most services; if you find yourself with Roadmap.Domain.Abstractions.Common, you've left the sweet spot.
Common mistakes that quietly break the architecture
- Returning EF
IQueryablefrom the application layer. The moment a controller can append.Where(), query composition — and its N+1s — escape the boundary. Return materialized lists or purpose-built read models. - Anemic domain + fat services. If every entity is getters and setters and all logic lives in services, you have layered procedural code, not a domain model. Push invariants onto entities (
Reserveabove), keep services as orchestrators. - DTOs from the API leaking inward. Request/response records belong to the API layer. When
CreateProductRequestshows up in a domain method signature, the delivery mechanism has invaded the core. - "Just this once" references. One
using Microsoft.EntityFrameworkCorein the application layer to useFirstOrDefaultAsyncseems harmless; six months later the seam is gone. The project split makes "just this once" impossible, which is precisely its job.
Migrating an existing codebase
You rarely get to start clean. The incremental path that works:
- Create the Domain project and move entities into it — pure classes first, dragging invariants out of services onto entities as you go.
- Extract interfaces for the data access your services actually use (not a generic wrapper) into an Application project, and move the orchestration logic behind them.
- Leave the existing data-access code where it is, but make it implement the new interfaces — infrastructure by another name.
- Add the reference rules last, and fix what breaks; every break is a dependency that was always wrong but previously invisible.
Steps 1–2 deliver testability immediately; you can stop there for months and still be better off. This is also exactly how you eat a legacy N-tier codebase: one aggregate at a time, not a big-bang rewrite.
What Clean Architecture is not
- Not a folder ritual. Four projects is enough for most services; sixteen projects with one class each is cargo cult. Add layers when a boundary earns its keep.
- Not "never use EF types". The debate about EF entities vs. separate persistence models is a trade-off; this series maps domain entities directly and stays honest about it.
- Not free. Every port is indirection. For a 3-endpoint internal tool, a single project is the right architecture. Reach for these boundaries when the domain has rules worth protecting and the codebase has years ahead of it.
Where the series goes next
With the skeleton in place, each part deepens one slice: the repository pattern properly (part 2), Dapper for read-heavy paths (part 3), JWT auth (part 4), caching (part 5), Docker (part 6), Azure deployment (part 7), CI/CD (part 8), microservice boundaries (part 9), and observability with OpenTelemetry (part 10). Clone the companion repository, run dotnet run in src/Roadmap.Api, and follow along.
Comments (0)
No comments yet — be the first to share your thoughts.