Updated on
Clean Architecture is a way to structure a C# application so the business rules sit at the center and depend on nothing. The database, the web framework and every other detail depend on them, never the other way around.
In .NET, that usually means four projects: Domain, Application, Infrastructure and Api.
We’ll start with the theory. Then we’ll build a small ticket-reservation API one project at a time, run it, test it, and make the compiler guard the rules for us.
We go much deeper in our Clean Architecture course: you build a complete ticketing system in .NET 10, from domain events to integration tests.
What Is Clean Architecture in C#?
Clean Architecture organizes code by how close it is to the business. Robert C. Martin described it in a 2012 blog post as a set of circles, with business rules in the middle and technical details on the outside.
One rule holds it together. The Dependency Rule says that source code dependencies point inward, toward the business rules, so inner code never mentions a database, a web framework or any other outer detail.
In a C# solution, the circles usually become four projects. Domain holds the entities and business rules, and it references nothing. Application holds the use cases and the interfaces they need from the outside world.
Infrastructure implements those interfaces with EF Core, files or external services. Api receives HTTP requests and wires everything together at startup.
That structure pays off in two ways. The rules you care about most run in unit tests without a database, and a technical change stays inside one outer project.
Martin states the rule in one sentence in his post The Clean Architecture: “This rule says that source code dependencies can only point inwards.”
Here’s the classic diagram, redrawn:

The ring names come from the original post, and they don’t map one to one onto .NET projects. Entities become our Domain project, and Use Cases become Application.
The two outer rings, Interface Adapters and Frameworks and Drivers, end up split between Infrastructure and Api. Those two projects sit side by side on the outside.
What Problem Does Clean Architecture Solve?
Let’s start from code that has the problem. This minimal API endpoint reserves tickets for an event.
It works and it’s short, but don’t copy it, because it’s the design we’re about to take apart:
app.MapPost("/api/events/{eventId:int}/reservations",
async (int eventId, ReserveRequest request, TicketingDbContext db) =>
{
var ev = await db.Events.FindAsync(eventId);
if (ev is null)
return Results.NotFound();
if (ev.TicketsSold + request.Quantity > ev.Capacity)
return Results.Conflict("Sold out");
ev.TicketsSold += request.Quantity;
await db.SaveChangesAsync();
return Results.Ok(ev);
});
The Event it works with is a plain bag of data, and it’s part of the problem:
public class Event
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int Capacity { get; set; }
public int TicketsSold { get; set; }
}
Here, one lambda does everything. It loads the event with EF Core and checks the capacity rule.
Then it changes the entity, saves it, and returns the database entity as the HTTP response.
So what’s wrong with it? Nothing yet. The trouble starts when the app grows:
- You can’t test the overselling rule without a database, because it lives inside an HTTP handler that needs a
TicketingDbContext. - The rule has no owner.
TicketsSoldhas a public setter, so the refund endpoint you write next month can change it without checking anything. - The database table is the API contract.
Results.Ok(ev)serializes the entity, so when you rename a column, every client sees the change. - Technical choices leak into business code. If you move data access from EF Core to Dapper, you rewrite this lambda, rule and all.
All four problems have one cause. The business rule, the one line that stops us from overselling, depends on the details around it: EF Core, SQLite, HTTP and JSON.
Clean Architecture turns that arrow around, so the details depend on the rule:

You’ll often hear that Clean Architecture lets you swap your database. Few teams ever do that, so it’s a weak reason to adopt it.
Two benefits show up every week instead. Your most important rules run in tests that take milliseconds, and a technical change stays inside one project.
What Is the Dependency Rule?
The Dependency Rule says that source code dependencies point inward. A class in an inner project can’t use, or even name, a class from an outer project.
So if the use cases can’t reference the database code, how does a use case load anything from the database?
Through an interface that the inner project owns. The Application project declares what it needs, for example:
public interface IEventRepository
{
Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default);
}
Infrastructure implements this interface with EF Core, and the dependency injection (DI) container hands that implementation to the use case at runtime.
So at runtime the call travels outward, from the use case to the database. The compile-time reference points inward, from Infrastructure to the interface in Application.
The inward arrows in the Clean Architecture diagram show only the second kind, the dependencies a compiler checks. If you read them as runtime calls, the whole diagram looks backwards.
An interface that an inner layer owns is called a port. The class outside that implements it is an adapter.
If that sounds like the dependency inversion principle, you’re right. The Dependency Rule applies that principle to whole projects.
What Will We Build?
One feature of a ticketing system: reserving tickets for an event. It has one business rule, the one every box office cares about: we never sell more tickets than an event has.
The API gets two endpoints:
POST /api/events/{eventId}/reservationsreserves tickets.GET /api/events/{eventId}reports how many tickets are left.
That’s small enough to follow in one sitting, and big enough to show every layer doing its job. We build from the inside out: Domain first, then Application, Infrastructure and the Api.
Step 1: Create the Solution and the Four Projects
You’ll need the .NET 10 SDK. The commands below use its noun-first forms, such as dotnet solution add and dotnet reference add.
SDKs older than 9.0.300 don’t have all of them and use dotnet sln add and dotnet add reference instead.
Let’s create the solution and one project per layer:
dotnet new sln -n EventTicketing dotnet new classlib -n EventTicketing.Domain dotnet new classlib -n EventTicketing.Application dotnet new classlib -n EventTicketing.Infrastructure dotnet new web -n EventTicketing.Api dotnet solution add EventTicketing.Domain EventTicketing.Application EventTicketing.Infrastructure EventTicketing.Api
Each project has one job:
EventTicketing.Domainholds the business: the entity, its rule and the errors that rule can produce.EventTicketing.Applicationholds the use cases, one class for each thing the app does.EventTicketing.Infrastructuretalks to the outside world, which for us is a SQLite database through EF Core.EventTicketing.Apiis the web edge. It receives HTTP requests and wires the other projects together.
The class library template adds a Class1.cs to each library. We don’t need those, so delete all three. On the .NET 10 SDK, dotnet new sln creates EventTicketing.slnx, the newer XML solution format.
Next come the project references. These three commands are the Dependency Rule, written down once:
dotnet reference add EventTicketing.Domain --project EventTicketing.Application dotnet reference add EventTicketing.Application --project EventTicketing.Infrastructure dotnet reference add EventTicketing.Application EventTicketing.Infrastructure --project EventTicketing.Api
Application references Domain. Infrastructure references Application, and Domain through it. The Api references Application and Infrastructure, and Domain references nothing at all.

Why does the Api reference Infrastructure? So Program.cs can register the EF Core classes that sit behind Application’s interfaces.
Program.cs is the composition root, the one place where every layer’s services come together. No endpoint uses an Infrastructure type.
Finally, each project gets the packages it needs, and nothing more:
dotnet package add Microsoft.Extensions.DependencyInjection.Abstractions --version 10.0.12 --project EventTicketing.Application dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project EventTicketing.Infrastructure
Application gets only the DI abstractions, so it can register its own services. EF Core goes to Infrastructure and nowhere else. We pin the versions so the commands keep working after .NET 11 ships.
Step 2: Build the Domain Layer
The Domain project holds the business. It references no other project and no package, so code in it can’t reach EF Core or ASP.NET Core.
Our rule lives on an Event entity, and Event needs one thing before we write it: a way to say no.
How Does the Result Pattern Handle Failures?
Selling out isn’t a fault in the program. Customers try to buy the last seats every day, and each of those requests is the system working correctly.
So Event shouldn’t throw an exception when it’s sold out. It should return a result that says what went wrong. We sort every failure with one question:

If a well-behaved user can cause it on an ordinary Tuesday, you’re looking at an expected failure, and we return it as a value.
Otherwise, something is broken, and we throw. Sold out and an unknown event ID are expected. A negative quantity that got past validation, or a database outage, is not.
Create EventTicketing.Domain/Result.cs:
namespace EventTicketing.Domain;
public enum ErrorType
{
NotFound,
Conflict
}
public sealed record Error(string Code, string Description, ErrorType Type);
public class Result
{
protected Result(Error? error) => Error = error;
public Error? Error { get; }
public bool IsSuccess => Error is null;
public static Result Success() => new(null);
public static implicit operator Result(Error error) => new(error);
}
public sealed class Result<TValue> : Result
{
private readonly TValue? _value;
private Result(TValue? value, Error? error)
: base(error) => _value = value;
public TValue Value => IsSuccess
? _value!
: throw new InvalidOperationException("A failed result has no value.");
public static implicit operator Result<TValue>(TValue value) =>
new(value, null);
public static implicit operator Result<TValue>(Error error) =>
new(default, error);
}
Here, Error describes a failure with a code, a message and a type. ErrorType tells the API edge later which HTTP status to send, and the Domain never learns that HTTP exists.
Result is either a success or a failure that carries an Error.
Result<TValue> adds a value for operations that return one. Value throws if you read it from a failed result, so you have to check IsSuccess first.
The implicit operators let a method return a plain value or an Error, and the compiler wraps it in the right Result. That’s what keeps our use cases short.
Why write our own? It’s under 40 lines, and we control every one of them. If you’d rather use a library, ErrorOr, FluentResults and Ardalis.Result solve the same problem.
For more on the pattern itself, see our article on the Result pattern in .NET Web API.
The Event Entity and Its Rule
The errors live next to the entity they describe. Create EventTicketing.Domain/EventErrors.cs:
namespace EventTicketing.Domain;
public static class EventErrors
{
public static Error NotFound(int eventId) =>
new("Event.NotFound", $"Event {eventId} was not found.", ErrorType.NotFound);
public static Error SoldOut(int ticketsLeft, int requested) =>
new("Event.SoldOut", $"Only {ticketsLeft} tickets left, {requested} requested.", ErrorType.Conflict);
}
Each error has a stable code, such as Event.SoldOut, and a message for people. Your client code should check the code, because we may reword the message later.
Now the entity itself. Create EventTicketing.Domain/Event.cs:
namespace EventTicketing.Domain;
public sealed class Event
{
private Event(string name, int capacity)
{
Name = name;
Capacity = capacity;
}
public int Id { get; private set; }
public string Name { get; private set; }
public int Capacity { get; private set; }
public int TicketsSold { get; private set; }
public int TicketsLeft => Capacity - TicketsSold;
public static Event Create(string name, int capacity)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity);
return new Event(name, capacity);
}
public Result Reserve(int quantity)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
if (quantity > TicketsLeft)
return EventErrors.SoldOut(TicketsLeft, quantity);
TicketsSold += quantity;
return Result.Success();
}
}
Here, every setter is private, so code outside the class can’t change the numbers. Create() is the only way to make an event, and Reserve() is the only way to sell tickets.
Reserve() has two ways to say no. A quantity of zero or less is a bug in the caller, so ThrowIfNegativeOrZero() throws.
A sold-out event is an ordinary day at the box office, so the method returns EventErrors.SoldOut instead. Only when the rule passes does TicketsSold change.
Event is deliberately small. A bigger domain would also have value objects, such as a Money type that refuses negative amounts.
It would also have aggregates, groups of objects that change together behind one entry point. Our article on aggregate design covers them in .NET.
Step 3: Write the Use Cases in the Application Layer
The Application project holds the use cases. Each use case is one class. A use case that changes data loads what it needs, asks the Domain to do the work, and saves the result.
A use case can’t talk to the database directly, because Application can’t reference Infrastructure. So it declares what it needs as a port. Create EventTicketing.Application/IEventRepository.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public interface IEventRepository
{
Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default);
Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
GetByIdAsync() loads an event we’re about to change. GetAvailabilityAsync() reads what the availability endpoint shows, as an EventAvailability record that we write in a moment. SaveChangesAsync() commits. Infrastructure implements all three in Step 4.
Where should repository interfaces live? You’ll find them in Domain in some templates and in Application in others.
We keep them in Application, next to the use cases that call them, because Domain code never loads or saves anything.
If your domain services do need to query data, Domain is a fine home too. Pick one place and stay consistent.
Now the first use case. A command is a record that carries the input, and a handler does the work. Create EventTicketing.Application/ReserveTickets.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public sealed record ReserveTicketsCommand(int EventId, int Quantity);
public sealed record ReservationResponse(int EventId, int TicketsReserved, int TicketsLeft);
public sealed class ReserveTicketsHandler(IEventRepository events)
{
public async Task<Result<ReservationResponse>> HandleAsync(
ReserveTicketsCommand command, CancellationToken cancellationToken = default)
{
var ev = await events.GetByIdAsync(command.EventId, cancellationToken);
if (ev is null)
return EventErrors.NotFound(command.EventId);
var reservation = ev.Reserve(command.Quantity);
if (!reservation.IsSuccess)
return reservation.Error!;
await events.SaveChangesAsync(cancellationToken);
return new ReservationResponse(ev.Id, command.Quantity, ev.TicketsLeft);
}
}
Here, the handler loads the event through the port and returns the Event.NotFound error when there’s no such event.
Next, it asks the entity to reserve the tickets, and if the entity says no, the handler passes that error on unchanged.
Only on success does it call SaveChangesAsync(). Finally, it builds a ReservationResponse by hand, with one new expression that the compiler checks.
The handler never checks the capacity itself. Event owns that rule, and the handler only sequences the steps: load, reserve, save, respond.
The second use case answers a question and changes nothing. Create EventTicketing.Application/GetEventAvailability.cs:
using EventTicketing.Domain;
namespace EventTicketing.Application;
public sealed record GetEventAvailabilityQuery(int EventId);
public sealed record EventAvailability(int EventId, string Name, int TicketsLeft);
public sealed class GetEventAvailabilityHandler(IEventRepository events)
{
public async Task<Result<EventAvailability>> HandleAsync(
GetEventAvailabilityQuery query, CancellationToken cancellationToken = default)
{
var availability = await events.GetAvailabilityAsync(query.EventId, cancellationToken);
if (availability is null)
return EventErrors.NotFound(query.EventId);
return availability;
}
}
This query never loads the Event entity. It asks the port for exactly the three values the response needs.
Why the difference? Commands go through the entity so its rule runs, and queries skip it. That split has a name, CQRS, and it gets its own section after we run the app.
Finally, the project registers its own handlers. Create EventTicketing.Application/DependencyInjection.cs:
using Microsoft.Extensions.DependencyInjection;
namespace EventTicketing.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ReserveTicketsHandler>();
services.AddScoped<GetEventAvailabilityHandler>();
return services;
}
}
Program.cs will call AddApplication() without knowing which handlers exist. When you add a use case, you register it here, in the project that owns it.
Do We Need MediatR for Clean Architecture?
No. Our endpoints will inject the handler they need and call HandleAsync(). That’s the whole dispatch mechanism, and the compiler checks every call.
MediatR puts a mediator between the endpoint and the handler, and adds a pipeline.
The pipeline runs code around every request, such as logging, validation or a transaction, so you don’t repeat it in each handler.
Without MediatR, you can get the same effect with decorators around the handlers, or with endpoint filters on the API side.
There’s also a licensing question. According to the vendor’s licensing FAQ, MediatR 13.0.0 and later are dual-licensed.
You can use them under the Reciprocal Public License 1.5 at no cost if you accept its reciprocal (copyleft) obligations, or buy a commercial license.
A free Community license covers organizations that meet all four of the vendor’s conditions. Version 12.5.0, released in April 2025, is the last one under the Apache 2.0 license.
MediatR is still a reasonable choice when you want its pipeline and the license works for you. Clean Architecture doesn’t require it. For that route, see our walkthrough of CQRS with MediatR.
Step 4: Add Persistence in the Infrastructure Layer
Where does persistence go in Clean Architecture? In the Infrastructure project. That’s where EF Core lives, together with anything else that talks to the world outside the process, such as files, queues, email and other services.
First, the DbContext. Create EventTicketing.Infrastructure/TicketingDbContext.cs:
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Infrastructure;
public sealed class TicketingDbContext(DbContextOptions<TicketingDbContext> options)
: DbContext(options)
{
public DbSet<Event> Events => Set<Event>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Event>(builder =>
{
builder.Property(e => e.Name).HasMaxLength(200);
builder.HasData(
new { Id = 1, Name = "Clean Architecture Live", Capacity = 100, TicketsSold = 0 },
new { Id = 2, Name = "Tiny Jazz Club Night", Capacity = 2, TicketsSold = 0 });
});
}
}
The Event class carries no EF Core attributes, and all the mapping lives here. That’s what persistence ignorance means: the entity doesn’t know how it’s stored.
EF Core still loads and saves it, private constructor and private setters included. HasData() seeds two events so we have something to reserve: “Clean Architecture Live” with 100 seats and “Tiny Jazz Club Night” with two.
Next, the repository implements the port from Application. Create EventTicketing.Infrastructure/EventRepository.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Infrastructure;
public sealed class EventRepository(TicketingDbContext dbContext) : IEventRepository
{
public Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default) =>
dbContext.Events.FirstOrDefaultAsync(e => e.Id == eventId, cancellationToken);
public Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default) =>
dbContext.Events
.AsNoTracking()
.Where(e => e.Id == eventId)
.Select(e => new EventAvailability(e.Id, e.Name, e.Capacity - e.TicketsSold))
.FirstOrDefaultAsync(cancellationToken);
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
dbContext.SaveChangesAsync(cancellationToken);
}
Here, GetByIdAsync() loads a tracked Event, so EF Core notices when Reserve() changes it. GetAvailabilityAsync() projects straight into the response record, with change tracking turned off. SaveChangesAsync() writes whatever changed.
Infrastructure registers its services in one method. Create EventTicketing.Infrastructure/DependencyInjection.cs:
using EventTicketing.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EventTicketing.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, string connectionString)
{
services.AddDbContext<TicketingDbContext>(options => options.UseSqlite(connectionString));
services.AddScoped<IEventRepository, EventRepository>();
return services;
}
}
AddInfrastructure() takes the connection string as a parameter. Reading configuration is the host’s job, so Infrastructure never needs to know where the string comes from.
You’ll also see solutions that put persistence in a separate Persistence project next to Infrastructure. That works as well, since both projects sit on the outside and point inward.
Should We Use the Repository Pattern With EF Core?
It’s one of the most argued questions in Clean Architecture, and both sides have a point.
The case against starts with EF Core itself. Microsoft’s DbContext reference says: “DbContext is a combination of the Unit Of Work and Repository patterns.”
So a generic IRepository<T> with GetAll(), Add(), Update() and Delete() for every entity copies DbSet<T> by hand, one method at a time.
The case for is the Dependency Rule. Our Application project doesn’t reference EF Core, so a handler can’t take a DbContext.
A small repository gives each use case a named operation, keeps EF Core outside, and gives your tests a place to plug in a fake.
Our rule: write a repository when it keeps EF Core out of the inner layers and speaks the use case’s language. Skip the generic one.
Our repository also exposes SaveChangesAsync(), which is fine while each use case saves through one repository. When a use case saves changes across several repositories, give the save its own interface. That’s the unit of work pattern.
Step 5: Expose the Use Cases Through the API
The Api project is where HTTP arrives. It turns requests into commands and queries, calls the handlers, and turns their results into HTTP responses.
Start with the error mapping. Create EventTicketing.Api/ResultExtensions.cs:
using EventTicketing.Domain;
namespace EventTicketing.Api;
public static class ResultExtensions
{
public static IResult ToProblem(this Result result)
{
var error = result.Error
?? throw new InvalidOperationException("A successful result is not a problem.");
var statusCode = error.Type switch
{
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status500InternalServerError
};
return TypedResults.Problem(statusCode: statusCode, title: error.Code, detail: error.Description);
}
}
No other file in the solution maps error types to status codes. The Domain says “conflict”, and only the API edge knows that a conflict is a 409.
TypedResults.Problem() writes a problem details body, the standard JSON shape for HTTP API errors, and our error code goes into title. There’s more on the format in ProblemDetails in ASP.NET Core.
Now the endpoints. Create EventTicketing.Api/EventEndpoints.cs:
using System.ComponentModel.DataAnnotations;
using EventTicketing.Application;
namespace EventTicketing.Api;
public sealed record ReserveTicketsRequest([property: Range(1, 20)] int Quantity);
public static class EventEndpoints
{
public static void MapEventEndpoints(this IEndpointRouteBuilder app)
{
var events = app.MapGroup("/api/events");
events.MapGet("/{eventId:int}", async (
int eventId,
GetEventAvailabilityHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(new GetEventAvailabilityQuery(eventId), cancellationToken);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
events.MapPost("/{eventId:int}/reservations", async (
int eventId,
ReserveTicketsRequest request,
ReserveTicketsHandler handler,
CancellationToken cancellationToken) =>
{
var command = new ReserveTicketsCommand(eventId, request.Quantity);
var result = await handler.HandleAsync(command, cancellationToken);
return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});
}
}
The client sends only a quantity, since the event ID travels in the URL.
Why two records that look the same? ReserveTicketsRequest describes the HTTP body, and ReserveTicketsCommand describes the use case input. They look alike today, and they can change for different reasons later.
Each endpoint builds a query or a command, calls the handler, and turns the Result into a response. There’s no business logic here for you to test.
The Api needs a connection string for SQLite. Replace EventTicketing.Api/appsettings.json with:
{
"ConnectionStrings": {
"Ticketing": "Data Source=tickets.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Finally, Program.cs wires everything together. Replace EventTicketing.Api/Program.cs with:
using EventTicketing.Api;
using EventTicketing.Application;
using EventTicketing.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Ticketing")!);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
var app = builder.Build();
app.UseExceptionHandler();
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<TicketingDbContext>();
dbContext.Database.EnsureCreated();
}
app.MapEventEndpoints();
app.Run();
AddApplication() and AddInfrastructure() register each layer’s services.
AddProblemDetails() and UseExceptionHandler() turn any unhandled exception into a 500 response in the problem details format. There’s more on handling errors in one place in our article on global error handling.
AddValidation() switches on the built-in validation for minimal APIs that arrived in .NET 10.
EnsureCreated() creates the SQLite database and the two seeded events on the first run. That’s fine for a demo, and a real app would use EF Core migrations instead. If EF Core itself is new to you, our EF Core series starts from the basics.
Program.cs is the only file that sees every layer. That’s what makes it the composition root, and it’s the only reason the Api references Infrastructure. If ASP.NET Core dependency injection is new to you, start there.
Why minimal APIs and not controllers? Microsoft’s APIs overview recommends minimal APIs for new projects.
In Clean Architecture, an endpoint only translates between HTTP and a use case, so a lambda is enough. Controllers work too, and if you prefer them, nothing outside the Api project changes.
Where Does Validation Belong?
In two places, because “validation” covers two different jobs.
Input validation checks the shape of a request. Is the quantity between 1 and 20? That needs no business knowledge, so it belongs at the edge, before a use case runs.
Our [Range] attribute plus AddValidation() does exactly that.
A business rule would still be true with no HTTP request around it. “We can’t sell more tickets than we have” is one.
It belongs in the Domain, inside the method that changes the state. That’s why it lives in Event.Reserve().
So why does Reserve() still throw on zero? Because the Domain can’t assume that every caller validated. A background job or a future endpoint might call Reserve(0), and that would be a bug in the caller.
In bigger apps, you’ll often see input validation move into the Application layer with FluentValidation, so a use case gets the same checks no matter who calls it.
For two endpoints, attributes are enough.
Step 6: Run the App and Send Some Requests
From the solution folder, start the API on a fixed port:
dotnet run --project EventTicketing.Api -- --urls http://localhost:5000
When the console shows Now listening on: http://localhost:5000, the API is ready. On the first run, EnsureCreated() also creates EventTicketing.Api/tickets.db with our two events. To start over later, stop the app and delete that file.
The easiest way to send requests is an .http file, which Visual Studio, Rider and VS Code (with the REST Client extension) can run. Create EventTicketing.Api/EventTicketing.Api.http:
GET http://localhost:5000/api/events/1
###
POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json
{ "quantity": 3 }
###
POST http://localhost:5000/api/events/2/reservations
Content-Type: application/json
{ "quantity": 3 }
###
POST http://localhost:5000/api/events/1/reservations
Content-Type: application/json
{ "quantity": 0 }
###
GET http://localhost:5000/api/events/99
The first request asks about the first event, and all 100 tickets are still there:
{"eventId":1,"name":"Clean Architecture Live","ticketsLeft":100}
Reserving three tickets returns the new count:
{"eventId":1,"ticketsReserved":3,"ticketsLeft":97}
Now let’s ask the Tiny Jazz Club Night, which has two seats, for three tickets. The answer is a 409 Conflict with a problem details body:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.10",
"title": "Event.SoldOut",
"status": 409,
"detail": "Only 2 tickets left, 3 requested.",
"traceId": "00-ce108ce4bc6489ca9b29b3ea9c78fe26-ebc9be53eb756f7b-00"
}
The failure traveled from Event.Reserve() through the handler to the endpoint as a plain return value. Nothing threw, and nothing was saved.
A quantity of zero never reaches our code. AddValidation() reads the [Range] attribute and answers 400 on its own:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Quantity": [
"The field Quantity must be between 1 and 20."
]
},
"traceId": "00-ecfcd14241c21757f5f5ebe6d761e553-406ab76e75f2823e-00"
}
And an unknown event comes back the same way as the sold-out one, as a 404 with the title Event.NotFound:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Event.NotFound",
"status": 404,
"detail": "Event 99 was not found.",
"traceId": "00-e0c75250298c4435886b8fcc54d7d64c-d2ac6be30f33ddae-00"
}
How Does a Request Flow Through the Layers?
Let’s follow the sold-out request from the client to the entity and back:

The handler calls outward into Infrastructure at step 3, yet no file in Application names an Infrastructure class.
The sold-out answer at step 6 is an ordinary return value. The handler skips SaveChangesAsync() by returning early, so nothing reaches the database.
How Does CQRS Fit Into Clean Architecture?
CQRS, short for Command Query Responsibility Segregation, splits the code that changes data from the code that reads it.
You don’t need two databases or a mediator library for it. One database with two paths through the code is already CQRS. Our app has both paths.
The reservation is a command. It loads the Event entity, because the capacity rule has to run, and then saves.
The availability check is a query. It never touches the entity and asks only for what the response shows.
You can see the difference in the SQL. EF Core logs every command it runs, so the console shows both paths. Here’s the query:
SELECT "e"."Id", "e"."Name", "e"."Capacity" - "e"."TicketsSold" FROM "Events" AS "e" WHERE "e"."Id" = @eventId LIMIT 1
And here’s the reservation, which loads the whole entity and then saves the one column that changed:
SELECT "e"."Id", "e"."Capacity", "e"."Name", "e"."TicketsSold" FROM "Events" AS "e" WHERE "e"."Id" = @eventId LIMIT 1 UPDATE "Events" SET "TicketsSold" = @p0 WHERE "Id" = @p1 RETURNING 1;
The query computes the tickets left in the database and tracks nothing. The command loads Event so Reserve() can check the rule, and then EF Core updates only TicketsSold.
In a bigger app, reads often get their own port and their own read models. The split stays the same.
Step 7: Test the Rules Without a Database
The layers make testing cheap. Let’s add a test project next to the others:
dotnet new classlib -n EventTicketing.UnitTests dotnet solution add EventTicketing.UnitTests
Delete its Class1.cs, then replace EventTicketing.UnitTests/EventTicketing.UnitTests.csproj with:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventTicketing.Application\EventTicketing.Application.csproj" />
</ItemGroup>
</Project>
The project uses xUnit v3 through the xunit.v3.mtp-off package, which keeps the classic test runner, so a plain dotnet test works.
If you create test projects with dotnet new xunit3 instead, watch the global.json it writes or updates. That file switches dotnet test to Microsoft Testing Platform, and the template also targets net8.0.
If you run dotnet test from a folder where that file isn’t found, the .NET 10 SDK stops with this error:
error Testing with VSTest target is no longer supported by Microsoft.Testing.Platform on .NET 10 SDK and later. If you use dotnet test, you should opt-in to the new dotnet test experience. For more information, see https://aka.ms/dotnet-test-mtp-error
Now the tests. Domain tests need nothing but the entity. Create EventTicketing.UnitTests/EventTests.cs:
using EventTicketing.Domain;
namespace EventTicketing.UnitTests;
public class EventTests
{
[Fact]
public void Reserve_WhenEnoughTicketsAreLeft_SellsThem()
{
var ev = Event.Create("Clean Architecture Live", capacity: 100);
var result = ev.Reserve(3);
Assert.True(result.IsSuccess);
Assert.Equal(97, ev.TicketsLeft);
}
[Fact]
public void Reserve_WhenTooFewTicketsAreLeft_ReturnsSoldOutAndSellsNothing()
{
var ev = Event.Create("Tiny Jazz Club Night", capacity: 2);
var result = ev.Reserve(3);
Assert.False(result.IsSuccess);
Assert.Equal("Event.SoldOut", result.Error!.Code);
Assert.Equal(0, ev.TicketsSold);
}
}
These tests create an Event and call its Reserve() method. There’s no database, no mocking library and no setup code.
Application tests replace the port with a fake, a small class we write ourselves that implements the port with the simplest code that works. Create EventTicketing.UnitTests/ReserveTicketsHandlerTests.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
namespace EventTicketing.UnitTests;
public class ReserveTicketsHandlerTests
{
[Fact]
public async Task HandleAsync_WhenTicketsAreAvailable_ReservesAndSaves()
{
var events = new FakeEventRepository(Event.Create("Clean Architecture Live", capacity: 100));
var handler = new ReserveTicketsHandler(events);
var result = await handler.HandleAsync(
new ReserveTicketsCommand(EventId: 1, Quantity: 2), TestContext.Current.CancellationToken);
Assert.True(result.IsSuccess);
Assert.Equal(98, result.Value.TicketsLeft);
Assert.Equal(1, events.SaveCount);
}
[Fact]
public async Task HandleAsync_WhenEventIsSoldOut_SavesNothing()
{
var events = new FakeEventRepository(Event.Create("Tiny Jazz Club Night", capacity: 1));
var handler = new ReserveTicketsHandler(events);
var result = await handler.HandleAsync(
new ReserveTicketsCommand(EventId: 1, Quantity: 2), TestContext.Current.CancellationToken);
Assert.False(result.IsSuccess);
Assert.Equal("Event.SoldOut", result.Error!.Code);
Assert.Equal(0, events.SaveCount);
}
}
internal sealed class FakeEventRepository(Event? ev) : IEventRepository
{
public int SaveCount { get; private set; }
public Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default) =>
Task.FromResult(ev);
public Task<EventAvailability?> GetAvailabilityAsync(int eventId, CancellationToken cancellationToken = default) =>
Task.FromResult<EventAvailability?>(null);
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
SaveCount++;
return Task.CompletedTask;
}
}
FakeEventRepository returns whatever event we hand it and counts how many times the handler saved. The handler can’t tell the difference:

The first test reserves two of 100 tickets and checks that the handler saved exactly once. The second asks for two tickets when only one is left and checks that nothing was saved.
Run the tests from the solution folder:
dotnet test
The output ends with the summary:
Test summary: total: 4, failed: 0, succeeded: 4, skipped: 0, duration: 2.9s Build succeeded in 5.1s
On our machine, the whole command took 5.1 seconds, build included. None of the tests needed a database.
You could use a mocking library instead of fakes. For a port this small, the fake takes 16 lines, and it reads like the code it replaces. If you prefer a library, see our guide to NSubstitute.
The assertions use xUnit’s own Assert class. We skipped FluentAssertions because version 8, released in January 2025, is paid for commercial use. Version 7 is still under Apache 2.0.
Unit tests can’t prove that the pieces fit together: the EF Core mapping, the DI registrations or the JSON. That’s the job of integration tests with WebApplicationFactory, which this article leaves out.
Step 8: Enforce the Dependency Rule With Architecture Tests
The compiler already enforces half of it. Let’s break the rule on purpose, a change you shouldn’t keep in your own code.
We add one using line at the top of Event.cs that points outward:
using EventTicketing.Application;
The build fails:
Event.cs(1,22): error CS0234: The type or namespace name 'Application' does not exist in the namespace 'EventTicketing' (are you missing an assembly reference?)
Domain has no reference to Application, so the compiler can’t see the namespace at all. Project references turn the Dependency Rule into a build error.
So why do we need tests? Because a project reference isn’t the only way in.
Nothing stops someone from adding the EF Core package straight to Application and writing a class like this one. It’s exactly the leak we want to catch, so don’t do this:
using EventTicketing.Domain;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Application;
public sealed class SneakyReportHandler(DbContext dbContext)
{
public Task<int> CountEventsAsync(CancellationToken cancellationToken = default) =>
dbContext.Set<Event>().CountAsync(cancellationToken);
}
This time the build succeeds. A project may reference any NuGet package it likes, and the compiler doesn’t know which packages break our architecture.
Architecture tests close that gap. Let’s add a second test project:
dotnet new classlib -n EventTicketing.ArchitectureTests dotnet solution add EventTicketing.ArchitectureTests
Delete its Class1.cs, then replace EventTicketing.ArchitectureTests/EventTicketing.ArchitectureTests.csproj with:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventTicketing.Application\EventTicketing.Application.csproj" />
</ItemGroup>
</Project>
The project references Application, which brings Domain along, and adds NetArchTest.Rules. That library reads the compiled assemblies and checks what each type depends on. Create EventTicketing.ArchitectureTests/DependencyRuleTests.cs:
using EventTicketing.Application;
using EventTicketing.Domain;
using NetArchTest.Rules;
namespace EventTicketing.ArchitectureTests;
public class DependencyRuleTests
{
[Fact]
public void Domain_DependsOnNoOtherLayer()
{
var result = Types.InAssembly(typeof(Event).Assembly)
.ShouldNot()
.HaveDependencyOnAny("EventTicketing.Application", "EventTicketing.Infrastructure", "EventTicketing.Api")
.GetResult();
Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
}
[Fact]
public void Application_DoesNotDependOnInfrastructureOrTheWeb()
{
var result = Types.InAssembly(typeof(ReserveTicketsHandler).Assembly)
.ShouldNot()
.HaveDependencyOnAny("EventTicketing.Infrastructure", "EventTicketing.Api",
"Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
.GetResult();
Assert.True(result.IsSuccessful, "Offending types: " + string.Join(", ", result.FailingTypeNames ?? []));
}
}
Each test loads one layer’s assembly through a type that lives in it, such as Event for Domain. HaveDependencyOnAny() lists the namespaces the layer must not use, and GetResult() reports every type that breaks the rule.
With the sneaky class in place, the test run fails and names it:
EventTicketing.ArchitectureTests test net10.0 failed with 1 error(s) (3.2s)
...\EventTicketing.ArchitectureTests\DependencyRuleTests.cs(29): error TESTERROR:
EventTicketing.ArchitectureTests.DependencyRuleTests.Application_DoesNotDependOnInfrastructureOrTheWeb (330ms): Error Message: Offending types: EventTicketing.Application.SneakyReportHandler
...
Test summary: total: 6, failed: 1, succeeded: 5, skipped: 0, duration: 3.3s
Build failed with 1 error(s) in 5.2s
Delete the class and the package reference, and both tests pass again. If you run them in CI next to the unit tests, the rule holds without anyone having to spot the leak in a code review.
Here’s how the two guards split the work:

NetArchTest reads compiled code, so it sees the types a class really uses. An unused using directive leaves nothing behind in the assembly, so the test can’t see it, and it can’t do any harm either.
NetArchTest.Rules hasn’t had a release since version 1.3.2 in May 2021, and it still runs fine on .NET 10, as the output above shows.
If you’d rather use a library with recent releases, ArchUnitNET is an alternative. Its latest version came out in August 2026.
What Does the Finished Solution Look Like?
Here’s the finished solution, with the files we created in each project:
EventTicketing.slnx
EventTicketing.Domain/
Event.cs, EventErrors.cs, Result.cs
EventTicketing.Application/
DependencyInjection.cs, GetEventAvailability.cs, IEventRepository.cs, ReserveTickets.cs
EventTicketing.Infrastructure/
DependencyInjection.cs, EventRepository.cs, TicketingDbContext.cs
EventTicketing.Api/
EventEndpoints.cs, EventTicketing.Api.http, Program.cs, ResultExtensions.cs, appsettings.json
EventTicketing.UnitTests/
EventTests.cs, ReserveTicketsHandlerTests.cs
EventTicketing.ArchitectureTests/
DependencyRuleTests.cs
The Domain knows nothing about the other three, and the tests prove it on every run.
Where Does My Code Go?
The four layers look tidy in a diagram, and real code is messier. Where does a call to a payment provider go? The current user’s ID? The current time?
We ask four questions in order and stop at the first yes:

When code seems to belong in two layers, check whether it’s really doing two jobs. Validation was the example we already met: the request-shape check sits at the edge, and the capacity rule sits in the Domain.
Here’s where the common pieces of code end up:
| Code | Layer | Why it goes there |
|---|---|---|
Entities and the rules they guard, such as Event.Reserve() | Domain | The rule would be true with no app around it |
Value objects, such as a Money type | Domain | A business concept that checks its own values |
Domain errors, such as EventErrors.SoldOut | Domain | The rule that fails knows what went wrong |
| Reading the current time | Domain or Application, through TimeProvider | .NET 8 added TimeProvider, so no layer needs its own clock interface |
Use case handlers, such as ReserveTicketsHandler | Application | They sequence the steps of one operation |
| Repository and unit of work interfaces | Application | The use cases that call them own them |
| An interface for the current user | Application | The use case needs the user, and the Api implements the interface from HttpContext |
DbContext, EF Core mappings and repositories | Infrastructure | They talk to the database |
| Email, payment and file storage clients | Infrastructure | They talk to something outside the process |
| Endpoints and request records | Api | They exist because clients speak HTTP |
Mapping ErrorType to status codes | Api | Only the edge knows what a 409 is |
Registering services in Program.cs | Api | The composition root is the one place where every layer's services come together |
The table is a starting point. When something doesn’t fit, name what the code does before you decide where it lives.
What Are the Most Common Clean Architecture Mistakes?
These five pass code review and still hurt you later.
An Anemic Domain Model
Entities with public setters and no behavior, plus a service class that holds the rules. Nothing stops the next handler from skipping the service.
It’s the tangled endpoint from the start of this article, spread across more files.
Business Logic in the Handler
A handler that checks the capacity itself has taken over the entity’s job. This version is the one to avoid:
if (ev.TicketsSold + command.Quantity > ev.Capacity)
return EventErrors.SoldOut(ev.TicketsLeft, command.Quantity);
ev.TicketsSold += command.Quantity;
With our Event, it doesn’t even compile:
ReserveTickets.cs(21,9): error CS0200: Property or indexer 'Event.TicketsSold' cannot be assigned to -- it is read only
The private setter is what makes the rule impossible to skip. When a handler needs an entity’s data to make a decision, the decision usually belongs on the entity.
A Generic Repository
A generic IRepository<T> copies DbSet<T> by hand and names none of the operations your use cases perform. Give each aggregate a small repository with named methods instead.
Returning IQueryable From a Repository
A repository method that returns IQueryable<Event> hands EF Core’s query provider back to Application.
Queries start to grow inside handlers, and the boundary that the port was built for is gone. Return results or read models instead.
An Interface for Everything
An interface earns its place when something needs to vary behind it, such as a real repository and a fake.
Our handlers have no interfaces at all, and the tests don’t miss them.
When Should We Not Use Clean Architecture?
When your app has no business rules worth protecting.
Clean Architecture has a daily cost. Adding one feature touches four projects, and following one request means opening five or six files.
Even a plain read goes through a query, a handler, a port and a repository.
That cost pays off when the domain has rules that must never break, such as reservations and payments.
It doesn’t pay off in a CRUD admin tool, or in a small service that copies data from one place to another.
The main alternative is Vertical Slice Architecture, which organizes code by feature instead of by layer. Here are our two features, cut both ways:

A slice keeps the endpoint, the logic and the data access for one feature in one place.
Here’s a feature our sample doesn’t have, a list of events with tickets left, written as a slice:
using EventTicketing.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace EventTicketing.Api;
public static class ListAvailableEvents
{
public sealed record AvailableEvent(int EventId, string Name, int TicketsLeft);
public static void MapListAvailableEvents(this IEndpointRouteBuilder app) =>
app.MapGet("/api/events", async (TicketingDbContext dbContext, CancellationToken cancellationToken) =>
await dbContext.Events
.AsNoTracking()
.Where(e => e.TicketsSold < e.Capacity)
.Select(e => new AvailableEvent(e.Id, e.Name, e.Capacity - e.TicketsSold))
.ToListAsync(cancellationToken));
}
It’s one file, with no handler, no port and no repository, and the endpoint queries TicketingDbContext directly.
We ran it inside our sample, and after we sold out the jazz night, GET /api/events returned only the other event:
[{"eventId":1,"name":"Clean Architecture Live","ticketsLeft":100}]
The slice pays for its brevity by breaking one of our rules. It lives in the Api project and uses an Infrastructure class directly, which our layered endpoints never do.
For a read with no business rules, that’s a fair trade.
You don’t have to choose one style for the whole app.
A common middle ground keeps a protected core for the features with real rules, like our reservations, and writes simple reads as thin slices.
Our Vertical Slice Architecture article shows the style in full. And when an app grows into several business areas, a modular monolith lets each module pick the style that fits it.
How Many Projects Do We Need?
Four is a convention. Microsoft’s guide to common web application architectures uses three: an Application Core project that holds the business model and the interfaces, plus Infrastructure and a UI project.
What separate projects buy you is enforcement. The compiler rejects code that points outward, as we saw with CS0234.
In a single project, you can keep the same rule with folders, namespaces and architecture tests, since NetArchTest can check namespaces too.
We’d start with four projects when the domain has real rules and more than one person works on it. For a small service, one project with folders and a few architecture tests is plenty.
How Is Clean Architecture Different From Onion, Hexagonal and N-Tier?
Onion Architecture and Hexagonal Architecture, also called ports and adapters, share the core idea: business code in the middle, with dependencies pointing inward.
They differ mostly in their names and in where they draw the inner boundaries.
We compare Onion and Clean Architecture side by side in a separate article. Our Hexagonal Architecture article covers ports and adapters in C#.
N-tier is the odd one out. It stacks presentation on business logic on data access, and every dependency points down, toward the database.
Clean Architecture can have the same number of boxes, but it turns the arrows around, so the database ends up on the outside.
Is Clean Architecture the Same as Clean Code?
No. Both names come from Robert C. Martin, which is why they get mixed up.
Clean code is about the inside of a class: clear names and small, readable functions.
Clean Architecture is about the boundaries between the parts of an application and the direction of the dependencies between them. A codebase can have one without the other.
Conclusion
We’ve built a Clean Architecture solution in C# from an empty folder. Along the way, we’ve covered:
- The Dependency Rule, and why runtime calls can travel outward while references point inward
- Four projects: a Domain that guards its rule, an Application with use cases and a port, an Infrastructure with EF Core, and an Api that wires it all together
- The Result pattern for expected failures, turned into problem details at the edge
- Commands and queries as two paths through the same code, which is CQRS without a mediator
- Unit tests with a fake, and architecture tests that fail as soon as a layer leaks
Our sample stops at one feature.
If you want to see the whole system, our Clean Architecture in .NET course builds the same ticketing app from a tangled starting point to a fully tested solution.
It covers a hand-rolled mediator and pipeline, domain events dispatched after the transaction commits, a purchase-and-refund feature, four test suites and a fair look at Vertical Slice Architecture.
If you’re bringing Clean Architecture into an existing app, start with the architecture tests. Pointed at your current projects, they’ll show you where the arrows point today.
Tested with .NET 10.0.10, EF Core 10.0.12, xUnit v3 4.0.1 and NetArchTest.Rules 1.3.2.

I just started reading the article, and something was found wrong, so I stopped reading it. Kindly confirm with another source and update your article. The repository layer interface will not belong to the domain but application layer as the domain layer is outside and it won’t depend on any other layer
Hi. Just to clear things out here. You said this: “as the domain layer is outside” – this is not the case. The Domain layer is in the core of the solution not at the outside part of it. Second, you wrote this: “and it won’t depend on any other layer” – it doesn’t depend on any other layer. Entities and main interfaces are inside the domain layer, that’s the rule for both the onion and clean architecture. The application layer references the Domain layer, as presented in the diagram. I am sorry you stopped reading the article, but the layers and all the elements inside the layer are properly placed. You can even watch the video and take the source code (as a Patreon member) and you will see that the Domain layer has no dependencies whatsoever on any other project inside the solution.