
Take a codebase that's painful to change and follow one request from the controller down to the database. Somewhere on the way you'll find a business rule tangled up with SQL, or a validation check that quietly needs an HTTP context. Each piece works on its own. The trouble is that every piece knows too much about its neighbours, so you can't touch one without the others noticing.
Onion architecture is one answer to that. It's older than many people assume and simpler than its diagrams suggest. It's also easy to do badly. This post covers where it came from, what it says, and how to use it without ending up with a museum of interfaces.
The short version
Put your business model in the center and let it depend on nothing.
Let every other piece of code depend inward, never outward.
Treat the database, the web framework and the email provider as details at the edge.
Where the name comes from
Jeffrey Palermo named the pattern on his blog in July 2008, in a series that grew to four parts, the last published in August 2013. He never claimed to have invented something new. He described it as pulling familiar object-oriented techniques together and giving them one name, so people could talk about the approach. That shared vocabulary is a big part of its value. He later covered it in a chapter of ASP.NET MVC in Action.
The problem it targets
Start with the classic layered app, where UI calls business logic and business logic calls data access. Palermo's complaint was that each layer ends up coupled to everything beneath it, so the UI is effectively coupled to data access too. Transitive dependencies are still dependencies.
He also argued that data access techniques tend to change every few years (he said about three, back in 2008), so any long-lived system will need to change them. If coupling makes that impractical, the system falls behind and is eventually rewritten. The exact interval isn't a law. The underlying point holds without it: the technology at the edges changes faster than the rules of your business.

Look at the right-hand side of Figure 1. The database is still there, and the UI still reaches it. The difference is the direction of the arrows: infrastructure depends on the core, not the other way around.
The one rule
Picture concentric circles. In the middle sits the domain model, the objects that carry the state and behaviour describing how your business works. Further out are other layers of the application core, and at the very edge are the UI, infrastructure and tests.
Code may depend on anything closer to the center, never on anything further out. All coupling points inward.

A few consequences follow from that:
The domain model depends only on itself.
Interfaces live inside, implementations live outside. Anything that loads or saves objects is declared as an interface in the core. The implementation, tied to a specific data-access technology, sits at the edge.
Something has to connect the two at runtime. That's why the pattern leans heavily on the Dependency Inversion principle.
The database is external. Palermo's view is that there are no "database applications" here, only applications that use a database as a storage service through infrastructure code that implements an interface the core understands.
Palermo boiled it down to four tenets, paraphrased here:
The core is built around an independent object model.
Inner layers define interfaces and outer layers implement them.
Coupling runs toward the center.
The application core can be compiled and run without any infrastructure.
The fourth is the easiest to check. If your core project can't build without a database package, you don't have an onion yet.
What lives in each ring
Ring | Typical contents | May depend on |
|---|---|---|
Domain model | Entities, value objects, business rules | Nothing |
Rest of the core | Repository and other interfaces, application services (use cases) | The domain model |
Outer edge | Web or UI, database implementations, API and email clients, tests | The core |
Palermo is explicit that the number of layers inside the core varies. The split above is a common convention, not a requirement, so don't add a ring just because a diagram has one.
A small example
This is an illustration in C#, not Palermo's code. Persistence mapping is left out to keep it short. Here is how the solution is laid out, and who references whom:
MyShop/
├── MyShop.Core/ references: nothing
│ └── Orders/
│ ├── Order.cs
│ ├── Interfaces.cs IOrderRepository, IClock
│ └── PlaceOrder.cs
├── MyShop.Infrastructure/ references: Core
│ ├── ShopDbContext.cs
│ ├── SqlOrderRepository.cs
│ └── SystemClock.cs
├── MyShop.Web/ references: Core, Infrastructure (wiring only)
│ └── Program.cs
└── MyShop.Tests/ references: Core
└── PlaceOrderTests.csThe core has no project references at all:
// MyShop.Core/Orders/Order.cs
public class Order
{
private readonly List<OrderLine> _lines = new();
public Guid Id { get; private set; } = Guid.NewGuid();
public DateTime PlacedAt { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines;
public bool IsPlaced => PlacedAt != default;
public void AddLine(string sku, int quantity, decimal unitPrice)
{
if (IsPlaced) throw new InvalidOperationException("Order already placed.");
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
_lines.Add(new OrderLine(sku, quantity, unitPrice));
}
public void Place(DateTime now)
{
if (_lines.Count == 0) throw new InvalidOperationException("Can't place an empty order.");
PlacedAt = now;
}
}
public record OrderLine(string Sku, int Quantity, decimal UnitPrice);
// MyShop.Core/Orders/Interfaces.cs
public interface IOrderRepository
{
Task<Order?> FindAsync(Guid id, CancellationToken ct);
Task SaveAsync(Order order, CancellationToken ct);
}
public interface IClock { DateTime UtcNow { get; } }
// MyShop.Core/Orders/PlaceOrder.cs
public class PlaceOrder(IOrderRepository orders, IClock clock)
{
public async Task ExecuteAsync(Guid orderId, CancellationToken ct)
{
var order = await orders.FindAsync(orderId, ct)
?? throw new KeyNotFoundException($"Order {orderId} not found.");
order.Place(clock.UtcNow);
await orders.SaveAsync(order, ct);
}
}Notice that Order enforces its own rules and PlaceOrder only coordinates. Neither knows what a database is.
The infrastructure project references the core and implements its interfaces:
// MyShop.Infrastructure/SqlOrderRepository.cs
public class SqlOrderRepository(ShopDbContext db) : IOrderRepository
{
public Task<Order?> FindAsync(Guid id, CancellationToken ct) =>
db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task SaveAsync(Order order, CancellationToken ct)
{
db.Orders.Update(order);
await db.SaveChangesAsync(ct);
}
}The web project is the only place that sees both sides, and it does so just to wire them together:
builder.Services.AddDbContext<ShopDbContext>(/* connection details */);
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddScoped<PlaceOrder>();The payoff shows up in the tests. InMemoryOrderRepository and FixedClock are small test doubles that live in the tests project:
[Fact]
public async Task Placing_an_order_stamps_the_time()
{
var repo = new InMemoryOrderRepository();
var clock = new FixedClock(new DateTime(2026, 10, 3, 9, 0, 0, DateTimeKind.Utc));
var order = new Order();
order.AddLine("SKU-1", 2, 10m);
await repo.SaveAsync(order, default);
await new PlaceOrder(repo, clock).ExecuteAsync(order.Id, default);
Assert.Equal(clock.UtcNow, order.PlacedAt);
}There's no database, no web server and no container in that test. Palermo has pointed out that the pattern doesn't require an IoC container either. Wiring things by hand works fine.
Onion, hexagonal, clean: same family
If you've read about ports and adapters or Clean Architecture, the rule will feel familiar. The three patterns share a core move, which is to keep infrastructure out of the business logic by inverting the dependency. Palermo acknowledged the overlap in his very first post, and Martin's Clean Architecture was explicitly an attempt to combine the earlier ideas. They differ mainly in emphasis.
Hexagonal (Ports and Adapters) | Onion | Clean Architecture | |
|---|---|---|---|
Who and when | Alistair Cockburn, 2005 | Jeffrey Palermo, 2008 | Robert C. Martin, 2012 (blog), 2017 (book) |
Main emphasis | Ports and adapters at the boundary, so users, programs, tests or scripts can all drive the app | Direction of coupling toward a domain model at the center | One explicit dependency rule across named rings |
Inside the app | The original write-up doesn't prescribe inner layers | A variable number of core layers around the domain model | Four named rings: Entities, Use Cases, Interface Adapters, Frameworks and Drivers |
Where the database sits | Behind a port, with the adapter outside | External: repository interface in the core, implementation at the edge | Outermost ring, accessed through an interface defined by the use cases |
You don't need domain-driven design to use any of them. Palermo says onion architecture works with or without DDD, and fits CQRS or plain forms-over-data apps. Which name you use matters less than the rule, so pick the vocabulary your team will actually say out loud.
When not to use it
Palermo said up front that the pattern isn't appropriate for small websites. He aimed it at long-lived business applications and systems with complex behavior. That's the right call. A three-table admin screen with a few forms doesn't need an inner core, a repository interface and a separate infrastructure project. You'd be paying for extra projects, mapping code and indirection to protect logic that barely exists. The architecture earns its keep when there's real business behavior worth protecting and a long future of changing technology around it.
Common mistakes
These aren't from the original posts. They're failure modes worth watching for.
Mistake | What goes wrong | Better |
|---|---|---|
Interfaces for everything | An interface with one implementation between two classes in the same ring is just noise | Add an interface where a dependency crosses a ring boundary and you'd want to fake or swap it |
Leaking the edge inward | A repository returning | Return domain objects and keep framework types out of the core |
Folders instead of enforcement | Naming a folder "Core" doesn't make it one | Use separate projects with one-way references, and add architecture tests (NetArchTest in .NET, ArchUnit in Java) |
A repository per table | Mirroring the schema pulls the database back to the center | Shape repositories around how the domain loads and saves its aggregates |
An empty core | Rules end up in services while entities are bags of getters, so you get the ceremony without the benefit | Put the rules where the data lives |
A quick sanity check
Ask three questions of your own project:
Does the core build without referencing any infrastructure or web framework?
Can you test a use case end to end without a database?
If you replaced SQL Server with something else, would the change be limited to the infrastructure project and the wiring?
Three yeses mean you have the real thing, whatever the folders are called. Any no points to the place to start.
Sources
Jeffrey Palermo, The Onion Architecture: part 1 (July 29, 2008) and part 2
Jeffrey Palermo, Onion Architecture: Part 4, After Four Years (August 2013)
Alistair Cockburn, Hexagonal Architecture (2005)
Robert C. Martin, The Clean Architecture (2012)
Herberto Graca, Onion Architecture
Comments (0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!