As modern enterprise applications grow, monolithic codebases often devolve into tightly coupled spaghetti code where database queries bleed into controllers and business rules are scattered across UI components. When requirements shift, changing a single feature becomes risky and time-consuming.
Clean Architecture, originally popularized by Robert C. Martin (Uncle Bob), solves this by organizing code around business logic rather than framework concerns. This article walks through a complete, production-grade implementation of Clean Architecture in an ASP.NET Core Web API, enforcing the Dependency Inversion Principle to keep your core code resilient and testable.
1. Architectural Overview & Folder Structure
Clean Architecture relies on the Dependency Rule: source code dependencies must point only inward, toward higher-level policies (business logic). External concerns like databases, UI frameworks, and third-party libraries are treated as plugins.
We organize our solution into four distinct projects:
Plaintext
Solution/
│
├── src/
│ ├── Domain/ # Enterprise business rules and entities (Zero dependencies)
│ ├── Application/ # Use cases, business rules, interfaces, and DTOs
│ ├── Infrastructure/ # EF Core, external services, database implementations
│ └── WebApi/ # Controllers, middleware, dependency injection wiring
Layer Responsibilities
Domain: Contains pure business models, value objects, domain events, and repository interfaces. It references no other projects.
Application: Implements application use cases (CQRS handlers, validation rules, mapping profiles). It references only the Domain layer.
Infrastructure: Implements data access (Entity Framework Core contexts, migrations), database repositories, and external service clients. It references the Application layer.
WebApi: The entry point. Handles HTTP requests, routing, authentication, and exception handling. It references Application and Infrastructure.
2. Step 1: Building the Domain Layer
The Domain layer is the heart of your application. Let's create a sample domain entity representing a Product.
C#
// Domain/Common/BaseEntity.cs
namespace Domain.Common;
public abstract class BaseEntity
{
public int Id { get; protected set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
C#
// Domain/Entities/Product.cs
using Domain.Common;
namespace Domain.Entities;
public class Product : BaseEntity
{
public string Name { get; private set; }
public decimal Price { get; private set; }
public int Stock { get; private set; }
// Private constructor for EF Core / Domain encapsulation
private Product() { }
public Product(string name, decimal price, int stock)
{
Name = GuardAgainstInvalidName(name);
Price = GuardAgainstInvalidPrice(price);
Stock = stock;
}
public void UpdateDetails(string name, decimal price, int stock)
{
Name = GuardAgainstInvalidName(name);
Price = GuardAgainstInvalidPrice(price);
Stock = stock;
}
private static string GuardAgainstInvalidName(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Product name cannot be empty.", nameof(name));
return name;
}
private static decimal GuardAgainstInvalidPrice(decimal price)
{
if (price < 0)
throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative.");
return price;
}
}
Next, define the repository interface within the Domain layer. Notice that the Domain defines what it needs, not how it's stored.
C#
// Domain/Interfaces/IProductRepository.cs
using Domain.Entities;
namespace Domain.Interfaces;
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken cancellationToken);
Task AddAsync(Product product, CancellationToken cancellationToken);
Task UpdateAsync(Product product, CancellationToken cancellationToken);
Task DeleteAsync(Product product, CancellationToken cancellationToken);
}
3. Step 2: Crafting the Application Layer
The Application layer handles orchestration. We will use the CQRS (Command Query Responsibility Segregation) pattern with MediatR to structure our use cases.
First, let's create a command to create a new product:
C#
// Application/Products/Commands/CreateProductCommand.cs
using MediatR;
namespace Application.Products.Commands;
public record CreateProductCommand(string Name, decimal Price, int Stock) : IRequest<int>;
Next, implement the command handler:
C#
// Application/Products/Commands/CreateProductCommandHandler.cs
using Domain.Entities;
using Domain.Interfaces;
using MediatR;
namespace Application.Products.Commands;
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
private readonly IProductRepository _productRepository;
public CreateProductCommandHandler(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = new Product(request.Name, request.Price, request.Stock);
await _productRepository.AddAsync(product, cancellationToken);
// Return the generated entity Id
return product.Id;
}
}
4. Step 3: Implementing the Infrastructure Layer
Infrastructure deals with technical details like database configurations. We implement EF Core here, fulfilling the repository interface defined in the Domain layer.
C#
// Infrastructure/Persistence/AppDbContext.cs
using Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Persistence;
public class AppDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
C#
// Infrastructure/Repositories/ProductRepository.cs
using Domain.Entities;
using Domain.Interfaces;
using Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace Infrastructure.Repositories;
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
{
_context = context;
}
public async Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.Products.FindAsync(new object[] { id }, cancellationToken);
}
public async Task<IReadOnlyList<Product>> GetAllAsync(CancellationToken cancellationToken)
{
return await _context.Products.AsNoTracking().ToListAsync(cancellationToken);
}
public async Task AddAsync(Product product, CancellationToken cancellationToken)
{
await _context.Products.AddAsync(product, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAsync(Product product, CancellationToken cancellationToken)
{
_context.Products.Update(product);
await _context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(Product product, CancellationToken cancellationToken)
{
_context.Products.Remove(product);
await _context.SaveChangesAsync(cancellationToken);
}
}
5. Step 4: Exposing the WebApi Layer
The API controller acts as a thin delivery mechanism. It accepts HTTP requests, dispatches commands/queries via MediatR, and returns appropriate status codes.
C#
// WebApi/Controllers/ProductsController.cs
using Application.Products.Commands;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace WebApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ISender _mediator;
public ProductsController(ISender mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProductCommand command, CancellationToken cancellationToken)
{
var productId = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(nameof(Create), new { id = productId }, productId);
}
}
Wiring Dependency Injection in Program.cs
Register your services cleanly by calling extension methods configured in each layer:
C#
// WebApi/Program.cs
using Infrastructure.Persistence;
using Infrastructure.Repositories;
using Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// Database Context
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Repositories
builder.Services.AddScoped<IProductRepository, ProductRepository>();
// MediatR (scans Application assembly)
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(CreateProductCommand).Assembly));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Conclusion
By enforcing Clean Architecture, your ASP.NET Core Web API becomes decoupled, highly testable, and adaptable to change. Business rules reside safely in the core layers, free from database constraints or framework updates.

Join the conversation! Your thoughts help the community grow.