Skip to main content
SEMastery
intermediate

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.

12 min readUpdated November 22, 2025

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 Order entity and one business rule.
  • Application holds the PlaceOrder use 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.

The four projects and which way their references point. Every arrow goes toward Domain.

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.Api

You 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.Infrastructure

Notice 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.

ProjectMay referenceMust never reference
DomainnothingApplication, Infrastructure, Api
ApplicationDomainInfrastructure, Api
InfrastructureApplication, DomainApi
ApiApplication, Infrastructure, Domainnothing

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

HTTP
Handler
Domain
Repository
Database

Steps

1

HTTP

Api receives JSON and builds a PlaceOrderCommand

2

Handler

PlaceOrderHandler runs the use case

3

Domain

Order constructor checks the business rule

4

Repository

IOrderRepository saves the order

5

Database

EF Core writes a row, result returns

How a request flows inward and a result flows back out.

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.Sqlite

We 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.

Dependency inversion: Application owns the interface, Infrastructure implements it.

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 run

The 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.

LayerIts job in this requestKnows about the web?Knows about the database?
ApiRead JSON, return HTTP statusYesNo
ApplicationRun the PlaceOrder use caseNoNo
DomainEnforce "at least one item"NoNo
InfrastructureSave the order with EF CoreNoYes

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 ProjectReference links so none of them point outward.
  • The Domain Order entity 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.
  • IOrderRepository lives in the inner Application layer; the EF Core class that implements it lives in outer Infrastructure. That is dependency inversion.
  • Program.cs is the composition root: the one place that binds interfaces to concrete classes and exposes POST /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

Related Posts