Clean Architecture in .NET 8: A Practical Guide

Clean Architecture separates your business logic from infrastructure concerns, making your codebase testable, maintainable, and framework-agnostic. Here's how to apply it in a real .NET 8 project — with actual code, not just diagrams.

What Is Clean Architecture?

Introduced by Robert C. Martin, Clean Architecture organises code into concentric layers where dependencies always point inward. The business rules at the center know nothing about databases, HTTP, or UI frameworks — they're pure C#.

The single enforced rule: inner layers cannot depend on outer layers. Domain knows nothing about Entity Framework. Application knows nothing about ASP.NET. Outer layers can depend on inner ones — never the reverse.

The Four Layers

1. Domain

The heart of the application. Contains entities, value objects, domain events, and repository interfaces (not implementations). Zero NuGet dependencies — just plain C# classes.

2. Application

Orchestrates use cases. Contains commands, queries (CQRS fits naturally here), DTOs, and application services. Depends only on Domain. No EF Core, no HTTP clients.

3. Infrastructure

Implements the interfaces defined in Domain. EF Core DbContext, third-party API clients, email services, blob storage — all live here. Depends on Application and Domain.

4. Presentation

ASP.NET Core Web API controllers or Minimal API endpoints. Receives HTTP, validates input, delegates to Application, returns responses. Knows nothing about EF Core or domain internals.

Project Structure

MyApp/
├── MyApp.Domain/
│   ├── Entities/
│   │   └── User.cs
│   └── Interfaces/
│       └── IUserRepository.cs
├── MyApp.Application/
│   ├── Users/
│   │   ├── CreateUserCommand.cs
│   │   └── CreateUserHandler.cs
│   └── DTOs/
│       └── UserDto.cs
├── MyApp.Infrastructure/
│   ├── Data/
│   │   └── AppDbContext.cs
│   └── Repositories/
│       └── UserRepository.cs
└── MyApp.Api/
    └── Endpoints/
        └── UserEndpoints.cs

A Concrete Example

Domain entity

// MyApp.Domain/Entities/User.cs
public class User
{
    public Guid   Id    { get; private set; }
    public string Email { get; private set; } = default!;
    public string Name  { get; private set; } = default!;

    private User() { }

    public static User Create(string email, string name)
    {
        if (string.IsNullOrWhiteSpace(email))
            throw new ArgumentException("Email is required.");

        return new User { Id = Guid.NewGuid(), Email = email, Name = name };
    }
}

Repository interface (Domain)

// MyApp.Domain/Interfaces/IUserRepository.cs
public interface IUserRepository
{
    Task AddAsync(User user, CancellationToken ct = default);
    Task<User?> GetByEmailAsync(string email, CancellationToken ct = default);
}

Application command handler

// MyApp.Application/Users/CreateUserHandler.cs
public record CreateUserCommand(string Email, string Name);

public class CreateUserHandler(IUserRepository users)
{
    public async Task<Guid> HandleAsync(CreateUserCommand cmd, CancellationToken ct)
    {
        var existing = await users.GetByEmailAsync(cmd.Email, ct);
        if (existing is not null)
            throw new InvalidOperationException("Email already registered.");

        var user = User.Create(cmd.Email, cmd.Name);
        await users.AddAsync(user, ct);
        return user.Id;
    }
}

Infrastructure implementation

// MyApp.Infrastructure/Repositories/UserRepository.cs
public class UserRepository(AppDbContext db) : IUserRepository
{
    public async Task AddAsync(User user, CancellationToken ct = default)
    {
        db.Users.Add(user);
        await db.SaveChangesAsync(ct);
    }

    public Task<User?> GetByEmailAsync(string email, CancellationToken ct = default)
        => db.Users.FirstOrDefaultAsync(u => u.Email == email, ct);
}

Minimal API endpoint (Presentation)

// MyApp.Api/Endpoints/UserEndpoints.cs
app.MapPost("/users", async (CreateUserCommand cmd, CreateUserHandler handler, CancellationToken ct) =>
{
    var id = await handler.HandleAsync(cmd, ct);
    return Results.Created($"/users/{id}", new { id });
});

Benefits and Trade-offs

Conclusion

Clean Architecture is a long-term investment. The initial setup costs more than a flat project structure, but when your domain grows complex — validation rules, domain events, multiple use cases — the separation pays dividends in every sprint. Start with the four projects, enforce the dependency rule at the csproj level, and let the structure guide your decisions as the system evolves.