You've probably heard "Clean Architecture" thrown around in job descriptions, code reviews, and tech talks. Maybe you've nodded along without being entirely sure what it means in practice.
This article is a practical version. No theory overload — just what Clean Architecture is, why it matters, and what it looks like in a real .NET 10 project with actual folder structure and code.
This is part of a series on building production .NET APIs:
- Part 1 — Why I Stopped Using MediatR and Built CQRS From Scratch in .NET 10
- Part 2 — Clean Architecture in .NET 10 — The Big Picture ← you are here
- Part 3 — The Domain Layer — Entities, Value Objects, and Aggregates (coming soon)
- Part 4 — The Application Layer — Use Cases, CQRS, and Validation (coming soon)
- Part 5 — The Infrastructure Layer — EF Core + Dapper (coming soon)
- Part 6 — The Api Layer — Controllers, Routing, and Result Mapping (coming soon)
- Part 7 — Testing Clean Architecture — Unit, Integration, and E2E (coming soon)
The problem it solves
Before explaining what Clean Architecture is, it helps to understand what it's solving.
Imagine you start a project. You have controllers, services, and database calls. It works. Six months later, you need to swap SQL Server for PostgreSQL. Or write unit tests. Or reuse the business logic in a background job.
Suddenly you realise your business logic is tangled up with your database code. Your controllers know too much. Testing requires spinning up a real database. Changing one thing breaks three others.
This is what happens when there are no clear boundaries between the parts of your system. Clean Architecture is about drawing those boundaries deliberately.
The core idea
Clean Architecture organises your code into layers. Each layer has one responsibility, and — crucially — each layer only depends on the layers closer to the centre.
Think of it as concentric circles:
┌─────────────────────────┐
│ Api │ ← HTTP, controllers, middleware
│ ┌───────────────────┐ │
│ │ Application │ │ ← Use cases, business logic
│ │ ┌─────────────┐ │ │
│ │ │ Domain │ │ │ ← Entities, rules, interfaces
│ │ └─────────────┘ │ │
│ └───────────────────┘ │
│ Infrastructure │ ← Database, external services
└─────────────────────────┘
The dependency rule: code in an inner layer never references code in an outer layer. Domain doesn't know about Application. Application doesn't know about Infrastructure or Api. Ever.
This one rule is what makes the architecture work.
The four layers
Domain — the heart of the system
This is where your business lives. Entities, business rules, and the interfaces that define what the system needs — but not how those needs are fulfilled.
Domain/
Entities/
Client.cs
Errors/
DomainError.cs
NotFoundError.cs
ConflictError.cs
ValidationError.cs
Repositories/
IClientRepository.cs
A domain entity encapsulates state and behaviour. It doesn't know about databases, HTTP, or anything external:
public sealed class Client
{
private Client() { }
public Guid Id { get; private set; }
public string FirstName { get; private set; } = null!;
public string LastName { get; private set; } = null!;
public string Email { get; private set; } = null!;
public string FullName => $"{FirstName} {LastName}";
public static Client Create(string firstName, string lastName, string email)
{
return new Client
{
Id = Guid.NewGuid(),
FirstName = firstName,
LastName = lastName,
Email = email
};
}
}
Notice the private constructor and private setters — the entity controls its own state. You can't create an invalid Client by accident.
The repository interface lives here too:
public interface IClientRepository
{
Task<Client?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken);
Task InsertAsync(Client client, CancellationToken cancellationToken);
}
This is the interface — the contract. Domain defines what it needs. Infrastructure provides it. Domain never sees the implementation.
Application — the use cases
This layer orchestrates. It takes a request, applies business logic using domain entities and repository interfaces, and returns a result. It knows about Domain. It knows nothing about databases or HTTP.
Application/
Abstractions/
ICommand.cs
IQuery.cs
IDispatcher.cs
UseCases/
Clients/
RegisterClient/
RegisterClientCommand.cs
RegisterClientCommandHandler.cs
RegisterClientCommandValidator.cs
GetClientById/
GetClientByIdQuery.cs
GetClientByIdQueryHandler.cs
A use case handler is intentionally simple — one job, clearly expressed:
internal sealed class RegisterClientCommandHandler(
IClientRepository repository)
: ICommandHandler<RegisterClientCommand, Guid>
{
public async Task<Result<Guid>> Handle(
RegisterClientCommand request,
CancellationToken cancellationToken)
{
var emailExists = await repository.ExistsByEmailAsync(
request.Email, cancellationToken);
if (emailExists)
return Result.Failure<Guid>(
new ConflictError("Client.EmailInUse", "Email is already in use."));
var client = Client.Create(request.FirstName, request.LastName, request.Email);
await repository.InsertAsync(client, cancellationToken);
return Result.Success(client.Id);
}
}
This handler doesn't know how the repository stores data. It doesn't know the response will become an HTTP 201. It just executes the business logic and returns a result.
Infrastructure — the implementation details
This is where the real world lives. Database connections, ORM configuration, external API clients. Infrastructure implements the interfaces defined in Domain.
Infrastructure/
Persistence/
AppDbContext.cs
Configurations/
ClientConfiguration.cs
Migrations/
Repositories/
ClientRepository.cs
The repository implementation:
internal sealed class ClientRepository(AppDbContext context) : IClientRepository
{
public async Task<Client?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
{
const string sql = """
SELECT id AS Id, first_name AS FirstName, last_name AS LastName, email AS Email
FROM clients
WHERE id = @Id
""";
var connection = context.Database.GetDbConnection();
return await connection.QuerySingleOrDefaultAsync<Client>(
new CommandDefinition(sql, new { Id = id }, cancellationToken: cancellationToken));
}
// ...
}
Infrastructure knows about SQL Server, Dapper, and EF Core. Nothing else in the codebase does. If you swap SQL Server for PostgreSQL tomorrow, you change Infrastructure. Domain, Application, and Api don't change at all — they never knew what database you were using.
Api — the entry point
The outermost layer. It receives HTTP requests, dispatches them to the right use case, and maps the result to an HTTP response. It knows about Application (to dispatch requests) and Infrastructure (to wire up DI).
Api/
Controllers/
ClientsController.cs
Conventions/
GlobalRoutePrefixConvention.cs
Extensions/
ResultExtensions.cs
Program.cs
The controller is intentionally thin:
[ApiController]
[Route("[controller]")]
public sealed class ClientsController(IDispatcher dispatcher) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Register(
[FromBody] RegisterClientCommand command,
CancellationToken cancellationToken)
{
var result = await dispatcher.Send(command, cancellationToken);
return result.IsFailure
? result.ToActionResult()
: CreatedAtAction(nameof(GetById), new { id = result.Value }, null);
}
}
No business logic. No database calls. No try/catch. The controller's only job is to translate HTTP into application commands and results back into HTTP responses.
The dependency graph
This is the most important diagram in the whole article:
Api → Application → Domain
Api → Infrastructure → Application → Domain
In .csproj terms:
-
Domain.csproj— no project references -
Application.csproj— references Domain only -
Infrastructure.csproj— references Application (and Domain transitively) -
Api.csproj— references Application and Infrastructure
Infrastructure references Application so it can implement the interfaces defined there. Api references Infrastructure only to wire up DI in Program.cs — at runtime, everything talks through interfaces.
Why this structure matters in practice
Testing becomes straightforward. Because Application only depends on interfaces, you can unit test a handler by substituting a fake repository. No database required. No HTTP server required. Just the logic.
// arrange
var repository = Substitute.For<IClientRepository>();
repository.ExistsByEmailAsync("john@example.com", Arg.Any<CancellationToken>())
.Returns(false);
var handler = new RegisterClientCommandHandler(repository);
// act
var result = await handler.Handle(
new RegisterClientCommand("John", "Doe", "john@example.com"),
CancellationToken.None);
// assert
result.IsSuccess.Should().BeTrue();
Changing infrastructure doesn't touch business logic. Swap Dapper for raw ADO.NET, change your database, replace your email provider — the change lives in Infrastructure. Application and Domain don't notice.
Onboarding is predictable. A new developer joins the team. They need to add a feature. They create a new folder under UseCases/, add a command and a handler, implement the repository method if needed, and wire up the controller. The pattern is the same every time.
Common mistakes
Putting business logic in controllers. The controller should orchestrate, not decide. If you're writing if statements about business rules in a controller, they belong in a use case handler.
Making Domain reference Application or Infrastructure. Domain is the innermost layer. It must not depend on anything else. If you're tempted to inject a service from Application into a Domain entity, that's a sign the logic belongs somewhere else.
Treating Infrastructure as a dumping ground. Infrastructure is for external concerns — databases, file systems, APIs. Configuration, DI wiring, and cross-cutting concerns like logging belong elsewhere.
The full working example
Everything in this article is part of a working .NET 10 project on GitHub — full layer structure, EF Core + Dapper, FluentValidation, custom CQRS without MediatR, and a complete test suite including E2E tests with Testcontainers.
github.com/ferras991/dotnet-clean-arch-cqrs
If you're building something new or refactoring an existing project, clone it, read through the layers, and adapt what makes sense for your context.
Clean Architecture isn't a rigid ruleset — it's a set of boundaries that keep your codebase maintainable as it grows. Start with the dependency rule and the rest follows naturally.
Next in the series: Part 3 goes deeper into the Domain layer — what entities, value objects, and aggregates look like in a real loyalty card system, and why getting this layer right makes everything else easier.
Follow to get notified when it's published.
Top comments (0)