StackPractices
intermediate By Mathias Paulenko

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

flowchart diagram: subgraph Outer[

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.