Onion / Clean Architecture in .NET: project references and dependency inversion (C#)

.NET Architecture & Design Patterns November 13, 2026 csharp

Difference from N-tier: in N-tier the BLL points to the DAL.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// ======================================================================
// Q: Explain Onion / Clean Architecture. How do the projects reference each
// other?
//
// - Difference from N-tier: in N-tier the BLL points to the DAL. Here
//   Infrastructure points to Application (dependency inversion). The database is
//   a detail.
// - Onion vs Clean: practically the same. Clean explicitly names the use cases
//   (handlers) and the "interface adapters".
// - Enforce the rules with architecture tests, so that nobody (not even an AI
//   agent) puts EF Core into the Domain.
// ======================================================================

// Domain: plain C#, the rules live inside the entity
public class Order
{
    public OrderStatus Status { get; private set; }
    public void Cancel()
    {
        if (Status == OrderStatus.Shipped) throw new DomainException("Already shipped");
        Status = OrderStatus.Cancelled;
    }
}

// Application: defines the interface (port) and the use case
public interface IOrderRepository { Task<Order?> GetAsync(int id, CancellationToken ct); }

public class CancelOrderHandler(IOrderRepository orders, IUnitOfWork uow)
{
    public async Task Handle(int id, CancellationToken ct)
    {
        var order = await orders.GetAsync(id, ct) ?? throw new NotFoundException();
        order.Cancel();
        await uow.SaveChangesAsync(ct);
    }
}

// Infrastructure: the EF Core implementation
public class EfOrderRepository(AppDbContext db) : IOrderRepository
{
    public Task<Order?> GetAsync(int id, CancellationToken ct) =>
        db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);
}

// Api: composition root
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();

// Architecture test: the Domain must not depend on EF Core or Infrastructure
[Fact]  // NetArchTest.Rules
public void Domain_does_not_depend_on_infrastructure()
{
    var result = Types.InAssembly(typeof(Order).Assembly)
        .ShouldNot().HaveDependencyOnAny("Microsoft.EntityFrameworkCore", "Shop.Infrastructure")
        .GetResult();
    Assert.True(result.IsSuccessful);
}