Blog/Onion vs Hexagonal Architecture: A Technical Comparison
architecturedomain-driven-designhexagonal-architectureonion-architecture

Onion vs Hexagonal Architecture: A Technical Comparison

April 25, 2024·14 min read·by Bishwambhar Sen
A comparative layout showing concentric rings of Onion architecture next to the polygon boundaries of Hexagonal ports and adapters.

Concept

Modern software engineering prioritizes maintainability, testability, and adaptability. To achieve these properties, architects must decouple business rules from volatile external factors: frameworks, database engines, user interfaces, and third-party integrations. Two prominent architectural styles address this challenge:

  1. Hexagonal Architecture (also known as the Ports and Adapters pattern, proposed by Alistair Cockburn in 2005).
  2. Onion Architecture (proposed by Jeffrey Palermo in 2008).

While both architectures share the same foundational goal—isolating the application core from the outer infrastructure—they model the boundaries and dependencies of the system using distinct mental representations.

Hexagonal Architecture (Ports & Adapters)

Hexagonal Architecture conceptualizes the application core as a hexagon. The shape is chosen not for geometric reasons, but to emphasize that the system has multiple sides, each representing a different interface with the outside world.

The core interacts with external systems exclusively through two components:

  • Ports: These are interfaces defining how the outside world can interact with the core, or how the core must interact with external systems.
  • Adapters: These are implementations of the ports that map external technology-specific inputs/outputs to the technology-neutral formats understood by the core.

Hexagonal classifies interactions into two categories:

  1. Primary/Driving Ports: Inward-facing entry points (e.g., HTTP Controllers, CLI commands, message consumer entry points) that trigger actions inside the core.
  2. Secondary/Driven Ports: Outward-facing exit points (e.g., database repositories, payment gateways, mail dispatch systems) that the core invokes.

Onion Architecture

Onion Architecture uses concentric circles to represent the system structure. The core rule of the Onion is the Dependency Rule: dependencies always point inward.

The application is structured into concentric layers:

  • Domain Model (Center): Represents the state and behavior of the business entities. This layer has zero external dependencies (no frameworks, no packages).
  • Domain Services: Encapsulates business logic that spans multiple entities.
  • Application Services (Use Cases): Orchestrates the execution of use cases, coordination of domain entities, and transaction boundaries.
  • Infrastructure / UI / Tests (Outer Ring): Implements database operations, UI rendering, network routing, and integration tests.

Structural Differences

The primary differences between the two styles lie in prescription and internal structure:

  • Internal Layering: Hexagonal Architecture treats the interior of the hexagon as a single "Application Core" black box, prescribing no specific layout within it. Onion Architecture explicitly mandates concentric layers inside the core, defining a strict dependency flow from Application Services down to the central Domain Model.
  • Directional Focus: Hexagonal focuses on separating the Inside from the Outside using adapters. Onion focuses on the Dependency Inversion Principle, specifically detailing how infrastructure must depend on abstractions defined by the application layer.

Constraints

Adopting either architectural model requires managing constraints related to performance, compilation boundaries, and transaction coordination:

1. Project Organization and Assembly Separation

To enforce the dependency rule at build time, the system must be split into separate physical projects or assemblies. In a C# environment:

  • Mpc.Domain: No references to third-party libraries or EF Core.
  • Mpc.Application: References Mpc.Domain. Defines the repository interfaces.
  • Mpc.Infrastructure: References Mpc.Application and contains the concrete database contexts and migrations. If a developer attempts to reference an infrastructure class directly from the domain, the build will fail, preventing architectural drift.

2. The Data Mapping Tax

As data flows across boundaries, it must be mapped to prevent database schema concerns from polluting the domain. This requires maintaining separate classes:

  • Database Entities: Optimized for storage, indexes, and ORM tracking (e.g., EF Core annotations).
  • Domain Aggregates: Optimized for business invariant validation.
  • Data Transfer Objects (DTOs): Optimized for API payload serialization. Mapping between these types (e.g., using Mapper classes or manually) increases CPU usage and memory allocations, creating a latency trade-off.

3. Cross-Boundary Transactions

Because the database adapter sits in the outer infrastructure layer, and transaction boundaries are managed by the application layer, the system cannot directly commit transactions using database-specific tokens (like EF Core's IDbContextTransaction). Instead, the system must abstract the transaction lifecycle using a technology-neutral Unit of Work interface defined in the application core.


Trade-offs

Decoupling vs. Development Velocity

Decoupling frameworks allows developers to swap out databases or UI frameworks with minimal impact on the business logic. However, in reality, switching database engines (e.g., from SQL Server to PostgreSQL) is a rare business requirement. The real benefit is testability: business logic can be unit-tested in isolation without starting a database. The trade-off is the significant amount of boilerplate code (interfaces, adapters, mappings) required for even simple CRUD operations.

Strict vs. Relaxed Layer Traversal

  • Strict Concentric Traversal: Layer $N$ can only call Layer $N-1$. For example, an HTTP controller must call an Application Service, which calls a Domain Service, which modifies an Entity. This maintains clean code paths but leads to "pass-through" layers that simply delegate calls downward, increasing boilerplate.
  • Relaxed Concentric Traversal: Outer layers can call any inner layer directly. For example, an HTTP controller can query the Domain Model directly for read-only views, bypassing the Application Service layer. This increases speed but makes tracing side effects more difficult.

Code

The following implementation illustrates how to structure a clean boundary in C#, combining the Concentric Dependency Rule of Onion Architecture with the Ports/Adapters separation of Hexagonal Architecture.

1. The Core Domain Layer (Center Circle - No Dependencies)

using System;

namespace Mpc.Architecture.Core.Domain
{
    // Pure Domain Aggregate Root
    public class Order
    {
        public Guid Id { get; }
        public string CustomerId { get; }
        public decimal TotalAmount { get; }
        public bool IsShipped { get; private set; }

        public Order(Guid id, string customerId, decimal totalAmount)
        {
            if (totalAmount <= 0)
                throw new ArgumentException("Total amount must be positive.", nameof(totalAmount));

            Id = id;
            CustomerId = customerId;
            TotalAmount = totalAmount;
            IsShipped = false;
        }

        public void Ship()
        {
            if (IsShipped)
                throw new InvalidOperationException("Order is already shipped.");

            IsShipped = true;
        }
    }
}

2. The Application Layer (Inner Ring - Defines Ports/Interfaces)

using System;
using System.Threading;
using System.Threading.Tasks;
using Mpc.Architecture.Core.Domain;

namespace Mpc.Architecture.Core.Application
{
    // The Port (Secondary / Driven Port in Hexagonal)
    public interface IOrderRepository
    {
        Task<Order> GetByIdAsync(Guid id, CancellationToken cancellationToken);
        Task SaveAsync(Order order, CancellationToken cancellationToken);
    }

    public record ShipOrderCommand(Guid OrderId);

    // Application Service (Onion Application layer)
    public class OrderUseCaseOrchestrator
    {
        private readonly IOrderRepository _repository;

        public OrderUseCaseOrchestrator(IOrderRepository repository)
        {
            _repository = repository ?? throw new ArgumentNullException(nameof(repository));
        }

        public async Task ExecuteShipOrderAsync(ShipOrderCommand command, CancellationToken cancellationToken)
        {
            // 1. Fetch order using the Repository Port
            var order = await _repository.GetByIdAsync(command.OrderId, cancellationToken);
            if (order == null)
            {
                throw new InvalidOperationException($"Order {command.OrderId} not found.");
            }

            // 2. Delegate execution to Domain Model (State transition)
            order.Ship();

            // 3. Persist changes back using the Repository Port
            await _repository.SaveAsync(order, cancellationToken);
        }
    }
}

3. The Infrastructure Layer (Outer Ring - Implements Adapter)

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using Mpc.Architecture.Core.Domain;
using Mpc.Architecture.Core.Application;

namespace Mpc.Architecture.Infrastructure.Adapters
{
    // Concrete Adapter implementing the Driven Port
    public class InMemoryDatabaseOrderAdapter : IOrderRepository
    {
        private readonly ConcurrentDictionary<Guid, OrderDbModel> _db = new();

        public Task<Order> GetByIdAsync(Guid id, CancellationToken cancellationToken)
        {
            if (!_db.TryGetValue(id, out var dbModel))
            {
                return Task.FromResult<Order>(null);
            }

            // Map Database Model to Domain Aggregate
            var domainOrder = new Order(dbModel.Id, dbModel.CustomerId, dbModel.TotalAmount);
            if (dbModel.IsShipped)
            {
                domainOrder.Ship();
            }

            return Task.FromResult(domainOrder);
        }

        public Task SaveAsync(Order order, CancellationToken cancellationToken)
        {
            // Map Domain Aggregate back to Database Model
            var dbModel = new OrderDbModel
            {
                Id = order.Id,
                CustomerId = order.CustomerId,
                TotalAmount = order.TotalAmount,
                IsShipped = order.IsShipped
            };

            _db[order.Id] = dbModel;
            return Task.CompletedTask;
        }
    }

    // Volatile database-specific schema representation
    internal class OrderDbModel
    {
        public Guid Id { get; set; }
        public string CustomerId { get; set; }
        public decimal TotalAmount { get; set; }
        public bool IsShipped { get; set; }
    }
}

Architectural Layout and Dependency Direction

The comparison below demonstrates the structural relationship: Hexagonal separates the application core using external Adapters, while Onion enforces concentric dependency rings targeting the Domain model:

graph LR
    subgraph Hexagonal Architecture
        AdapterIn[HTTP Controller Adapter] -->|Call| PortIn[Driving Port / Interface]
        PortIn -->|Execute| Core[Application Core]
        Core -->|Invoke| PortOut[Driven Port / Interface]
        PortOut -->|Implement| AdapterOut[DB Repository Adapter]
    end

    subgraph Onion Architecture
        Infra[Infrastructure / UI] -->|Depends Inward| App[Application Services]
        App -->|Depends Inward| DomSvc[Domain Services]
        DomSvc -->|Depends Inward| DomModel((Domain Model))
    end
    
    style DomModel fill:#d4a359,stroke:#0d101a,color:#0d101a
    style Core fill:#0d101a,stroke:#d4a359,stroke-width:2px,color:#fff

Further Reading