Onion architecture without the ceremony
Onion architecture is a way of drawing one arrow. Everything points inward: infrastructure knows about the domain, the domain knows about nothing. If you remember only that, you already have the design — the project names and folder counts are details.
The reason to bother is change. Databases get replaced, an HTTP endpoint is joined by a background worker, a third-party API changes shape. When those details live at the edge, the code that holds business rules does not have to move.
The layers
- Domain — entities, value objects, invariants, domain services. No EF Core, no ASP.NET Core, no JSON attributes.
- Application — use cases: commands, queries, and the ports (interfaces) they need. Organised by feature, not by technical type.
- Infrastructure — adapters: EF Core repositories, HTTP clients, message senders, file storage. Implements the ports.
- Presentation — API endpoints, DTOs, request validation. Calls into the application layer only.
Dependencies only ever point from an outer layer to an inner one. The domain project has no PackageReference to anything but the base class library; application references domain; infrastructure references application; the host references all of them, once.
Model behaviour, not just data
The most common way an onion turns into a bag of DTOs is an anaemic domain model: entities with public setters and services that reach in and set properties. Push the rules down into the entity so an invalid object cannot exist.
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
private Order() { }
public static Order Create(Guid customerId) => new()
{
Id = Guid.NewGuid(),
CustomerId = customerId,
Status = OrderStatus.Draft
};
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderLine> Lines => _lines;
public void AddLine(Guid productId, Money unitPrice, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be changed.");
if (quantity <= 0)
throw new DomainException("Quantity must be positive.");
_lines.Add(new OrderLine(productId, unitPrice, quantity));
}
}
Now the rule lives in one place. A controller cannot skip it, and a test does not need a database to prove it works.
Ports in, adapters out
The application layer declares what it needs; infrastructure supplies it. The interface belongs to the consumer, which is why it lives inward, next to the use case that uses it:
// Application
public interface IOrderRepository
{
Task<Order?> GetAsync(Guid id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
}
public sealed class PlaceOrderHandler(IOrderRepository orders, IUnitOfWork unitOfWork)
{
public async Task<Guid> HandleAsync(PlaceOrder command, CancellationToken ct)
{
var order = Order.Create(command.CustomerId);
foreach (var line in command.Lines)
order.AddLine(line.ProductId, line.UnitPrice, line.Quantity);
await orders.AddAsync(order, ct);
await unitOfWork.SaveChangesAsync(ct);
return order.Id;
}
}
// Infrastructure
internal sealed class EfOrderRepository(AppDbContext db) : IOrderRepository
{
public Task<Order?> GetAsync(Guid id, CancellationToken ct = default) =>
db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct = default) =>
await db.Orders.AddAsync(order, ct);
}
Notice that IOrderRepository speaks in domain terms. If it started returning IQueryable<Order> or EF entities with tracking behaviour, the abstraction would leak and the application layer would be back to depending on the database.
One composition root
builder.Services.AddApplication(); // handlers, validators
builder.Services.AddInfrastructure(builder.Configuration); // EF, clients
app.MapOrderEndpoints();
The host is the only place that knows every layer. If a class inside the domain needs IConfiguration, that is a signal that the dependency is pointing the wrong way.
Where people go wrong
- Too many projects. Four is usually enough. A project per aggregate adds build friction and no boundaries you did not already have.
- Interfaces for everything. A port exists because there is a real seam — a database, a clock, a payment provider. Wrapping every class in an interface produces indirection without value.
- Leaking framework types. Returning
DbContext,HttpResponseMessageorIActionResultacross a boundary quietly re-couples the layers. - Mapping by hand everywhere. A small, explicit mapper per feature beats reflection-based mapping you cannot debug.
Is it worth it?
For a CRUD screen with one consumer, no. For a system with real business rules, several entry points, and a lifespan measured in years, yes — the cost is mostly discipline, and the payoff is that infrastructure changes stay boring. That is the whole point of the onion: the interesting code never has to care.