Why Should .NET Developers Care?
Look, I get it. You have got a working project. The controllers call the DbContext directly. It works. Why bother with all these layers?
Here's the honest answer: for a small personal project or a quick prototype, you probably don't need this. Ship it and move on.
But the moment your project:
- Has more than one developer
- Needs unit tests (not just integration tests that spin up a database)
- Will live in production for more than a few months
- Has business logic more complex than basic CRUD
...you'll want some kind of structure. And Clean Architecture is one of the best options we have in the .NET ecosystem right now.
What you get:
- Testability - Your business logic is isolated. You can test it without a database, without HTTP, without anything external.
- Flexibility - Want to swap SQL Server for PostgreSQL? Done. Switch from SendGrid to AWS SES? The domain layer doesn't even blink.
- Onboarding - New developers can look at the solution structure and immediately know where things live.
- Longevity - The core of your app stays clean even as the frameworks around it evolve.
The Four Application Layers
Let me break down each layer the way I would explain it to a colleague over coffee.
1. Domain Layer (The Heart)
This is where your business entities live, the concepts your application is built around. Think Product, Order, Customer, Invoice.
It also includes:
- Value Objects (like
Money, Address, Email, things defined by their value, not identity)
- Enums (like
OrderStatus.Pending, OrderStatus.Shipped)
- Domain Exceptions (like
InsufficientStockException)
- Domain Events (optional, but useful for reactive patterns)
Rules:
- No dependencies on anything else. Period. No NuGet packages (except maybe a validation library). No reference to EF Core, no reference to ASP.NET.
- This layer defines what the business looks like, not how it's implemented.
Here's a real example:
csharp
namespace CleanArch.Domain.Entities;
public class Product
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Description { get; private set; }
public decimal Price { get; private set; }
public int StockQuantity { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
private Product() { }
public Product(string name, string description, decimal price, int stockQuantity)
{
Id = Guid.NewGuid();
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? string.Empty;
Price = price > 0 ? price : throw new ArgumentException("Price must be positive.", nameof(price));
StockQuantity = stockQuantity >= 0 ? stockQuantity : throw new ArgumentException("Stock can't be negative.", nameof(stockQuantity));
CreatedAt = DateTime.UtcNow;
}
public void UpdatePrice(decimal newPrice)
{
if (newPrice <= 0)
throw new ArgumentException("Price must be positive.");
Price = newPrice;
UpdatedAt = DateTime.UtcNow;
}
public void ReduceStock(int quantity)
{
if (quantity > StockQuantity)
throw new InvalidOperationException("Insufficient stock.");
StockQuantity -= quantity;
UpdatedAt = DateTime.UtcNow;
}
}
Notice a few things:
- The setters are
private. No one outside can mess with the state directly.
- Validation happens inside the entity. The entity protects its own invariants.
- There's no
[Table("Products")] attribute, no EF-specific junk. This is pure business logic.
2. Application Layer (The Use Cases)
This is where things get orchestrated. The Application layer defines what your system can do, the operations, the workflows, the use cases.
It contains:
- Service interfaces (like
IProductService)
- Repository interfaces (like
IProductRepository)
- DTOs (Data Transfer Objects: what goes in and out of use cases)
- Validators (input validation with something like FluentValidation)
- Application service implementations
Rules:
- References the Domain layer only.
- Defines interfaces that the outer layers will implement.
- No concrete infrastructure code here. If you see
SqlConnection or SmtpClient in this layer, something's wrong.
Here's how an interface and a service might look:
csharp
namespace CleanArch.Application.Interfaces;
public interface IProductRepository
{
Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IEnumerable<Product>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(Product product, CancellationToken ct = default);
Task UpdateAsync(Product product, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}
csharp
namespace CleanArch.Application.Interfaces;
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
And the DTOs:
csharp
namespace CleanArch.Application.DTOs;
public record CreateProductRequest(
string Name,
string Description,
decimal Price,
int StockQuantity
);
public record ProductResponse(
Guid Id,
string Name,
string Description,
decimal Price,
int StockQuantity,
DateTime CreatedAt
);
Now the service that ties it all together:
csharp
using CleanArch.Application.DTOs;
using CleanArch.Application.Interfaces;
using CleanArch.Domain.Entities;
namespace CleanArch.Application.Services;
public class ProductService
{
private readonly IProductRepository _productRepository;
private readonly IUnitOfWork _unitOfWork;
public ProductService(IProductRepository productRepository, IUnitOfWork unitOfWork)
{
_productRepository = productRepository;
_unitOfWork = unitOfWork;
}
public async Task<ProductResponse> CreateProductAsync(CreateProductRequest request, CancellationToken ct = default)
{
var product = new Product(
request.Name,
request.Description,
request.Price,
request.StockQuantity
);
await _productRepository.AddAsync(product, ct);
await _unitOfWork.SaveChangesAsync(ct);
return new ProductResponse(
product.Id,
product.Name,
product.Description,
product.Price,
product.StockQuantity,
product.CreatedAt
);
}
public async Task<ProductResponse?> GetProductByIdAsync(Guid id, CancellationToken ct = default)
{
var product = await _productRepository.GetByIdAsync(id, ct);
if (product is null)
return null;
return new ProductResponse(
product.Id,
product.Name,
product.Description,
product.Price,
product.StockQuantity,
product.CreatedAt
);
}
public async Task<IEnumerable<ProductResponse>> GetAllProductsAsync(CancellationToken ct = default)
{
var products = await _productRepository.GetAllAsync(ct);
return products.Select(p => new ProductResponse(
p.Id,
p.Name,
p.Description,
p.Price,
p.StockQuantity,
p.CreatedAt
));
}
}
See how clean this is? The service doesn't know about databases, HTTP, or any framework. It just works with interfaces and domain objects. That's the whole point.
3. Infrastructure Layer (The Dirty Work)
This is where reality shows up. The Infrastructure layer provides the concrete implementations of all those interfaces you defined in the Application layer.
It contains:
- EF Core DbContext and configurations
- Repository implementations
- External service integrations (email, messaging, file storage, payment gateways)
- Authentication implementations
- Dependency Injection registration
Rules:
- References both Domain and Application layers.
- Implements the interfaces defined in the Application layer.
- This is where NuGet packages like
Microsoft.EntityFrameworkCore, SendGrid, MassTransit, etc., belong.
Here's the DbContext:
csharp
using CleanArch.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace CleanArch.Infrastructure.Persistence;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Price).HasPrecision(18, 2);
});
base.OnModelCreating(modelBuilder);
}
}
And the repository implementation:
csharp
using CleanArch.Application.Interfaces;
using CleanArch.Domain.Entities;
using CleanArch.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace CleanArch.Infrastructure.Repositories;
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await _context.Products.FindAsync(new object[] { id }, ct);
public async Task<IEnumerable<Product>> GetAllAsync(CancellationToken ct = default)
=> await _context.Products.ToListAsync(ct);
public async Task AddAsync(Product product, CancellationToken ct = default)
=> await _context.Products.AddAsync(product, ct);
public async Task UpdateAsync(Product product, CancellationToken ct = default)
{
_context.Products.Update(product);
await Task.CompletedTask;
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var product = await _context.Products.FindAsync(new object[] { id }, ct);
if (product is not null)
_context.Products.Remove(product);
}
}
Unit of Work implementation (wrapping EF Core's SaveChanges):
csharp
using CleanArch.Application.Interfaces;
using CleanArch.Infrastructure.Persistence;
namespace CleanArch.Infrastructure.Repositories;
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
public async Task<int> SaveChangesAsync(CancellationToken ct = default)
=> await _context.SaveChangesAsync(ct);
}
And here's the DI registration helper that keeps Program.cs tidy:
csharp
using CleanArch.Application.Interfaces;
using CleanArch.Infrastructure.Persistence;
using CleanArch.Infrastructure.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace CleanArch.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<IProductRepository, ProductRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
return services;
}
}
4. Presentation / WebAPI Layer (The Front Door)
This is the entry point. In most .NET projects, it's an ASP.NET Core Web API project. It handles HTTP, routing, serialization, middleware, and wiring everything together.
Rules:
- References Application and Infrastructure (for DI registration).
- Should NOT contain business logic. A controller that's more than 10-15 lines per action is a code smell.
- This is where you register everything in
Program.cs.
Here's a clean controller:
csharp
using CleanArch.Application.DTOs;
using CleanArch.Application.Services;
using Microsoft.AspNetCore.Mvc;
namespace CleanArch.WebAPI.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ProductService _productService;
public ProductsController(ProductService productService)
{
_productService = productService;
}
[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken ct)
{
var products = await _productService.GetAllProductsAsync(ct);
return Ok(products);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var product = await _productService.GetProductByIdAsync(id, ct);
return product is null ? NotFound() : Ok(product);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProductRequest request, CancellationToken ct)
{
var product = await _productService.CreateProductAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
}
And Program.cs:
csharp
using CleanArch.Application.Services;
using CleanArch.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddScoped<ProductService>();
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Short, readable, no noise. Every layer does its job and nothing more.
Project Structure at a Glance
Here's how the solution looks when you lay it all out:

And here's the dependency flow between projects:

The key insight from this diagram: Infrastructure and WebAPI both depend on Application, but Application never depends on them. That's the inversion of control at work.