Clean Architecture in ASP.NET Core — A Complete Practical Guide
Every developer has worked on that codebase. You know the one — controllers that are 500 lines long, business logic mixed with database queries, and a codebase so tangled that changing one thing breaks three others.
Clean Architecture solves this. It is not just a design pattern — it is a way of thinking about software that keeps your business logic isolated, your code testable, and your application ready to grow without falling apart.
In this guide I will walk you through Clean Architecture in ASP.NET Core from first principles to a complete working example. By the end you will understand the four layers, how dependencies flow between them, and how to structure a real project correctly.
Table of Contents
- What is Clean Architecture?
- Why use it — and when NOT to
- The 4 layers explained
- Solution structure — folder and project setup
- Domain Layer — entities, value objects, interfaces
- Application Layer — use cases, CQRS, MediatR
- Infrastructure Layer — EF Core, repositories
- Presentation Layer — ASP.NET Core Web API
- Wiring it all together — Dependency Injection
- Testing Clean Architecture
- Common mistakes to avoid
01 — Foundation What is Clean Architecture?
Clean Architecture was introduced by Robert C. Martin (Uncle Bob) in his 2012 blog post and later detailed in his book. It is a software design philosophy that separates the elements of a design into ring levels — with the key rule that dependencies can only point inward. Outer layers can depend on inner layers, but never the other way around.
In ASP.NET Core terms, this means your business logic knows nothing about your database, your API framework, or your UI. It is completely isolated at the centre of your application.
The core idea in one sentence
Your business rules are the most valuable part of your application. Clean Architecture protects them from everything else.
Dependencies always point inward — never outward
02 — Decision Why use it — and when NOT to
Why you should use Clean Architecture
- Testability — because the Application Core does not depend on Infrastructure, it is very easy to write automated unit tests for this layer without touching a real database
- Swap databases easily — want to switch from SQL Server to PostgreSQL? Change only the Infrastructure layer. Domain and Application stay untouched
- Independent of frameworks — your business logic should be testable without databases or UI, and you should avoid tight coupling with ASP.NET Core or Entity Framework
- Onboard new developers faster — the structure is predictable. A developer who has never seen your codebase knows exactly where to find business logic, database code, and API code
- Future-proof — by structuring a project around clean architecture, developers can create systems that are resilient to changes in technology, tools, and databases
When NOT to use it
Honest advice
Clean Architecture adds overhead. If you are building a simple CRUD API with 5 endpoints and a 2-week deadline, use a straightforward layered architecture instead. Use Clean Architecture for enterprise applications that will live for 3+ years and grow in complexity.
03 — Core Concepts The 4 Layers Explained
Layer 1 — Innermost
Domain
Entities, Value Objects, Domain Events, and Repository Interfaces. Zero external dependencies. Pure C# only.
Layer 2
Application
Use Cases, CQRS Commands/Queries, MediatR Handlers, DTOs, Service Interfaces, Validators. Framework-agnostic.
Layer 3
Infrastructure
EF Core DbContext, Repository implementations, Email/SMS services, External API clients, File storage.
Layer 4 — Outermost
Presentation
ASP.NET Core Controllers or Minimal APIs, Middleware, Filters, Program.cs, and DI configuration.
04 — Project Setup Solution Structure
We will build a Product Management API using Clean Architecture. Here is the solution structure we will create — each layer is a separate C# project:
SolutionProductManagement.sln │ ├── src/ │ ├── ProductManagement.Domain ← Layer 1: Entities, Interfaces │ ├── ProductManagement.Application ← Layer 2: Use Cases, CQRS │ ├── ProductManagement.Infrastructure ← Layer 3: EF Core, Repos │ └── ProductManagement.API ← Layer 4: Controllers, DI │ └── tests/ ├── ProductManagement.Domain.Tests └── ProductManagement.Application.Tests
Create the solution with CLI
bash# Create solution and projects dotnet new sln -n ProductManagement dotnet new classlib -n ProductManagement.Domain -o src/Domain dotnet new classlib -n ProductManagement.Application -o src/Application dotnet new classlib -n ProductManagement.Infrastructure -o src/Infrastructure dotnet new webapi -n ProductManagement.API -o src/API # Add projects to solution dotnet sln add src/Domain/ProductManagement.Domain.csproj dotnet sln add src/Application/ProductManagement.Application.csproj dotnet sln add src/Infrastructure/ProductManagement.Infrastructure.csproj dotnet sln add src/API/ProductManagement.API.csproj # Add project references (dependency direction: inward only) dotnet add src/Application reference src/Domain dotnet add src/Infrastructure reference src/Domain dotnet add src/Infrastructure reference src/Application dotnet add src/API reference src/Application dotnet add src/API reference src/Infrastructure
Notice the API project
The API project references both Application AND Infrastructure — but only to wire up dependency injection in Program.cs. Controllers should only ever call Application layer code through MediatR. Never reference Infrastructure directly from a controller.
05 — Layer 1 Domain Layer
The Domain layer is at the center of the architecture and represents the business problem and its governing rules. It must be explicit, robust, and easy to reason about — containing no dependencies on other application layers, no external libraries, and no direct use of EF Core or ASP.NET.
Domain Entity
C# — Domain// ProductManagement.Domain/Entities/Product.cs namespace ProductManagement.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 Stock { get; private set; } public DateTime CreatedAt { get; private set; } public bool IsActive { get; private set; } // Private constructor — use factory method private Product() { } // Factory method with domain validation public static Product Create(string name, string description, decimal price, int stock) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Product name cannot be empty"); if (price <= 0) throw new ArgumentException("Price must be greater than zero"); if (stock < 0) throw new ArgumentException("Stock cannot be negative"); return new Product { Id = Guid.NewGuid(), Name = name, Description = description, Price = price, Stock = stock, CreatedAt = DateTime.UtcNow, IsActive = true }; } // Domain behaviour — business rules live here public void UpdatePrice(decimal newPrice) { if (newPrice <= 0) throw new ArgumentException("Price must be greater than zero"); Price = newPrice; } public void ReduceStock(int quantity) { if (quantity <= 0) throw new ArgumentException("Quantity must be positive"); if (Stock < quantity) throw new InvalidOperationException("Insufficient stock"); Stock -= quantity; } public void Deactivate() => IsActive = false; }
Repository Interface (defined in Domain)
C# — Domain// ProductManagement.Domain/Interfaces/IProductRepository.cs namespace ProductManagement.Domain.Interfaces; public interface IProductRepository { Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default); Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken ct = default); Task<IReadOnlyList<Product>> GetActiveAsync(CancellationToken ct = default); Task AddAsync(Product product, CancellationToken ct = default); Task UpdateAsync(Product product, CancellationToken ct = default); Task DeleteAsync(Guid id, CancellationToken ct = default); Task<bool> ExistsAsync(Guid id, CancellationToken ct = default); } // Also define the Unit of Work pattern public interface IUnitOfWork { IProductRepository Products { get; } Task<int> SaveChangesAsync(CancellationToken ct = default); }
06 — Layer 2 Application Layer — CQRS with MediatR
The Application Layer defines the business use cases and application-specific logic. It manages how different parts of the system interact to fulfill business requirements without worrying about how the data is stored or presented in the user interface. This layer is framework-agnostic — no dependencies on EF Core or ASP.NET.
In modern ASP.NET Core projects the Application layer implements the CQRS pattern (Command Query Responsibility Segregation) using MediatR. Commands change state, Queries read state.
Install MediatR and FluentValidation
dotnet add src/Application package MediatR
dotnet add src/Application package FluentValidation
dotnet add src/Application package FluentValidation.DependencyInjectionExtensions
Create Product Command (Write operation)
C# — Application// Application/Products/Commands/CreateProduct/CreateProductCommand.cs public record CreateProductCommand( string Name, string Description, decimal Price, int Stock ) : IRequest<Guid>; // Command Handler public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Guid> { private readonly IUnitOfWork _uow; public CreateProductCommandHandler(IUnitOfWork uow) => _uow = uow; public async Task<Guid> Handle(CreateProductCommand cmd, CancellationToken ct) { // Domain layer validates business rules — not the handler var product = Product.Create(cmd.Name, cmd.Description, cmd.Price, cmd.Stock); await _uow.Products.AddAsync(product, ct); await _uow.SaveChangesAsync(ct); return product.Id; } } // Validator with FluentValidation public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand> { public CreateProductCommandValidator() { RuleFor(x => x.Name) .NotEmpty().WithMessage("Product name is required") .MaximumLength(200).WithMessage("Name cannot exceed 200 characters"); RuleFor(x => x.Price) .GreaterThan(0).WithMessage("Price must be greater than zero"); RuleFor(x => x.Stock) .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative"); } }
Get Products Query (Read operation)
C# — Application// Application/Products/Queries/GetProducts/ProductDto.cs public record ProductDto( Guid Id, string Name, string Description, decimal Price, int Stock, bool IsActive ); // Query public record GetAllProductsQuery() : IRequest<IReadOnlyList<ProductDto>>; // Query Handler public class GetAllProductsQueryHandler : IRequestHandler<GetAllProductsQuery, IReadOnlyList<ProductDto>> { private readonly IProductRepository _repo; public GetAllProductsQueryHandler(IProductRepository repo) => _repo = repo; public async Task<IReadOnlyList<ProductDto>> Handle( GetAllProductsQuery query, CancellationToken ct) { var products = await _repo.GetActiveAsync(ct); return products.Select(p => new ProductDto( p.Id, p.Name, p.Description, p.Price, p.Stock, p.IsActive )).ToList().AsReadOnly(); } }
07 — Layer 3 Infrastructure Layer — EF Core & Repository
The Infrastructure layer implements the interfaces defined in Domain. Changes in the Infrastructure layer should not affect other layers — it is abstracted through interfaces so that a change in Infrastructure does not provoke any changes in other layers.
C# — Infrastructure// Infrastructure/Persistence/AppDbContext.cs public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Product> Products => Set<Product>(); protected override void OnModelCreating(ModelBuilder builder) { builder.Entity<Product>(e => { e.HasKey(p => p.Id); e.Property(p => p.Name).IsRequired().HasMaxLength(200); e.Property(p => p.Price).HasPrecision(18, 2); e.HasIndex(p => p.Name); }); } } // Infrastructure/Repositories/ProductRepository.cs public class ProductRepository : IProductRepository { private readonly AppDbContext _db; public ProductRepository(AppDbContext db) => _db = db; public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default) => await _db.Products.FirstOrDefaultAsync(p => p.Id == id, ct); public async Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken ct = default) => await _db.Products.AsNoTracking().ToListAsync(ct); public async Task<IReadOnlyList<Product>> GetActiveAsync(CancellationToken ct = default) => await _db.Products.AsNoTracking() .Where(p => p.IsActive).ToListAsync(ct); public async Task AddAsync(Product product, CancellationToken ct = default) => await _db.Products.AddAsync(product, ct); public Task UpdateAsync(Product product, CancellationToken ct = default) { _db.Products.Update(product); return Task.CompletedTask; } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { var product = await GetByIdAsync(id, ct); if (product != null) _db.Products.Remove(product); } public async Task<bool> ExistsAsync(Guid id, CancellationToken ct = default) => await _db.Products.AnyAsync(p => p.Id == id, ct); } // Unit of Work implementation public class UnitOfWork : IUnitOfWork { private readonly AppDbContext _db; public IProductRepository Products { get; } public UnitOfWork(AppDbContext db, IProductRepository products) { _db = db; Products = products; } public Task<int> SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct); }
08 — Layer 4 Presentation Layer — ASP.NET Core Web API
The Presentation layer is the thinnest layer. Controllers do almost nothing — they receive the HTTP request, dispatch a MediatR command or query, and return the result. All business logic lives in the Application layer.
C# — API// API/Controllers/ProductsController.cs [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly IMediator _mediator; public ProductsController(IMediator mediator) => _mediator = mediator; // GET /api/products [HttpGet] public async Task<IActionResult> GetAll(CancellationToken ct) { var products = await _mediator.Send(new GetAllProductsQuery(), ct); return Ok(products); } // GET /api/products/{id} [HttpGet("{id:guid}")] public async Task<IActionResult> GetById(Guid id, CancellationToken ct) { var product = await _mediator.Send(new GetProductByIdQuery(id), ct); return product == null ? NotFound() : Ok(product); } // POST /api/products [HttpPost] public async Task<IActionResult> Create( CreateProductCommand cmd, CancellationToken ct) { var id = await _mediator.Send(cmd, ct); return CreatedAtAction(nameof(GetById), new { id }, new { id }); } // DELETE /api/products/{id} [HttpDelete("{id:guid}")] public async Task<IActionResult> Delete(Guid id, CancellationToken ct) { await _mediator.Send(new DeleteProductCommand(id), ct); return NoContent(); } }
09 — Wiring It Up Dependency Injection in Program.cs
C# — Program.cs// API/Program.cs var builder = WebApplication.CreateBuilder(args); // ── Infrastructure Layer ── builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); builder.Services.AddScoped<IProductRepository, ProductRepository>(); builder.Services.AddScoped<IUnitOfWork, UnitOfWork>(); // ── Application Layer ── builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly( typeof(CreateProductCommandHandler).Assembly)); builder.Services.AddValidatorsFromAssembly( typeof(CreateProductCommandValidator).Assembly); // ── Presentation Layer ── builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); app.UseSwagger(); app.UseSwaggerUI(); app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();
10 — Testing Testing Clean Architecture
This is where Clean Architecture pays for itself. Because the Application layer depends on interfaces not concrete implementations, you can mock everything and test business logic with no database, no HTTP, and no framework overhead.
C# — Tests// Application.Tests/Products/CreateProductCommandHandlerTests.cs public class CreateProductCommandHandlerTests { private readonly Mock<IUnitOfWork> _uowMock; private readonly CreateProductCommandHandler _handler; public CreateProductCommandHandlerTests() { _uowMock = new Mock<IUnitOfWork>(); var repoMock = new Mock<IProductRepository>(); _uowMock.Setup(u => u.Products).Returns(repoMock.Object); _handler = new CreateProductCommandHandler(_uowMock.Object); } [Fact] public async Task Handle_ValidCommand_ReturnsNewProductId() { // Arrange var command = new CreateProductCommand( Name: "Laptop", Description: "High-performance laptop", Price: 999.99m, Stock: 50 ); // Act var result = await _handler.Handle(command, CancellationToken.None); // Assert result.Should().NotBeEmpty(); _uowMock.Verify(u => u.SaveChangesAsync(default), Times.Once); } [Fact] public async Task Handle_InvalidPrice_ThrowsDomainException() { // Arrange var command = new CreateProductCommand( "Laptop", "Desc", Price: -10m, Stock: 5 ); // Act & Assert — domain rules throw before hitting the database await FluentActions .Invoking(() => _handler.Handle(command, CancellationToken.None)) .Should().ThrowAsync<ArgumentException>() .WithMessage("*Price must be greater than zero*"); } }
11 — Watch Out Common Mistakes to Avoid
❌ Putting business logic in controllers
Controllers should be thin — receive request, send to MediatR, return response. Any if/else business logic in a controller is a violation of Clean Architecture.
❌ Referencing EF Core in the Domain layer
Your Domain project must have zero EF Core references. No [Key] attributes, no DbContext, no IEntityTypeConfiguration. Domain is pure C# only.
❌ Returning domain entities from the Application layer
Always return DTOs from Application layer handlers — never expose raw domain entities to controllers or external callers. This is how you keep your domain model protected.
❌ Directly calling Infrastructure from a Controller
Never inject a repository directly into a controller. Controllers go through MediatR → Application handler → repository interface. This is the whole point of the pattern.
✅ Use the official template to get started fast
Jason Taylor's CleanArchitecture template on GitHub has been updated to support .NET 10 and is the most widely referenced starting point for Clean Architecture in ASP.NET Core. Install it with: dotnet new install Clean.Architecture.Solution.Template
Final Thoughts
Clean Architecture is not about adding complexity — it is about removing the wrong kind of complexity. When done right, your codebase becomes a joy to work in. Business logic is easy to find, tests run in milliseconds, and adding new features does not break existing ones. It takes a little extra setup upfront. The payoff is a codebase you are still proud of two years later.
Written by
Adnan Shaukat
Backend Developer with 5+ years in .NET, Python, and REST APIs. Writing daily at codewithadnanshaukat.blogspot.com
Comments
Post a Comment