← Back to Blog

Onion Architecture

A beginner-friendly guide to onion architecture, how its layers work, and when it helps teams keep business rules clean and stable.

What It Is

Onion architecture is a layered architectural style that places business logic at the center and pushes technical details to the outside. The key idea is that core domain rules should not depend on frameworks, databases, UI code, or external services.

Dependencies flow inward. Outer layers can reference inner layers, but inner layers do not know anything about outer ones. This helps protect domain logic from frequent infrastructure changes.

Why Teams Use It

Teams often choose onion architecture when they want long-term maintainability and clear boundaries between business decisions and technical implementation. By keeping domain models and use cases independent, teams can swap infrastructure choices with less disruption.

Typical Layers

  • Domain: entities, value objects, and core business rules
  • Application: use cases and orchestration of domain behavior
  • Infrastructure: database access, external APIs, file systems, queues
  • Presentation: HTTP endpoints, UI controllers, or GraphQL resolvers

Naming Tip: Infrastructure

Infrastructure is a strong and common name for concrete implementations. A simple rule of thumb is:

  • Use Infrastructure for technical details like EF Core repositories, message brokers, file storage, email providers, cache clients, and third-party API clients
  • Keep domain logic out of Infrastructure
  • Let Domain and Application define contracts, and let Infrastructure implement them

If one Infrastructure project gets too broad, split by capability:

src/
  Ordering.Infrastructure.Persistence/
    AppDbContext.cs
    Repositories/
      OrderRepository.cs
  Ordering.Infrastructure.Messaging/
    KafkaProducer.cs
    OutboxDispatcher.cs
  Ordering.Infrastructure.Identity/
    JwtTokenService.cs
  Ordering.Infrastructure.Integrations/
    StripePaymentGateway.cs
    SendGridEmailSender.cs

Pros

  • Business logic remains independent of framework and infrastructure choices
  • Testing core behavior is easier because domain and use cases can be tested without I/O
  • Boundaries are explicit, which can reduce accidental coupling over time
  • Swapping infrastructure (for example, a database provider) becomes more manageable

Cons

  • There is more upfront structure and ceremony compared to simpler architectures
  • Small projects may feel over-engineered if boundaries are too strict too early
  • Teams need discipline to keep dependency direction correct
  • Extra abstractions can slow development if used without clear purpose

Common Misconceptions

  • "Every class needs an interface." Onion architecture does not require interfaces everywhere. Add abstractions where they protect business logic from volatile dependencies such as databases, external APIs, or time providers.
  • "Infrastructure is bad code that belongs far away." Infrastructure is essential code, not second-class code. The goal is separation of concerns, not lower quality.
  • "Domain entities cannot have behavior." Rich domain models are encouraged. Entities should enforce invariants and business rules instead of becoming passive data containers.
  • "You cannot use EF Core with Onion architecture." You can. Keep EF Core in Infrastructure and avoid leaking EF-specific concerns into Domain and Application.
  • "Onion architecture always means many projects." You can start in one project with folders and dependency rules, then split into multiple projects as complexity grows.
  • "It automatically makes code clean." The structure helps, but teams still need consistent naming, good tests, and disciplined boundaries.

Swappable Infrastructure Choices

One practical benefit of onion architecture is that teams can replace technical dependencies with less disruption when contracts stay stable.

  • Database engine: SQL Server to PostgreSQL while keeping repository interfaces unchanged
  • Data access style: EF Core to Dapper by swapping repository implementations in Infrastructure
  • Message broker: RabbitMQ to Azure Service Bus or Kafka behind an event bus abstraction
  • Cache provider: in-memory cache to Redis through a shared cache interface
  • File storage: local disk to Azure Blob Storage or S3 behind a file storage adapter
  • Email or SMS provider: SendGrid or Twilio to alternatives behind notification interfaces
  • Identity provider: Auth0 to Azure AD B2C or another OIDC provider behind an auth boundary
  • Search engine: SQL full-text to Elasticsearch or OpenSearch behind a search service contract
  • Background jobs: Hangfire to Quartz.NET or cloud jobs through job scheduling abstractions
  • Observability stack: logging and metrics backends can change with minimal domain impact

This only works well if inner layers avoid vendor-specific types. Once Domain or Application depends directly on a framework SDK model, swapping providers becomes significantly harder.

When to Choose It

Onion architecture fits well when business rules are complex and likely to outlive specific frameworks or tools. It is especially useful in systems where domain correctness matters more than quickly shipping a thin CRUD layer.

Example Structure

A realistic C# solution often separates each layer into its own project:

src/
  Ordering.sln
  Ordering.Domain/
    Entities/
      Order.cs
      OrderItem.cs
    ValueObjects/
      Money.cs
    Interfaces/
      IOrderRepository.cs
      IUnitOfWork.cs
  Ordering.Application/
    Orders/
      PlaceOrder/
        PlaceOrderCommand.cs
        PlaceOrderHandler.cs
      GetOrderById/
        GetOrderByIdQuery.cs
        GetOrderByIdHandler.cs
    Abstractions/
      IClock.cs
      ICurrentUserService.cs
  Ordering.Infrastructure/
    Persistence/
      AppDbContext.cs
      Repositories/
        OrderRepository.cs
    Services/
      SystemClock.cs
  Ordering.Api/
    Controllers/
      OrdersController.cs
    DependencyInjection.cs
    Program.cs

This keeps domain and application independent, while infrastructure and API reference inward to implement and expose behavior.

Code Example

The application layer depends on domain interfaces, while infrastructure provides concrete implementations:

// Ordering.Domain/Interfaces/IOrderRepository.cs
namespace Ordering.Domain.Interfaces;

public interface IOrderRepository
{
    Task AddAsync(Order order, CancellationToken cancellationToken = default);
    Task<Order?> GetByIdAsync(Guid orderId, CancellationToken cancellationToken = default);
}

// Ordering.Application/Orders/PlaceOrder/PlaceOrderHandler.cs
namespace Ordering.Application.Orders.PlaceOrder;

public sealed class PlaceOrderHandler
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;

    public PlaceOrderHandler(IOrderRepository orderRepository, IUnitOfWork unitOfWork)
    {
        _orderRepository = orderRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken cancellationToken)
    {
        var order = Order.Create(command.CustomerId, command.Items);

        await _orderRepository.AddAsync(order, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return order.Id;
    }
}

// Ordering.Infrastructure/Persistence/Repositories/OrderRepository.cs
namespace Ordering.Infrastructure.Persistence.Repositories;

public sealed class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _dbContext;

    public OrderRepository(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public Task AddAsync(Order order, CancellationToken cancellationToken = default)
        => _dbContext.Orders.AddAsync(order, cancellationToken).AsTask();

    public Task<Order?> GetByIdAsync(Guid orderId, CancellationToken cancellationToken = default)
        => _dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);
}

The handler has no dependency on Entity Framework directly, which keeps application logic stable even if persistence technology changes later.

Conclusion

Onion architecture is a strong option when long-term clarity and domain integrity are priorities. It adds structure, but that structure can pay off when systems grow and technical choices evolve.

References