Onion Architecture: Domain-Centric Design Guide
A practical guide to Onion Architecture: organize code around the domain, enforce inward dependencies, and isolate infrastructure. Includes C# examples.
Overview
Onion Architecture, introduced by Jeffrey Palermo in 2008, organizes an application as concentric layers with the domain model at the center. In a traditional layered architecture, dependencies point downward: UI depends on business logic, which depends on the database. Onion inverts that direction. Every layer depends on the layers closer to the center, never the other way around. Infrastructure, UI, and external services sit at the outer edge and depend on abstractions defined in the domain core. This keeps the domain model free of frameworks, databases, and delivery mechanisms.
I first ran into Onion Architecture on a .NET project where the team had built a moderately complex ordering system on top of Entity Framework. The business rules were buried inside EF entity configurations and controller actions. When we needed to swap SQL Server for PostgreSQL during a cloud migration, we discovered that the domain logic was so tangled with EF Core that the migration took six weeks instead of the expected one. Onion Architecture would have isolated that change to the infrastructure layer. The lesson stuck: the domain shouldn’t know or care which database sits behind it.
The pattern gets its name from the metaphor of an onion. Peel back the outer layers and you find more layers, each one more central and more stable than the last. At the very center sits the domain model, the part of the system that encodes business rules and that changes the least. Everything else is a detail that can be swapped: the database, the web framework, the message bus, the caching layer. By keeping those details at the edges and depending on abstractions, you buy yourself the flexibility to change them without rewriting the core. I think of it as a bet that your infrastructure choices will be wrong eventually. They almost always are.
When to Use
Use Onion Architecture when the domain model must outlive framework choices, when business rules are complex and change often, and when you want to delay decisions about the database, web framework, or UI. It also helps when you need fast, deterministic tests for business rules without spinning up a database or web server, and when you’re already applying Domain-Driven Design.
Teams that maintain long-lived applications benefit the most. If your project runs for three to five years or more, the frameworks you chose at the start will likely be replaced or upgraded. Onion keeps those decisions reversible because the domain has no framework references. I’ve worked on projects where we migrated from ASP.NET MVC to ASP.NET Core Web API, and later to Minimal APIs, without touching a single line of domain code. That’s the payoff. On one project, we swapped NHibernate for EF Core over a weekend — the domain tests didn’t change at all.
It also shines in regulated industries where the domain model must be auditable and testable in isolation. If you work in fintech, healthcare, or insurance, those business rules are the actual product — not the UI, not the API, the rules themselves. Onion lets you prove those rules work with pure unit tests, no database or web server in the loop. Auditors and QA teams can verify behavior deterministically, which is hard to do when the rules are scattered across controllers and stored procedures.
When to Avoid
Avoid it for simple CRUD or throwaway prototypes where the extra layering costs more than it gives back. If the team isn’t comfortable with dependency inversion or testing through interfaces, the structure can feel heavy. It’s also a poor fit when deadlines matter more than long-term maintainability and the domain is unlikely to change. I once inherited a project where someone had added four Onion layers to a simple admin tool with three entities and no business logic — navigating four projects to add a single field to a form took longer than writing the feature itself.
I’ve seen teams add four projects (Domain, Application, Infrastructure, Presentation) to a simple internal admin tool that had three entities and no business rules. The overhead of navigating four projects to add a field to a form was worse than the problem Onion solves. If your app is mostly data entry with minimal logic, vertical slice architecture is a better fit.
Core Concepts
The Layers
The architecture splits the system into four layers. I’ll walk through them from the inside out, starting at the center where nothing depends on anything external. The Domain Core sits at the center and contains entities, value objects, domain events, and business rules, and it stays free of outside dependencies. Domain Services hold operations that don’t fit naturally inside an entity, and they rely only on the Domain Core. Application Services coordinate use cases, map DTOs, and drive domain objects, relying on the Domain Core and Domain Services. Infrastructure fills in the interfaces defined by inner layers, such as repositories, message buses, file storage, and external APIs, and it connects to the Application layer through those interfaces. Presentation contains controllers, CLI handlers, or views and depends on the Application Services. This setup leaves the domain as the most stable part of the system. Nothing outside can reach inward and crack it open.
The Dependency Rule
All dependencies point inward. Outer layers depend on inner layers through
interfaces that live in the inner layers. The domain stays clear of Entity
Framework, ASP.NET, RabbitMQ, and any other framework. Instead, the
infrastructure layer references the domain and fills in interfaces such as
IOrderRepository or IEventBus.
This rule is what makes the whole pattern work. Without it, you just have
layered architecture wearing a costume. The first time I reviewed an “Onion”
codebase that didn’t enforce this rule, I found IOrderRepository defined in
the Infrastructure project. The domain had to reference Infrastructure to use
it. That’s not Onion, that’s layered architecture with extra projects and
extra confusion. The dependency rule is what makes the domain portable, testable
in isolation, and immune to framework churn. Enforce it with architecture tests
in CI; don’t rely on code review alone because someone will always sneak an
EF Core reference into the domain “just this once.”
Ports and Adapters
The interfaces defined by the inner layers are ports. The concrete implementations in the outer layers are adapters. The application states its needs, and the infrastructure satisfies them. This decoupling lets you swap SQL Server for PostgreSQL, REST for gRPC, or a real bus for an in-memory fake without touching the domain.
This concept overlaps with hexagonal architecture (also called Ports and Adapters). The difference is mostly naming and emphasis: Onion names its layers explicitly, while hexagonal focuses on the ports/adapters metaphor. In practice, most teams I’ve worked with use the terms interchangeably and pick whichever framing makes the architecture click for their team.
Layer Diagram
The diagram shows the key insight: arrows only point inward. Infrastructure fills in ports defined in the domain, but the domain never references infrastructure. Presentation calls application services, but application services never know about controllers.
Implementation Example
The C# snippets below show a small order system: the domain defines an Order
entity and an IOrderRepository port, the application layer places an order, and
the infrastructure layer builds the repository with Entity Framework Core. The
full code with tests is available in the
companion repository.
Domain Core
The domain has zero external dependencies. No EF Core, no ASP.NET, no logging framework, nothing. It defines entities, value objects, events, and the interfaces (ports) that infrastructure will implement.
// Domain Core — no external dependencies
public interface IOrderRepository
{
Task<Order> GetByIdAsync(OrderId id);
Task SaveAsync(Order order);
}
public class Order
{
public OrderId Id { get; private set; }
public Money Total { get; private set; }
private List<OrderLine> _lines = new();
public void AddLine(Product product, int quantity)
{
if (quantity <= 0) throw new DomainException("Quantity must be positive");
_lines.Add(new OrderLine(product, quantity));
RecalculateTotal();
}
private void RecalculateTotal() =>
Total = _lines.Aggregate(Money.Zero, (sum, line) => sum + line.Subtotal);
}
Notice that Order enforces its own invariants. The AddLine method validates
quantity and recalculates the total. This isn’t an anemic model; the entity
holds behavior. The IOrderRepository interface is a port defined in the domain,
not in infrastructure.
Application Layer
The application layer orchestrates use cases. It depends on domain interfaces, not on concrete implementations. This is where dependency injection wires everything together at the composition root.
// Application Layer — orchestrates use cases
public class PlaceOrderHandler
{
private readonly IOrderRepository _orderRepository;
private readonly IProductRepository _productRepository;
private readonly IEventBus _eventBus;
public PlaceOrderHandler(
IOrderRepository orderRepository,
IProductRepository productRepository,
IEventBus eventBus)
{
_orderRepository = orderRepository;
_productRepository = productRepository;
_eventBus = eventBus;
}
public async Task<OrderId> Handle(PlaceOrderCommand command)
{
var order = new Order();
foreach (var item in command.Items)
{
var product = await _productRepository.GetByIdAsync(item.ProductId);
order.AddLine(product, item.Quantity);
}
await _orderRepository.SaveAsync(order);
await _eventBus.PublishAsync(new OrderPlacedEvent(order.Id, order.Total));
return order.Id;
}
}
The handler doesn’t know whether the repository uses SQL Server, MongoDB, or an
in-memory list. It only talks to IOrderRepository. That’s what keeps the
application layer testable with mocks and fast. On a project I worked on,
we ran 200+ application tests in under 8 seconds because none of them touched a
database. The whole suite finished before the coffee machine did.
Infrastructure Layer
Infrastructure fills in the domain’s ports. It references EF Core, RabbitMQ clients, file system APIs, or whatever external technology is needed.
// Infrastructure Layer — implements domain interfaces
public class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _dbContext;
public SqlOrderRepository(AppDbContext dbContext) => _dbContext = dbContext;
public async Task<Order> GetByIdAsync(OrderId id) =>
await _dbContext.Orders
.Include(o => o.Lines)
.FirstAsync(o => o.Id == id);
public async Task SaveAsync(Order order)
{
_dbContext.Orders.Add(order);
await _dbContext.SaveChangesAsync();
}
}
Solution Structure
A typical .NET solution looks like this:
src/
Domain/
Entities/Order.cs
ValueObjects/Money.cs
Events/OrderPlacedEvent.cs
Interfaces/IOrderRepository.cs
Application/
Orders/PlaceOrder/PlaceOrderHandler.cs
DTOs/OrderDto.cs
Infrastructure/
Persistence/Repositories/SqlOrderRepository.cs
Messaging/RabbitMqEventBus.cs
Presentation/
Controllers/OrdersController.cs
Enforcing Dependencies in CI
Dependency rules can be enforced in CI with a test such as this one using NetArchTest or ArchUnit:
var result = Types.InAssembly(typeof(Order).Assembly)
.Should().NotHaveDependencyOn("Infrastructure")
.And().NotHaveDependencyOn("Presentation")
.And().NotHaveDependencyOn("Microsoft.EntityFrameworkCore")
.GetResult();
result.IsSuccessful.Should().BeTrue();
I consider architecture tests non-negotiable for Onion projects. Without them,
someone will add a reference to Microsoft.EntityFrameworkCore in the domain
“just this once” and the dependency rule silently breaks. The test costs five
minutes to write and saves weeks of refactoring later. I learned this the hard
way on a project where we found the violation three
months in. Untangling it took two sprints.
Testing Strategy
Testability is where Onion Architecture pays off the most. Each layer gets a different testing approach. Let me walk through each one.
Domain Core tests are pure unit tests — no mocks, no database, no I/O. They run in milliseconds and cover business rules. That’s where you get the fastest feedback on logic changes. If a domain test is slow or needs a mock, you’ve leaked infrastructure inward and the whole point is lost.
Application Service tests use mocked ports (IOrderRepository,
IEventBus). You check that the handler calls the right methods in the right
order, applies business rules, and publishes events. These tests are fast because
the mocks are just interfaces — no database, no network, no startup time.
Infrastructure tests spin up a real database or a test container. I usually
reach for Testcontainers here — it gives me a throwaway PostgreSQL or SQL
Server instance per test run.
They verify that SqlOrderRepository correctly persists and retrieves orders, that
mappings work, and that migrations apply cleanly. These tests are slower but
they catch the integration bugs that unit tests can’t see.
Presentation tests hit the full API host using WebApplicationFactory
or something similar. They verify HTTP status codes, serialization, routing, and
authentication.
The further inward you go, the faster and more deterministic the tests get. If your domain tests need a database, something went wrong with the dependency direction — you’ve leaked infrastructure inward.
A practical testing setup I’ve used on three projects: domain tests run on every save (they take under 500ms for the whole suite), application tests run on every push (they take 2-5 seconds with mocks), and infrastructure tests run only on PRs because they need a Docker container for the database. This tiered setup gives you fast feedback where it counts — business rules — and thorough verification where it’s needed — integration. The separation is only possible because Onion keeps the layers isolated; in a traditional layered app, every test would need the full stack and you’d be waiting 30 seconds for a simple domain check.
Best Practices
Keep the Domain Core pure by making sure it never references a framework, ORM, or
external library. Define repository, bus, and unit-of-work interfaces in the
domain or application layer, not in infrastructure. Wire concrete adapters through
dependency injection at the composition root, usually in Program.cs or a startup
module. Enforce layer boundaries with architecture tests in CI, because a passing
build isn’t enough when a new reference creeps inward. Map between entities and
DTOs explicitly, and never expose domain objects straight from controllers. Keep
business rules inside entities and domain services, and let application services
only coordinate.
I’ve found that the repository pattern works naturally here because the domain defines the interface and infrastructure gives you the implementation. Don’t skip the repository abstraction even if you use EF Core directly; the abstraction is what keeps the domain testable.
One practice I recommend: keep the domain project physically small. If it grows beyond a few hundred files, consider splitting by bounded context. A domain that fits in one solution folder is easier to reason about than one spread across dozens of subfolders. The same goes for the application layer; once you’re past 20 use case handlers, you’ve probably missed a bounded context boundary and should split before it gets worse.
Common Mistakes
Leaking ORM details into the domain is the most common mistake I see. Mapping
configuration and
framework attributes belong in infrastructure. I’ve seen [Table("Orders")] and
[Column("total")] attributes on domain entities, which ties the domain to a
specific ORM and breaks the portability that Onion is supposed to provide. Use
fluent configuration in infrastructure instead.
Putting business logic in application services also breaks the model, because
rules belong in the domain while application code coordinates. If your
PlaceOrderHandler contains validation logic, discount calculations, or state
transitions, move those into the Order entity or a domain service. The handler
should be thin: load, call domain method, save, publish.
Circular dependencies between layers can be caught early with architecture tests. Building an anemic domain model, where entities are just data bags with getters and setters, misses the point. Adding every layer to a small CRUD app is overkill; the pattern only pays off when domain complexity is genuine.
Another mistake I see often: teams create a Domain project but then put
IUnitOfWork in infrastructure “because it’s about databases.” No. IUnitOfWork
is a port. It belongs in the domain or application layer, not in infrastructure. Infrastructure
implements it. If you put the interface in infrastructure, the domain has to
reference infrastructure to use it, which inverts the dependency rule.
A subtler mistake is over-abstracting the domain. Some teams create interfaces for every entity, every value object, and every service, turning the domain into a web of abstractions that’s harder to read than the original problem. The domain should be concrete: real classes with real methods that do real work. Interfaces go at the boundaries (repositories, event buses, external services), not inside the domain model itself. When I review Onion projects, I look for domain code that reads like a description of the business. If it reads like a framework, something went wrong and the abstractions have taken over the actual logic.
Onion vs. Clean vs. Hexagonal
These three architectures are close cousins. They all enforce the same principle with different naming. Clean Architecture by Robert C. Martin uses concentric rings without naming the layers. Onion Architecture (Jeffrey Palermo) gives explicit names: Domain, Application, Infrastructure, Presentation. Hexagonal Architecture (Alistair Cockburn) focuses on the ports and adapters metaphor without prescribing layer names.
In practice, they all do the same thing: dependencies point inward, the domain is framework-agnostic, infrastructure is swappable. Pick whichever framing works for your team. I’ve worked on projects that called themselves “Clean Architecture” but were structurally identical to Onion and vice versa. The naming matters less than the discipline of enforcing the dependency rule.
One question that comes up: should you mix Onion with CQRS or event sourcing? You can. Onion defines the layering; CQRS defines how commands and queries flow through those layers. They’re orthogonal. I’ve seen teams use Onion for the command side (write model) and a simpler read model for queries, which is essentially CQRS within an Onion structure. CQRS doesn’t replace the dependency rule; it adds a separation between read and write paths on top of it.
Summary
Onion Architecture puts the domain at the center and makes everything else depend on it inward. Infrastructure depends on domain abstractions, never the reverse. The domain stays free of frameworks, databases, and delivery mechanisms. Use it when business rules are complex and long-lived. Skip it for simple CRUD apps where the layering overhead exceeds the benefit. Enforce the dependency rule with architecture tests in CI. Keep business logic in entities and domain services. Let application services coordinate, not decide. The pattern pairs with DDD, the repository pattern, and DI. The upfront cost is real — more interfaces, more projects. But the payoff comes when you swap a database or replace a framework without touching the business rules.
See Also
Jeffrey Palermo’s original Onion Architecture series remains the canonical reference. The Microsoft .NET architecture guide covers clean architecture patterns in the .NET ecosystem with practical examples. For enforcing architecture rules in tests, NetArchTest gives you a fluent API for .NET assembly dependency checks, and ArchUnit does the same for Java. If you’re comparing approaches, the clean architecture guide and hexagonal architecture guide cover the related patterns with trade-offs. The dependency injection pattern explains how to wire adapters at the composition root, which is essential for making Onion work in practice.
Frequently Asked Questions
What is the difference between Onion and Clean Architecture?
Both use the same inward dependency rule. Onion gives explicit names to the layers: Domain, Application, Infrastructure, Presentation. Clean Architecture draws the same idea as generic concentric rings. Same thing, different names.
Can I use Onion Architecture in a monolith?
Yes. It works at module or application level. A monolith can contain several onion-structured modules, each with its own domain core.
Which ORM works best?
Any ORM that lets you use plain POCO or POJO entities without base classes or attributes. EF Core with Fluent API, Dapper, Hibernate with XML mappings, and SQLAlchemy with declarative bases all work.
How do I start with an existing codebase?
Pick one bounded context or service and apply the layering there. Move framework code outward, define ports in the domain, and add an adapter. Measure before expanding.
How do I handle transactions?
Define IUnitOfWork in the domain or application layer. Infrastructure
handles it with EF Core or Dapper. The application handler opens the unit of
work, runs domain operations, and commits. The domain knows nothing about
transactions.
How do I test each layer?
Domain Core tests are pure unit tests with no mocks. Application Service tests use mocked ports. Infrastructure tests run against a real database or a test container. Presentation tests run against the full API host.
Should I use Onion for microservices?
It depends on how complex the service gets. For simple CRUD microservices, Onion adds overhead without benefit. For services with rich business rules, Onion at the service level keeps the domain clean and makes the service independently testable. Many teams I've worked with use Onion per service in a microservices architecture.
Related Resources
Layered Architecture — N-Tier Explained
A practical guide to Layered (N-Tier) Architecture: separating presentation, business logic, and data layers with clear responsibilities and dependency rules.
GuideVertical Slice Architecture: Feature-First Organization
A practical guide to Vertical Slice Architecture: organize code by feature instead of technical concern, reducing cross-layer navigation and improving cohesion.
PatternDependency Injection Pattern
Supply dependencies from outside rather than creating them internally. An architectural pattern for decoupled, testable code.
PatternRepository Pattern
Abstract data access logic behind a clean interface. An architectural design pattern for testable, maintainable data layers.
GuideClean Architecture
A practical guide to Uncle Bob's Clean Architecture: organize code into layers so that frameworks, UI, and databases are details, not dependencies.
GuideHexagonal Architecture — Ports, Adapters, and Testability
A complete guide to Hexagonal Architecture (Ports and Adapters): structure applications so domain logic is isolated from frameworks, databases, and external services.