Onion Architecture

Keep your domain at the core, push infrastructure to the edges, and enforce a single rule: dependencies always point inward

Introduction

Onion Architecture is a layered software design pattern coined by Jeffrey Palermo in 2008. It organizes code into concentric rings, placing the Domain at the very center and pushing infrastructure details (databases, HTTP frameworks, message brokers) to the outermost ring. At its core, this pattern just follows one simple rule: high-level components must not depend on low-level components, simple as that. The non-negotiable consequence is that all source-code dependencies point inward: outer layers know about inner layers, but inner layers never import from outer ones.

The practical payoff is a domain that is fully testable in isolation, business rules that are insulated from framework churn, and an architecture where swapping a database or delivery mechanism requires only changes in the outermost ring.

Quick Navigation

1. The Four Layers

Domain, Application, Infrastructure, Presentation

2. The Dependency Rule

Why imports must always point inward

3. Key Concepts

Ports, aggregates, value objects, DI

4. When to Use / Avoid

Fit criteria and anti-patterns

5. Pros and Cons

Trade-offs at a glance

6. Related Patterns

Hexagonal, Clean Architecture, N-Tier

1. The Four Layers

Onion Architecture organizes code into four concentric rings. Each ring may only depend on the rings closer to the center. The diagram below shows the layers from innermost to outermost, with the direction that imports are allowed to flow.

Onion Architecture - Layer Overview

DomainCore (no deps)ApplicationUse Cases / DTOsInfrastructureRepos / AdaptersPresentationHTTP / CLI / Taskdepends onimplements portscalls use casesinjects impls

Figure 1: Dependency direction: all arrows point toward the Domain. The composition root (Presentation) wires concrete infrastructure into use cases.

Domain Layer (innermost)

The heart of the application. Contains everything about what the business does, with zero external dependencies.

  • Entities - Objects with identity and lifecycle (e.g., Order, Product)
  • Value Objects - Immutable, equality-by-value types (e.g., Money, OrderStatus)
  • Repository Ports - Abstract interfaces defining data access contracts (IOrderRepository)
  • Domain Services - Cross-entity business logic that does not belong to a single entity
  • Domain Exceptions - Business-rule violations expressed as typed errors
Application Layer

Orchestrates domain objects to fulfill a single user intent. Depends on the Domain, never on Infrastructure.

  • Use Cases - One class per operation (e.g., PlaceOrderUseCase, CancelOrderUseCase)
  • DTOs - Data Transfer Objects that cross layer boundaries (OrderDTO, PlaceOrderCommand)
  • Mappers - Convert domain entities to DTOs for callers outside the layer
Infrastructure Layer

Provides concrete implementations for the domain's repository ports. This is where databases, ORMs, and external APIs live.

  • Repository implementations - e.g., PostgresOrderRepository implements IOrderRepository
  • External service adapters - Email, payment gateway, third-party API clients
  • ORM / DB configuration - SQLAlchemy models, migrations, connection pooling
Presentation Layer (outermost)

Accepts input from the outside world and hands it to the Application layer. Also serves as the composition root: the one place that instantiates all concrete types and wires the whole application together.

  • HTTP controllers / REST API - FastAPI routes, Django views
  • CLI commands - Entry points for batch or admin scripts
  • Composition root - Dependency injection wiring (main.py)

2. The Dependency Rule

The dependency rule is the single architectural invariant that makes Onion Architecture work: source-code dependencies can only point inward. An outer layer may import from any inner layer, but an inner layer must never import from an outer one.

Allowed import directions
  • Application can import from Domain
  • Infrastructure can import from Domain (to implement its interfaces)
  • Presentation can import from Application and Infrastructure
Forbidden import directions
  • Domain must never import from Application, Infrastructure, or Presentation
  • Application must never import from Infrastructure or Presentation
  • Infrastructure must never import from Application or Presentation

The Order entity below illustrates this perfectly. Despite being the central aggregate root used everywhere in the application, it only imports from inside the domain itself:

# service/domain/entities/order.py
# The Domain layer has ZERO external dependencies.
# It imports only from within the domain itself.

from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import List

from service.domain.entities.order_item import OrderItem       # <- domain
from service.domain.exceptions import InvalidOrderTransitionError  # <- domain
from service.domain.value_objects.money import Money           # <- domain
from service.domain.value_objects.order_status import OrderStatus  # <- domain

@dataclass
class Order:
    id: str
    customer_id: str
    items: List[OrderItem] = field(default_factory=list)
    status: OrderStatus = OrderStatus.PENDING
    total: Money = field(default_factory=Money.zero)
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    def transition_to(self, new_status: OrderStatus) -> None:
        if not self.status.can_transition_to(new_status):
            raise InvalidOrderTransitionError(
                f"Cannot transition from {self.status.value} to {new_status.value}"
            )
        self.status = new_status
        self.updated_at = datetime.now(timezone.utc)

The trick that allows Infrastructure to implement Domain interfaces without the Domain knowing about Infrastructure is the repository port: an abstract class defined in the Domain that Infrastructure implements from the outside:

# service/domain/repositories/i_order_repository.py
# The port (interface) lives in the Domain layer.
# It defines WHAT operations are needed, not HOW they are implemented.

from abc import ABC, abstractmethod
from typing import List, Optional
from service.domain.entities.order import Order

class IOrderRepository(ABC):

    @abstractmethod
    def find_by_id(self, order_id: str) -> Optional[Order]:
        ...

    @abstractmethod
    def find_by_customer_id(self, customer_id: str) -> List[Order]:
        ...

    @abstractmethod
    def save(self, order: Order) -> None:
        ...

3. Key Concepts

The Domain layer defines ports: abstract interfaces that describe what data-access operations the application needs. The Infrastructure layer provides adapters: concrete classes that implement those interfaces against a real database, an in-memory store, or any other backing store.

Because use cases only depend on the port (the interface), the backing store can be swapped entirely without touching Application or Domain code. For tests, you can inject a fast in-memory implementation; for production, you inject the Postgres one.

Example from the demo: IOrderRepository (port, in domain) is implemented by both InMemoryOrderRepository (test adapter, in infrastructure) and would be implemented by a futurePostgresOrderRepository without any change to use-case code.

An aggregate root is the single entry point through which a cluster of related domain objects (an aggregate) is accessed and modified. External code never directly mutates sub-entities; all changes go through the root.

In the demo, Order is the aggregate root. Status transitions are enforced by Order.transition_to(), which validates the move against allowed transitions and raises InvalidOrderTransitionError if the sequence is illegal. OrderItem objects are owned by the Order and are not accessed independently.

Why it matters: aggregate boundaries define consistency guarantees. Everything within one aggregate is updated in a single transaction; cross-aggregate communication uses domain events or use-case orchestration.

A value object is defined entirely by its attributes, carries no identity, and is immutable after creation. Two value objects with the same attributes are considered equal.

In the demo:

  • Money - a frozen dataclass with amount and currency fields. Addition creates a new Money instance; the original is never mutated.
  • OrderStatus - an Enum that also encodes the valid transition graph (e.g., PENDING can move to CONFIRMED or CANCELLED, but not to DELIVERED directly).
Why it matters: immutability eliminates an entire class of bugs caused by shared mutable state. Value objects are safe to pass around freely.

A domain service encapsulates business logic that involves multiple entities but does not naturally belong to any single one of them.

In the demo, OrderPricingService.calculate_total(items) sums OrderItem subtotals to produce an Order total. This logic belongs in the domain (it is a business rule about pricing), but it spans multiple OrderItem instances, so it sits in a dedicated service rather than inside any single entity.

Data Transfer Objects (DTOs) are plain data containers used to move information across layer boundaries without exposing domain internals. They carry no business logic.

Commands (e.g., PlaceOrderCommand) carry input from the caller into the Application layer. Read DTOs (e.g., OrderDTO) carry output back to the caller. Mappers in application/_mappers.py handle the entity-to-DTO conversion so that domain objects never escape the Application boundary.

Why it matters: if you expose raw domain entities to controllers or API responses, a change in the domain model breaks the external contract. DTOs decouple the two.

Use cases receive all their dependencies through constructors (constructor injection). They declare what they need (domain ports), but do not instantiate anything themselves.

The composition root is the single location in the application, typically the entry-point file (main.py), where all concrete infrastructure objects are instantiated and injected into the use cases that need them. This makes the dependency graph explicit and easy to swap.

# service/main.py: the Composition Root
# The ONLY place where all layers are wired together.
# Infrastructure implementations are injected into Application use cases.

from service.infrastructure.repositories.in_memory_order_repository import InMemoryOrderRepository
from service.infrastructure.repositories.in_memory_product_repository import InMemoryProductRepository
from service.infrastructure.repositories.in_memory_customer_repository import InMemoryCustomerRepository
from service.domain.services.order_pricing_service import OrderPricingService
from service.application.use_cases.place_order_use_case import PlaceOrderUseCase

def execute() -> None:
    # Concrete infrastructure implementations
    order_repo    = InMemoryOrderRepository()
    product_repo  = InMemoryProductRepository()
    customer_repo = InMemoryCustomerRepository()
    pricing       = OrderPricingService()

    # Injected into use cases, use cases only see the domain interfaces
    place_order = PlaceOrderUseCase(
        order_repository=order_repo,
        product_repository=product_repo,
        customer_repository=customer_repo,
        pricing_service=pricing,
    )

    task = EcommerceOrderTask(place_order, ...)
    task.run()

Here is the full PlaceOrderUseCase.execute() from the demo, showing how the Application layer orchestrates domain objects using only the port interfaces it received via constructor injection:

# service/application/use_cases/place_order_use_case.py
# The Application layer orchestrates domain objects.
# It depends on Domain interfaces (ports), never on Infrastructure.

class PlaceOrderUseCase:

    def __init__(
        self,
        order_repository: IOrderRepository,      # <- domain port
        product_repository: IProductRepository,   # <- domain port
        customer_repository: ICustomerRepository, # <- domain port
        pricing_service: OrderPricingService,     # <- domain service
    ) -> None:
        self._order_repo = order_repository
        self._product_repo = product_repository
        self._customer_repo = customer_repository
        self._pricing = pricing_service

    def execute(self, command: PlaceOrderCommand) -> OrderDTO:
        customer = self._customer_repo.find_by_id(command.customer_id)
        if customer is None:
            raise CustomerNotFoundError(...)

        # Pass 1: validate all stock before mutating anything
        resolved = []
        for item_cmd in command.items:
            product = self._product_repo.find_by_id(item_cmd.product_id)
            if not product.has_sufficient_stock(item_cmd.quantity):
                raise InsufficientStockError(...)
            resolved.append((product, item_cmd.quantity))

        # Pass 2: all items validated - safe to decrement stock
        order_items = []
        for product, quantity in resolved:
            product.decrement_stock(quantity)
            order_items.append(OrderItem(...))

        total = self._pricing.calculate_total(order_items)
        order = Order(id=str(uuid.uuid4()), customer_id=customer.id, items=order_items)
        order.set_total(total)
        self._order_repo.save(order)
        return order_to_dto(order)

4. When to Use and When to Avoid

Use Onion Architecture when...
  • Domain logic is complex - multiple entities, intricate business rules, and state transitions that need to be protected from infrastructure leakage.
  • The application will live a long time - frameworks and databases change; an onion design makes those migrations surgical rather than systemic.
  • High testability is required - you want to unit-test business rules without spinning up a database or HTTP server.
  • You are practicing DDD - aggregates, value objects, and domain events map naturally onto the domain layer.
  • Multiple delivery mechanisms exist - the same use cases need to be triggered by a REST API, a queue consumer, and a cron job.
Avoid Onion Architecture when...
  • The app is mostly CRUD - if the bulk of your work is read-a-row / write-a-row with no real business logic, adding interfaces, mappers, and DTOs per entity is ceremony with no payoff.
  • It is a short-lived prototype - the overhead is not justified for throwaway code.
  • The team is new to DDD - the learning curve is real. A team unfamiliar with aggregates and ports will produce a tangled imitation rather than a clean onion.
  • Performance is critical in tight loops - every additional abstraction layer adds indirection. In hot paths this can matter.

5. Pros and Cons

AdvantagesTrade-offs
Domain logic is unit-testable in complete isolation - no database, no HTTP server neededSignificant boilerplate: one interface, one or more implementations, DTOs, and mappers per entity
Infrastructure is fully swappable - switching from PostgreSQL to DynamoDB touches only the Infrastructure layerSteep learning curve for developers new to DDD and the ports-and-adapters pattern
Business rules are centralized in the domain and Application layers, never scattered across controllers or ORM hooksCan feel over-engineered for simple CRUD applications where the "domain" is just a thin wrapper over a table
Dependency direction is explicit and enforced by the codebase structure, making violations easy to detectThe composition root grows in complexity as the number of use cases and repositories increases
Aligns naturally with Domain-Driven Design concepts (aggregates, value objects, domain services)Extra indirection in hot paths may have a performance cost if not profiled carefully

6. Onion vs. Related Patterns

Onion Architecture belongs to a family of patterns that all share the same core idea - isolate the domain from infrastructure. The differences are mostly in emphasis and naming.

Hexagonal / Ports and Adapters

Alistair Cockburn, 2005

Same inward-dependency rule. The "hexagon" metaphor emphasizes that the application has multiple ports (interfaces) for different interaction types: a REST port, a message queue port, a test port. Onion Architecture adds explicit ring naming (Domain, Application, Infrastructure) but is architecturally equivalent.

Clean Architecture

Robert C. Martin (Uncle Bob), 2012

Extends the same concentric-ring model with four named rings: Entities, Use Cases, Interface Adapters, and Frameworks. Onion Architecture is essentially a variant with slightly different ring names. Clean Architecture places more emphasis on the use-case ring as its own named layer.

Traditional N-Tier / Layered

Classic top-down approach

In a standard three-tier architecture (Presentation, Business, Data), the database is at the bottom and the business layer often imports from the data layer directly. Onion Architecture inverts this: the domain is at the center and never imports from the database layer. Infrastructure implements domain interfaces, not the other way around.

Practical guidance

In practice, teams often blend these terms. If someone says their project uses Clean Architecture or Hexagonal Architecture, expect to see the same inward-dependency rule and repository ports, it is the same idea under a different label. The naming is less important than consistently enforcing the dependency rule.

Key Takeaways

  • Domain is the center; infrastructure is the rim - all source-code dependencies must point inward, never outward.
  • Repository interfaces (ports) live in the Domain - concrete implementations live in Infrastructure. The domain never knows how data is stored.
  • Use cases orchestrate domain objects - they hold business workflow logic but do not contain business rules, which stay in entities and domain services.
  • The composition root is the single wiring point - it is the only file that touches all layers simultaneously. Everywhere else, code only sees what it needs.
  • High testability is the main payoff - with in-memory repository adapters and constructor injection, the Domain and Application layers can be fully tested without a real database or HTTP server.
  • Best suited for complex, long-lived domains - for simple CRUD, the ceremony outweighs the benefit. Adopt it when business rules justify the investment.

Bonus: Working Code Demo

A complete Python implementation of Onion Architecture is available as a reference project. It models a small e-commerce order domain and demonstrates every concept covered in this lesson with runnable, tested code.

The project includes:

  • Domain layer - Order aggregate root, OrderItem, Product, Customer entities, Money and OrderStatus value objects, IOrderRepository / IProductRepository / ICustomerRepository ports, and OrderPricingService
  • Application layer - four use cases (PlaceOrderUseCase with two-pass stock validation, CancelOrderUseCase, GetOrderUseCase, ListCustomerOrdersUseCase), command/DTO classes, and entity-to-DTO mappers
  • Infrastructure layer - in-memory repository implementations for all three domain ports, ready to be swapped for real database adapters
  • Composition root - service/main.py wires all layers; entry point is service/task.py running an EcommerceOrderTask scenario
  • Test suite - unit and integration tests with coverage reporting via python manager.py run-tests