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
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.,
PostgresOrderRepositoryimplementsIOrderRepository - 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
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
| Advantages | Trade-offs |
|---|---|
| Domain logic is unit-testable in complete isolation - no database, no HTTP server needed | Significant 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 layer | Steep 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 hooks | Can 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 detect | The 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 -
Orderaggregate root,OrderItem,Product,Customerentities,MoneyandOrderStatusvalue objects,IOrderRepository/IProductRepository/ICustomerRepositoryports, andOrderPricingService - Application layer - four use cases (
PlaceOrderUseCasewith 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.pywires all layers; entry point isservice/task.pyrunning anEcommerceOrderTaskscenario - Test suite - unit and integration tests with coverage reporting via
python manager.py run-tests