Onion Architecture

Onion Architecture

"The diagram at the left shows the Onion Architecture. The fundamental rule is that all code can depend on layers more central, but code cannot depend on layers further out from the core." — Jeffrey Palermo, 2008

Intent

Onion Architecture is Jeffrey Palermo's 2008 formulation of the dependency inversion principle applied at the application level. Palermo introduced Onion Architecture as an evolution of Hexagonal Architecture, explicitly specifying the internal structure of the application core that Hexagonal deliberately left unspecified.

Onion's unique contribution: the explicit separation of Domain Services (domain logic that does not belong to one entity — e.g., PricingService, ShippingCalculator) from Application Services (use case orchestration — e.g., PlaceOrderService). Clean Architecture collapses both into the "Use Cases" layer. Hexagonal Architecture prescribes no internal layering at all. Onion added DDD-style internals to what Hexagonal left unspecified.

Clean Architecture, Hexagonal Architecture, and Onion Architecture express the same foundational principle — dependency inversion toward the domain — with different vocabularies. Three separate notes exist because the vocabulary differences matter in practice: teams encounter these patterns by name in codebases and must work in the specific terminology already in use.

When NOT to Use

  • Small applications or CRUD systems without complex domain logic — the Domain Services / Application Services distinction creates overhead with no benefit
  • Teams without familiarity with DDD tactical patterns (Aggregate, Domain-Events, Repository)
  • Prototypes or scripts

When to Use

  • Systems where the domain model is complex and must be protected from infrastructure concerns
  • Teams already using DDD patterns — Aggregate, Domain-Events, Repository — where Onion's explicit ring model aligns with the team's existing vocabulary
  • When persistence ignorance is a hard requirement — repository interfaces must live in the domain layer, with no domain object knowing about databases

How It Works

Onion Architecture organises code into four concentric rings (inner to outer):

  1. Domain Model (innermost) — entities, value objects, domain events; no external dependencies whatsoever. This ring owns the repository interfaces — e.g., OrderRepository is defined here, with its entity.
  2. Domain Services — domain logic that operates on the domain model but does not belong to one entity (e.g., PricingService calculating totals across multiple OrderItem objects, ShippingCalculator applying business rules to an Order). No infrastructure dependencies.
  3. Application Services — use case orchestration; the outermost domain-facing layer. An application service calls domain services and repositories, coordinates transactions, and translates between the domain model and the outside world.
  4. Infrastructure / UI / Tests (outermost) — all implementations of interfaces defined in inner rings: JpaOrderRepository implementing OrderRepository, HTTP controllers, message consumers, test doubles.

Repository interface placement (Palermo's rule): Repository interfaces belong in the Domain Model ring (inner). Implementations belong in Infrastructure (outer ring). This is the structural enforcement of persistence ignorance — the domain model knows what it needs (an OrderRepository) but has zero knowledge of how it is implemented. See Repository for the detailed pattern.

+------------------------------------------------+
|  Infrastructure / UI / Tests (outer)           |
|  +------------------------------------------+ |
|  |  Application Services (use case layer)    | |
|  |  +------------------------------------+   | |
|  |  |  Domain Services                  |   | |
|  |  |  +------------------------------+ |   | |
|  |  |  |      Domain Model            | |   | |
|  |  |  | (entities, value objects,    | |   | |
|  |  |  |  domain events)              | |   | |
|  |  |  +------------------------------+ |   | |
|  |  +------------------------------------+   | |
|  +------------------------------------------+ |
+------------------------------------------------+
           All dependencies point inward -->
Confusion with Clean and Hexagonal

All three architectures express the same foundational principle — dependency inversion toward the domain — with different vocabularies.

DimensionHexagonal (Cockburn, 2005)Onion (Palermo, 2008)Clean (Martin, 2012)
Layer NamesPort / Adapter / Application CoreDomain Model / Domain Services / Application Services / InfrastructureEntities / Use Cases / Interface Adapters / Frameworks & Drivers
Dependency Rule Statement"Outside actors connect via ports; the application core has no outward dependencies""All coupling toward the centre; outer layers depend on inner layers, never the reverse""Source code dependencies can only point inward. Nothing in an inner circle can know anything about something in an outer circle."
What It Prescribes Inside the CoreNothing — Hexagonal does not specify the internal structure of the application coreExplicit separation of Domain Services from Application ServicesExplicit separation of Entities (enterprise-wide rules) from Use Cases (application-specific rules)

Architecture Diagram

Onion-Architecture-diagram.excalidraw

TypeScript Example

Folder layout:

src/
  domain/
    model/          # Domain Model (entities, value objects, repository interfaces)
      Order.ts
      OrderRepository.ts    # Repository interface DEFINED HERE
    services/       # Domain Services
      PricingService.ts
  application/
    services/       # Application Services (use cases)
      PlaceOrderService.ts
  infrastructure/   # Outer ring — implementations
    persistence/
      PostgresOrderRepository.ts   # Implements domain/model/OrderRepository.ts
    http/

Domain Services vs Application Services pattern:

// Onion Architecture — TypeScript
 
// src/domain/services/PricingService.ts
// DOMAIN SERVICE — pure domain logic, no orchestration, no infrastructure
export class PricingService {
  calculateTotal(items: OrderItem[]): Money {
    return items.reduce((sum, item) => sum.add(item.price), Money.zero());
  }
}
 
// src/application/services/PlaceOrderService.ts
// APPLICATION SERVICE — orchestrates domain objects, calls repository port
import { PricingService } from '../../domain/services/PricingService';
import { OrderRepository } from '../../domain/model/OrderRepository';
 
export class PlaceOrderService {
  constructor(
    private readonly pricing: PricingService,
    private readonly orders: OrderRepository
  ) {}
 
  async placeOrder(command: PlaceOrderCommand): Promise<void> {
    const total = this.pricing.calculateTotal(command.items);
    const order = new Order(command.customerId, command.items, total);
    await this.orders.save(order);
  }
}

Java Example

Package layout:

com.app/
  domain/
    model/
      Order.java
      OrderRepository.java     # Interface DEFINED HERE (not in application/ or infrastructure/)
    services/
      PricingService.java
  application/
    services/
      PlaceOrderService.java
  infrastructure/
    persistence/
      JpaOrderRepository.java  # Implements domain.model.OrderRepository
    web/

Domain Services vs Application Services pattern:

// Onion Architecture — Java
 
// domain/services/PricingService.java — pure domain logic
public class PricingService {
    public Money calculateTotal(List<OrderItem> items) {
        return items.stream()
            .map(OrderItem::getPrice)
            .reduce(Money.zero(), Money::add);
    }
}
 
// application/services/PlaceOrderService.java — use case orchestration
public class PlaceOrderService {
    private final PricingService pricingService;
    private final OrderRepository orderRepository;
 
    public PlaceOrderService(PricingService pricingService, OrderRepository orderRepository) {
        this.pricingService = pricingService;
        this.orderRepository = orderRepository;
    }
 
    public void placeOrder(PlaceOrderCommand command) {
        Money total = pricingService.calculateTotal(command.items());
        Order order = new Order(command.customerId(), command.items(), total);
        orderRepository.save(order);
    }
}

Enforcement Tooling

Install:

# TypeScript — dependency-cruiser
npm install --save-dev dependency-cruiser
 
# Java — ArchUnit (Maven)
# com.tngtech.archunit:archunit-junit5:1.3.0

TypeScript — dependency-cruiser ring boundary rule:

// .dependency-cruiser.js
{
  forbidden: [{
    name: "no-domain-to-application",
    from: { path: "^src/domain" },
    to:   { path: "^src/application" }
  }, {
    name: "no-inner-to-infrastructure",
    from: { path: "^src/(domain|application)" },
    to:   { path: "^src/infrastructure" }
  }]
}

Java — ArchUnit:

@AnalyzeClasses(packages = "com.app")
class OnionArchitectureTest {
    @ArchTest
    static final ArchRule domainMustNotDependOnApplication =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..application..");
 
    @ArchTest
    static final ArchRule noInnerLayersDependOnInfrastructure =
        noClasses().that().resideInAnyPackage("..domain..", "..application..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infrastructure..");
}
Repository Interface Placement Pitfall

In Onion Architecture, the repository interface (e.g., OrderRepository) must be defined in the Domain Model ring (innermost), NOT in the Application Services ring. Placing it in application/ instead of domain/model/ breaks persistence ignorance — the domain model is not truly isolated.

Warning sign: A domain/ folder that imports from application/ or infrastructure/, or an application/ folder that defines repository interfaces that belong with the entities they serve in domain/model/.

  • Clean-Architecture — Robert C. Martin's 2012 formulation; collapses Domain Services and Application Services into a single "Use Cases" layer; same dependency rule with different vocabulary
  • Hexagonal-Architecture — Alistair Cockburn's 2005 formulation; provides Port/Adapter vocabulary but leaves the internal structure of the core unspecified — Onion fills that gap
  • Adapter-Pattern — GoF structural pattern; Onion's infrastructure implementations are Adapters between inner-ring interfaces and external systems
  • Repository — the canonical pattern for the repository interface that lives in the Domain Model ring; implementation lives in Infrastructure
  • Bounded-Context — each Onion application naturally maps to a bounded context; domain model ring holds the bounded context's core entities
  • Domain-Events — live in the Domain Model ring (innermost); raised by entities and aggregates, consumed by application services or infrastructure
  • Aggregate — lives in the Domain Model ring; the consistency boundary that owns invariants and raises domain events
  • Value-Object — lives in the Domain Model ring; immutable descriptors used by entities and aggregates

Sources