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
- Testability: The Application layer has no infrastructure dependencies — mock
IUserRepositoryand test business logic in total isolation. - Replaceability: Swap EF Core for Dapper, or SQL Server for PostgreSQL, by rewriting Infrastructure — Domain and Application don't change.
- Overhead for CRUD: For simple CRUD APIs with little business logic, Clean Architecture is overkill. Reserve it for domains where the rules are genuinely complex.
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.