Build a Clean Architecture .NET App: A Hands-On PlaceOrder Tutorial
Build a Clean Architecture .NET 10 app from an empty solution to a working POST /orders minimal API. Four projects, one use case, EF Core, step by step.
Imagine a big company building. The leaders sit in a quiet office in the very center. They make the important decisions: what the company sells and the rules it must follow. Around them is a ring of managers who turn those decisions into tasks. Around the managers is the front desk and the loading dock, where people and trucks come and go all day.
Now notice one thing. The loading dock knows where the leaders are. But the leaders do not need to know which truck arrived today. Information flows inward. The center never depends on the noisy outside.
Clean Architecture builds software the same way. Your most important rules sit in the middle and depend on nothing. The database, the web framework, and other changeable tools sit on the outside and point inward. When a tool changes, the center stays calm.
In this guide we will not just talk about it. We will build it, one step at a time, from an empty solution to a working web endpoint. By the end you will have a small POST /orders API that places an order, using .NET 10 (the current LTS release) and EF Core 10. Let us go.
What we are building
We will build a tiny order service with four projects:
- Domain holds the
Orderentity and one business rule. - Application holds the
PlaceOrderuse case and the repository interface. - Infrastructure holds the EF Core code that talks to the database.
- Api is the minimal API that wires everything together.
The golden rule of Clean Architecture is about dependency direction. Every project may only point inward, never outward.
Read the arrows carefully. Api knows about everything. Domain knows about nothing. That single rule is the whole game.
Step 1: Create the solution and four projects
Open a terminal in an empty folder. We will create a solution file and four class libraries, then one web project. Run these commands one by one.
dotnet new sln -n CleanOrders
dotnet new classlib -n CleanOrders.Domain
dotnet new classlib -n CleanOrders.Application
dotnet new classlib -n CleanOrders.Infrastructure
dotnet new web -n CleanOrders.Api
dotnet sln add CleanOrders.Domain
dotnet sln add CleanOrders.Application
dotnet sln add CleanOrders.Infrastructure
dotnet sln add CleanOrders.ApiYou now have four projects in one solution. None of them reference each other yet. That is our next job, and it is the most important step.
Step 2: Wire references so dependencies point inward
This step is where Clean Architecture actually lives. We add ProjectReference links, but only inward ones.
# Application can see Domain
dotnet add CleanOrders.Application reference CleanOrders.Domain
# Infrastructure can see Application (and through it, Domain)
dotnet add CleanOrders.Infrastructure reference CleanOrders.Application
# Api can see Application and Infrastructure
dotnet add CleanOrders.Api reference CleanOrders.Application
dotnet add CleanOrders.Api reference CleanOrders.InfrastructureNotice what we did not do. We never let Domain reference anything. We never let Application reference Infrastructure. Those forbidden arrows would point outward and break the design.
Here is a quick map of who may reference whom.
| Project | May reference | Must never reference |
|---|---|---|
| Domain | nothing | Application, Infrastructure, Api |
| Application | Domain | Infrastructure, Api |
| Infrastructure | Application, Domain | Api |
| Api | Application, Infrastructure, Domain | nothing |
If you ever feel tempted to add a reference that breaks this table, stop. That itch usually means a class is in the wrong project.
Step 3: Model the Order entity and a domain rule
Now the fun part. In the Domain project, delete the default Class1.cs and add an Order entity. An order has a customer, a list of items, and a total. Our business rule is simple but real: an order must have at least one item, and the customer name cannot be blank.
namespace CleanOrders.Domain;
public sealed class Order
{
private readonly List<OrderItem> _items = new();
public Guid Id { get; private set; }
public string Customer { get; private set; }
public IReadOnlyList<OrderItem> Items => _items;
public decimal Total => _items.Sum(i => i.LineTotal);
private Order() { Customer = string.Empty; } // for EF Core
public Order(string customer, IEnumerable<OrderItem> items)
{
if (string.IsNullOrWhiteSpace(customer))
throw new ArgumentException("Customer is required.");
_items = items.ToList();
if (_items.Count == 0)
throw new InvalidOperationException("An order needs at least one item.");
Id = Guid.NewGuid();
Customer = customer;
}
}
public sealed record OrderItem(string Product, int Quantity, decimal Price)
{
public decimal LineTotal => Quantity * Price;
}Look closely. The rule lives inside the entity. You cannot build a broken Order, because the constructor refuses one. This is the heart of the Domain layer: it protects itself. No database and no web code can sneak past this rule.
Step 4: Write the PlaceOrder use case
The Domain knows the rules. The Application layer knows the steps to get something done. One use case equals one job the app can do. Ours is "place an order."
First, in the Application project, define the repository interface. This is the key trick of Clean Architecture: the inner layer declares what it needs, and the outer layer supplies it.
using CleanOrders.Domain;
namespace CleanOrders.Application;
public interface IOrderRepository
{
Task AddAsync(Order order, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}The Application layer has no idea how orders get saved. It only knows the contract. Maybe it is SQL Server. Maybe it is a file. Maybe it is a fake list in a test. The use case does not care.
Now the use case itself. We use a small request record and a handler class. No MediatR, no extra library. (MediatR, MassTransit, and AutoMapper moved to commercial licenses, so plain classes are a popular, free choice today.)
using CleanOrders.Domain;
namespace CleanOrders.Application;
public record PlaceOrderItem(string Product, int Quantity, decimal Price);
public record PlaceOrderCommand(string Customer, List<PlaceOrderItem> Items);
public record PlaceOrderResult(Guid OrderId, decimal Total);
public sealed class PlaceOrderHandler
{
private readonly IOrderRepository _repository;
public PlaceOrderHandler(IOrderRepository repository) => _repository = repository;
public async Task<PlaceOrderResult> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
{
var items = command.Items
.Select(i => new OrderItem(i.Product, i.Quantity, i.Price));
// The Order constructor enforces the domain rule.
var order = new Order(command.Customer, items);
await _repository.AddAsync(order, ct);
await _repository.SaveChangesAsync(ct);
return new PlaceOrderResult(order.Id, order.Total);
}
}The handler reads almost like a recipe: build the order, save it, return a result. All the noisy details live elsewhere. That readability is a sign you got the layering right.
Here is the journey of one request, end to end.
One POST /orders request
Steps
HTTP
Api receives JSON and builds a PlaceOrderCommand
Handler
PlaceOrderHandler runs the use case
Domain
Order constructor checks the business rule
Repository
IOrderRepository saves the order
Database
EF Core writes a row, result returns
Step 5: Implement the repository with EF Core
Time for the Infrastructure layer. This is where real tools live. Add EF Core to the project first.
dotnet add CleanOrders.Infrastructure package Microsoft.EntityFrameworkCore.SqliteWe use SQLite because it is a tiny file-based database, perfect for learning. Now add the DbContext.
using CleanOrders.Domain;
using Microsoft.EntityFrameworkCore;
namespace CleanOrders.Infrastructure;
public sealed class OrdersDbContext : DbContext
{
public OrdersDbContext(DbContextOptions<OrdersDbContext> options) : base(options) { }
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder model)
{
model.Entity<Order>(order =>
{
order.HasKey(o => o.Id);
order.Property(o => o.Customer).IsRequired();
order.Ignore(o => o.Total); // Total is computed, not stored
order.OwnsMany<OrderItem>("_items"); // items stored as owned rows
});
}
}Now implement the interface that the Application layer declared. This class lives in Infrastructure, so it is allowed to know about EF Core. The Application layer never sees this code.
using CleanOrders.Application;
using CleanOrders.Domain;
namespace CleanOrders.Infrastructure;
public sealed class OrderRepository : IOrderRepository
{
private readonly OrdersDbContext _db;
public OrderRepository(OrdersDbContext db) => _db = db;
public async Task AddAsync(Order order, CancellationToken ct)
=> await _db.Orders.AddAsync(order, ct);
public Task SaveChangesAsync(CancellationToken ct)
=> _db.SaveChangesAsync(ct);
}This is the Dependency Inversion Principle in action. The arrow of control still points inward: Infrastructure depends on the IOrderRepository contract that Application owns. At runtime, the outer class plugs into the inner interface.
Step 6: Register everything in Program.cs (the composition root)
The Api project is where all the pieces finally meet. This single place is called the composition root. It is the only spot that knows about every layer at once, and it decides which concrete class fills each interface.
Open Program.cs in CleanOrders.Api and replace it with this.
using CleanOrders.Application;
using CleanOrders.Infrastructure;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Infrastructure: the real database
builder.Services.AddDbContext<OrdersDbContext>(options =>
options.UseSqlite("Data Source=orders.db"));
// Bind the inner interface to the outer implementation
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
// Application: the use case handler
builder.Services.AddScoped<PlaceOrderHandler>();
var app = builder.Build();
// Make sure the database file and tables exist (fine for a demo)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<OrdersDbContext>();
db.Database.EnsureCreated();
}The line that binds IOrderRepository to OrderRepository is the magic moment. The Application layer asked for an IOrderRepository, and right here we say "use the EF Core one." Swap that one line and you could point at a totally different database, with zero changes to your use case.
Step 7: Expose the POST /orders endpoint
Still in Program.cs, below the database setup, add the endpoint and start the app.
app.MapPost("/orders", async (
PlaceOrderCommand command,
PlaceOrderHandler handler,
CancellationToken ct) =>
{
try
{
var result = await handler.HandleAsync(command, ct);
return Results.Created($"/orders/{result.OrderId}", result);
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
// The domain rule said no. Return a clean 400.
return Results.BadRequest(new { error = ex.Message });
}
});
app.Run();Notice how thin the endpoint is. It reads the request, calls the handler, and turns the result into an HTTP response. It contains no business logic at all. All the thinking happens deeper inside. That is exactly what we want from the outermost layer.
Step 8: Run it and test it
From the CleanOrders.Api folder, start the app.
dotnet runThe terminal prints a local URL such as http://localhost:5000. Now send a real order. Paste this into another terminal (PowerShell users can use Invoke-RestMethod instead of curl).
curl -X POST http://localhost:5000/orders \
-H "Content-Type: application/json" \
-d '{
"customer": "Asha",
"items": [
{ "product": "Notebook", "quantity": 2, "price": 3.50 },
{ "product": "Pen", "quantity": 5, "price": 1.20 }
]
}'You should get back something like this, with a fresh order id and the total worked out by the Domain layer.
{ "orderId": "f3a1...e9", "total": 13.00 }Now test the business rule. Send an order with an empty items list. The Order constructor throws, the endpoint catches it, and you get a clean 400 Bad Request saying "An order needs at least one item." The rule held, even though we never wrote a single if in the web code.
Here is what each layer did during that one request.
| Layer | Its job in this request | Knows about the web? | Knows about the database? |
|---|---|---|---|
| Api | Read JSON, return HTTP status | Yes | No |
| Application | Run the PlaceOrder use case | No | No |
| Domain | Enforce "at least one item" | No | No |
| Infrastructure | Save the order with EF Core | No | Yes |
Read that table top to bottom. Only the edges touch the messy outside world. The middle stays pure. That separation is the entire payoff of Clean Architecture.
A note on real projects
Two small upgrades for when you go past a demo. First, replace EnsureCreated() with real EF Core migrations (dotnet ef migrations add Initial) so you can evolve your schema safely over time. Second, resist wrapping EF Core in a giant generic IRepository<T> that returns IQueryable. If your repository hands back IQueryable, the database has leaked straight into your Application layer, and the boundary you worked for is gone. Keep repository methods focused on whole aggregates, like "add this order" or "get this order with its lines."
It is also worth knowing what you did not need. No MediatR. No AutoMapper. Those tools are fine, but they are now paid, and Clean Architecture never required them. The design is about dependency direction, plain interfaces, and clear layers. The newest C# 14 features, like the field keyword and extension members, can make your Domain even tidier, but they are a bonus, not a requirement.
Quick recap
- Clean Architecture is layers like an onion: rules in the middle, tools on the outside, and every dependency points inward.
- We built four projects: Domain, Application, Infrastructure, and Api, and set
ProjectReferencelinks so none of them point outward. - The Domain
Orderentity protects its own rule: an order must have at least one item. - One use case, PlaceOrder, lives in Application as a small handler class, with no paid libraries needed.
IOrderRepositorylives in the inner Application layer; the EF Core class that implements it lives in outer Infrastructure. That is dependency inversion.Program.csis the composition root: the one place that binds interfaces to concrete classes and exposesPOST /orders.- We ran the app, placed a real order, and watched the domain rule reject a bad one with a clean
400.
References and further reading
- What's new in .NET 10 (Microsoft Learn)
- Tutorial: Create a minimal API with ASP.NET Core (Microsoft Learn)
- EF Core releases and planning (Microsoft Learn)
- Implementing Clean Architecture in .NET 10 (codewithmukesh)
- Clean Architecture in .NET (Code Maze)
Related Posts
Building Your First Use Case With Clean Architecture in .NET
A beginner-friendly, step-by-step guide to building your first use case in .NET Clean Architecture: command, handler, repository, and endpoint, with diagrams.
How to Build a URL Shortener With .NET: A Beginner's Step-by-Step Guide
A friendly, step-by-step guide to building a URL shortener in .NET 10 using minimal APIs and EF Core. Learn short codes, redirects, and storage.
How to Implement Multitenancy in ASP.NET Core with EF Core
A simple, student-friendly guide to building multitenant apps in ASP.NET Core with EF Core using tenant resolution, global query filters, and per-tenant databases.
How to Build a Production-Ready Invoice Builder in .NET Using IronPDF
A simple, beginner-friendly guide to building a real invoice PDF generator in .NET with IronPDF, from HTML template to a clean download in your API.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.