Skip to content
StackPractices
intermediate By Mathias Paulenko

Onion Architecture — Dependency Inversion in Practice

A practical guide to Onion Architecture: organizing code around the domain model, enforcing dependency direction inward, and isolating infrastructure from business logic.

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

Onion Architecture, popularized by Jeffrey Palermo, structures applications as concentric layers with the domain model at the center. Unlike traditional layered architecture where dependencies point downward (UI → Business → Data), Onion inverts this: all dependencies point inward toward the domain core. Infrastructure, UI, and external services live at the outer edges and depend on inner abstractions, never the other way around. This makes the domain model completely isolated from frameworks, databases, and delivery mechanisms.

When to Use

  • For alternatives, see Hexagonal Architecture — Ports, Adapters, and Testability.

  • You need a domain model that survives framework changes

  • Your business logic is complex and changes frequently

  • You want to defer technology decisions (database, framework, UI)

  • Testing business rules without database or web server is a priority

  • You are applying Domain-Driven Design (DDD) principles

The Layers

LayerResponsibilityDependencies
Domain CoreEntities, value objects, domain events, business rulesNone (pure)
Domain ServicesOperations that don’t belong to an entityDomain Core
Application ServicesUse cases, orchestration, DTOsDomain Core, Domain Services
InfrastructureDB access, external APIs, messaging, file systemApplication Services (via interfaces)
PresentationControllers, CLI handlers, viewsApplication Services

Dependency Rule

All dependencies point inward. Outer layers depend on inner layers via interfaces defined in the inner layers.

// Domain Core — innermost layer
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);
}
// 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;
    }
}
// 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();
    }
}

Ports and Adapters

The outer layers implement interfaces (ports) defined by inner layers. This is the Ports and Adapters pattern.

┌─────────────────────────────────────┐
│  Presentation (Controllers, CLI)   │
│         ↓ uses interfaces          │
├─────────────────────────────────────┤
│  Application Services (use cases)  │
│         ↓ uses interfaces          │
├─────────────────────────────────────┤
│  Domain Services (operations)      │
│         ↓ uses                     │
├─────────────────────────────────────┤
│  Domain Core (entities, rules)     │
└─────────────────────────────────────┘

   Infrastructure implements interfaces defined above

Common Mistakes

  • Leaking ORM details into the domain — mapping configuration belongs in infrastructure, not entity classes
  • Application services with business logic — business rules belong in the domain, orchestration in application
  • Circular dependencies — using a tool like ArchUnit or NetArchTest to enforce layer boundaries
  • Anemic domain model — entities should encapsulate behavior, not just data
  • Too many layers — for simple CRUD apps, Onion can be overkill; use it when domain complexity justifies it

Troubleshooting

  • High latency between services: trace the request path. Look for synchronous chains, missing caching, and oversized payloads that cross network boundaries.
  • Single point of failure: identify components without redundancy. Add replicas, failover, or circuit breakers before scaling traffic.
  • Unexpected coupling between services: review shared databases, libraries, and schemas. Bound contexts should own their data and expose stable interfaces.
  • Cost spikes after scaling: right-size instances and use autoscaling with limits. Reserved capacity or spot instances can reduce steady-state spend.
  • Difficult to reason about the system: maintain architecture decision records and service dependency maps. Use observability to validate the diagrams.

FAQ

Onion vs Clean Architecture? Both share the same dependency inversion principle. Onion explicitly names the layers (Domain, Application, Infrastructure, Presentation), while Clean Architecture uses a more generic concentric ring model. They are functionally equivalent.

Can I use Onion with a monolithic application? Yes. Onion Architecture works at the module or application level. A monolith can have multiple onion-structured modules.

What ORM works best with Onion? Any ORM that supports POCO/POJO entities without requiring base classes or attributes. EF Core with Fluent API, Dapper, Hibernate with XML mappings, or SQLAlchemy with declarative base all work.

How do I get started with this in an existing project?

Start with a small, isolated part of your codebase. Apply the concepts from this guide to one module or service. Measure the impact, then expand to other areas.

What tools do I need?

The tools mentioned throughout this guide are listed in each section. Most are open-source and widely adopted. Check the related resources for setup instructions.

How do I measure success after implementing this?

Define clear metrics before starting: performance benchmarks, error rates, or maintainability indicators. Compare before and after. Iterate based on the data, not on assumptions.

Advanced Topics

Detailed Scenario: Order System with Onion Architecture

Project: Order system .NET 8
Project structure:
  src/
    Domain/                    # Core — no external dependencies
      ├── Entities/
      │   ├── Order.cs          # Entity with business logic
      │   ├── OrderLine.cs      # Value object
      │   └── Product.cs
      ├── ValueObjects/
      │   ├── Money.cs          # Immutable value object
      │   └── OrderId.cs
      ├── Events/
      │   ├── OrderPlacedEvent.cs
      │   └── OrderCancelledEvent.cs
      ├── Interfaces/
      │   ├── IOrderRepository.cs   # Port defined in domain
      │   ├── IProductRepository.cs
      │   └── IEventBus.cs
      └── Exceptions/
          └── DomainException.cs
    Application/               # Use cases — depends on Domain
      ├── Orders/
      │   ├── PlaceOrder/
      │   │   ├── PlaceOrderCommand.cs
      │   │   ├── PlaceOrderHandler.cs
      │   │   └── PlaceOrderValidator.cs
      │   ├── CancelOrder/
      │   │   ├── CancelOrderCommand.cs
      │   │   └── CancelOrderHandler.cs
      │   └── GetOrderById/
      │       ├── GetOrderByIdQuery.cs
      │       └── GetOrderByIdHandler.cs
      └── DTOs/
          └── OrderDto.cs
    Infrastructure/           # Implementations — depends on Application
      ├── Persistence/
      │   ├── AppDbContext.cs
      │   ├── Configurations/
      │   │   └── OrderConfiguration.cs   # EF Core mapping
      │   └── Repositories/
      │       ├── SqlOrderRepository.cs    # Implements IOrderRepository
      │       └── SqlProductRepository.cs
      ├── Messaging/
      │   └── RabbitMqEventBus.cs          # Implements IEventBus
      └── DependencyInjection.cs
    Presentation/             # API — depends on Application
      ├── Controllers/
      │   └── OrdersController.cs
      └── Program.cs

Dependency rules (verified with NetArchTest):
  Domain references no project
  Application references only Domain
  Infrastructure references Application and Domain
  Presentation references Application and Domain
  No project references Infrastructure (dependency inversion)

Testing per layer:
  | Layer | Type | Tool |
  |-------|------|------|
  | Domain | Pure unit, no mocks | xUnit |
  | Application | Unit with repo mocks | xUnit + NSubstitute |
  | Infrastructure | Integration with Testcontainers | xUnit + Testcontainers |
  | Presentation | Integration with WebApplicationFactory | xUnit |

Architecture verification in CI:
  // ArchUnitTest.cs
  var result = Types.InAssembly(typeof(Order).Assembly)
      .Should().NotHaveDependencyOn("Infrastructure")
      .And().NotHaveDependencyOn("Presentation")
      .And().NotHaveDependencyOn("Microsoft.EntityFrameworkCore")
      .GetResult();
  result.IsSuccessful.Should().BeTrue();

How do I handle transactions in Onion Architecture?

Define an IUnitOfWork interface in the domain. Infrastructure implements it with EF Core or Dapper. The application handler uses IUnitOfWork to coordinate transactions: open the unit of work, execute domain operations, and commit or rollback. The domain knows nothing about transactions; it only exposes methods that change its state. The application layer decides when to persist.

End of document. Review and update quarterly.

Common Production Pitfalls

  • Treating the guide as a checklist to complete once rather than a practice to evolve.
  • Adopting every recommendation at once instead of starting with one measured change.
  • Skipping the maturity assessment and forcing advanced practices on an unprepared team.
  • Not updating runbooks and on-call expectations as new practices are introduced.
  • Ignoring real incident data when prioritizing which parts of the guide to apply first.
  • Failing to assign an owner who reviews decisions quarterly.
  • Copying examples without adapting them to the team’s actual tooling and constraints.
  • Forgetting to measure outcomes before adding the next improvement.