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

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.