What Is Onion Architecture? Layers and Dependency Rules Explained
Onion Architecture is a software architecture pattern that organizes application code into concentric layers centered on the domain model. Its fundamental rule is that dependencies must point inward: code in an outer layer may depend on an inner layer, but code in an inner layer must not depend on an outer layer. Business rules should not require knowledge of HTTP frameworks, database drivers, message brokers, or external service SDKs.
Jeffrey Palermo introduced Onion Architecture in 2008 as an approach to reducing coupling in long-lived applications. Instead of treating the database as the foundation of a system, Onion Architecture treats the domain model as the foundation and moves infrastructure toward the edges. This guide explains the layers, dependency rules, domain-driven design concepts, and implementation trade-offs through a practical order management API built with Python, FastAPI, PostgreSQL, and SQLAlchemy.
Table of Contents
- What Is Onion Architecture?
- The Layers of Onion Architecture
- Dependency Rules and Dependency Inversion
- Production Design Example: Order Management API
- Ready-to-Use Example with FastAPI and PostgreSQL
- Project Structure and Dependencies
- Implement the Domain Model and Domain Services
- Implement Application Services and Repository Interfaces
- Implement the PostgreSQL Infrastructure Layer
- Implement FastAPI and Dependency Injection
- Test Business Rules Without Infrastructure
- Run and Verify the Application
- Onion vs. Layered, Hexagonal, and Clean Architecture
- Production Practices and Common Mistakes
- Frequently Asked Questions
- Conclusion
What Is Onion Architecture?
Onion Architecture is an approach to organizing software so that business concepts and behavior remain independent of technology choices. The domain model sits in the center, application-oriented behavior surrounds it, and infrastructure such as databases, external APIs, and web frameworks remains on the outside.
The structure resembles an onion because the application contains several conceptual rings. Each ring may depend on rings closer to the center, but the center must remain unaware of the outer implementation details.
Origins and Core Principles
Jeffrey Palermo introduced Onion Architecture in a series of articles in 2008. His motivation was to address long-term maintainability problems caused by coupling important business behavior to frameworks and persistence technologies that evolve over time.
Palermo later summarized four defining principles:
- The application is organized around an independent object model.
- Inner layers define interfaces, while outer layers provide implementations.
- Dependencies point toward the center.
- The application core can run and be tested independently of infrastructure.
These principles are more important than any particular folder structure or fixed number of layers.
A Python application can follow Onion Architecture without adopting a heavyweight dependency injection framework or reproducing the conventions of an enterprise Java or .NET project.
Problems with Traditional Layered Architecture
Traditional layered applications often organize code into controllers, services, repositories, and database access components. This arrangement separates technical responsibilities, but it can also allow the business layer to depend directly on infrastructure.
For example, consider an order service that receives a SQLAlchemy session, constructs database models, performs business validation, and commits transactions directly.
Such a service may work perfectly for a small application, but its responsibilities are mixed. Testing business rules requires database-related setup, and persistence concerns can become scattered throughout the application.
The problem becomes more visible when business behavior grows. Order validation, pricing, discount rules, inventory reservations, and payment coordination may all become entangled with ORM calls and database transaction details.
Onion Architecture reverses this relationship. The application defines what it needs from persistence, while the infrastructure layer implements those needs using SQLAlchemy, PostgreSQL, or another suitable technology.
This does not mean traditional layered architecture is always wrong. A conventional layered application can also respect dependency inversion. The important distinction is whether the domain model depends on infrastructure or infrastructure depends on the domain model.
The Layers of Onion Architecture
Onion Architecture has no mandatory number of rings, but implementations commonly distinguish the domain model, domain services, application services, and outer infrastructure or presentation concerns.
These layers represent different kinds of responsibilities. Understanding those responsibilities prevents developers from simply moving existing controller and repository code into new directories without actually changing the dependencies.
Domain Model: Entities and Value Objects
The domain model belongs at the center. It expresses important business concepts and the rules that make those concepts valid.
For an order management application, domain entities and value objects might include:
- Order: An identifiable business entity containing ordered items and lifecycle state.
- OrderItem: A product, quantity, and unit price.
- Money: A value object representing an amount and currency.
- OrderStatus: A valid stage in the order lifecycle.
- Business invariants: Rules such as positive quantities and valid state transitions.
The domain model should express behavior, not merely store fields. An Order may calculate its total, reject invalid line items, and enforce constraints before it can be created.
Its code should not import FastAPI, SQLAlchemy, boto3, or a payment provider SDK. The domain should remain usable from a unit test or command-line script without configuring a database or HTTP server.
A useful design question is: if we replaced PostgreSQL or FastAPI tomorrow, would this business rule need to change? If the answer is yes, the business rule may be coupled to the wrong abstraction.
Domain Services and Business Rules
Not every business rule naturally belongs to one entity. Domain services encapsulate domain behavior that operates across multiple concepts or represents a business policy without a natural entity owner.
For example, a discount policy might determine whether an order receives a volume discount based on its item quantities and subtotal.
The discount calculation is a domain concern because it reflects a business rule. It should not require an HTTP request object or a database session.
Domain services can accept entities and value objects, produce domain results, and enforce domain-specific policies.
They should not become a dumping ground for every function with the word "service" in its name. A domain service should represent meaningful domain behavior, while orchestration of persistence and external operations generally belongs in the application layer.
Application Services and Use Cases
Application services implement use cases: actions the system makes available to users, other applications, or background workflows.
Examples include CreateOrder, CancelOrder, GetOrder, and SubmitPayment.
An application service coordinates domain objects and required external capabilities. It may construct entities, invoke domain services, call repository abstractions, and return an application result.
For example, CreateOrder can validate order data, calculate discounts through a domain service, then request persistence through a repository interface.
The use case determines what the operation accomplishes, while the database adapter determines how records are physically stored.
Application services should not return HTTP status codes or throw framework-specific exceptions. Those concerns belong to the presentation layer.
Infrastructure and Presentation Layers
The outer rings contain components that connect application behavior to technical systems.
Typical infrastructure components include:
- PostgreSQL and SQLAlchemy repositories.
- Redis cache integrations.
- External payment or shipping API clients.
- SQS, Kafka, or RabbitMQ publishers.
- Object storage implementations.
- Logging and telemetry integrations.
Presentation components include FastAPI endpoints, GraphQL resolvers, CLI commands, and message-consuming entry points.
These components may depend on the application and domain layers, but domain entities must not import them.
The presentation and infrastructure components are not necessarily arranged as one strict linear stack. Both can reside at the outer boundary and depend on appropriate inner abstractions.
Dependency Rules and Dependency Inversion
The defining feature of Onion Architecture is not that code is divided into directories. It is that source-code dependencies are directed toward the center.
This remains true even when runtime control flows outward from an application service to a database or external API.
Why All Dependencies Point Inward
The domain model should be the most stable part of a business application. Framework implementations, database drivers, deployment systems, and external integrations tend to change for technical or organizational reasons.
If inner-layer code imports outer-layer types, these changes can propagate into the domain.
With inward-pointing dependencies, changes at the edge are less likely to require changes at the center.
The conceptual structure is:
Outermost:
FastAPI / SQLAlchemy / External API Adapters
|
v
Application Services
|
v
Domain Services
|
v
Domain Model
Center
This is a dependency diagram, not a mandatory runtime call sequence. An application service may call a repository interface implemented by infrastructure, even though infrastructure is outside the application core.
The dependency inversion principle makes that possible.
Interfaces, Repository Abstractions, and Boundaries
Suppose the application needs to save an order. If it imports a concrete PostgreSQL repository, the application becomes tied to that infrastructure implementation.
Instead, define an OrderRepository contract inside the application core. The PostgreSQL repository implements that contract from the outside.
The application may call repository.add(order) without knowing whether the implementation uses SQLAlchemy or an in-memory dictionary.
In Python, interfaces can be expressed with typing.Protocol or abstract base classes.
The contract should reflect the application's needs, not the full feature set of the underlying database. Avoid generic repositories that expose arbitrary SQL queries or ORM sessions to callers.
Also avoid assuming all persistence systems have equivalent guarantees. PostgreSQL uniqueness constraints, DynamoDB conditional writes, and eventual consistency have different semantics even if they can support similar application operations.
Dependency Injection and Composition
Dependency injection supplies concrete implementations to code that depends on abstractions.
An application service can accept an OrderRepository through its constructor. In production, the composition root supplies a PostgreSQL implementation. In a unit test, it supplies an in-memory implementation.
For FastAPI, request dependencies can construct application services and configure repository lifetimes.
The composition root is allowed to know about both the application core and infrastructure because its responsibility is to connect them.
Dependency injection does not require a specialized container. Straightforward constructor injection is often sufficient, especially in Python applications with a manageable number of dependencies.
Production Design Example: Order Management API
Consider a backend that receives order creation requests, validates line items, applies volume discounts, stores orders in PostgreSQL, and later integrates with payment and notification services.
The application must remain testable without external infrastructure, reject duplicate order identifiers, handle concurrent requests safely, and allow pricing rules to evolve independently of the web framework.
Architecture and Layer Responsibilities
The domain model contains Order and OrderItem. The domain service contains a DiscountPolicy. The application service implements CreateOrder and GetOrder.
The repository abstraction is defined in the application core, while PostgreSQL provides its implementation.
The FastAPI adapter translates HTTP requests into application inputs and converts application errors into appropriate HTTP responses.
The responsibilities are:
- Domain: Validate order structure, enforce invariants, and calculate base totals.
- Domain services: Apply pricing policies without needing persistence or HTTP infrastructure.
- Application: Coordinate order creation and retrieval.
- Infrastructure: Persist orders using PostgreSQL and translate database records to domain objects.
- Presentation: Expose the operations through HTTP with validated request and response schemas.
Using the same business example across different architecture styles helps reveal the actual implementation differences. Onion Architecture emphasizes the central domain and surrounding inward-dependent layers, rather than treating every integration as an equally important organizational concern.
Transactions, Concurrency, and Failure Handling
Separation of layers does not automatically make a system consistent or fault tolerant. Business transactions still need appropriate database guarantees and recovery strategies.
Two concurrent requests may attempt to create an order with the same identifier. Checking for an existing order before insertion is not sufficient because both requests can observe that the record does not exist.
The production database needs an appropriate uniqueness constraint, and the infrastructure implementation must handle that conflict correctly.
Similarly, a checkout workflow that stores an order and publishes an event has a dual-write problem if the database commit succeeds but publication fails.
For critical workflows, a transactional outbox can persist the business update and a pending event within one database transaction. An independent publisher subsequently delivers the event.
The application layer should express the intended operation and required consistency boundary, while infrastructure implements database transactions and delivery mechanisms.
For payments, durable business state and reconciliation are also necessary. An application must account for the possibility that a provider accepted a charge even when the client experienced a timeout.
The working example below focuses on one atomic persistence operation. More complex workflows should introduce an explicit unit-of-work or transaction boundary instead of allowing independent repositories to commit unrelated operations separately.
Ready-to-Use Example with FastAPI and PostgreSQL
This example builds a small but functional order management API using Onion Architecture. It includes a domain model with business rules, a domain service for discounts, application services, a repository abstraction, a PostgreSQL adapter, and a FastAPI presentation layer.
The example uses Python 3.12, FastAPI, SQLAlchemy 2, PostgreSQL, and pytest. The business tests do not require a database.
Project Structure and Dependencies
Create the following structure. Add empty __init__.py files inside each Python package directory.
onion-orders/
app/
__init__.py
domain/
__init__.py
models.py
services.py
application/
__init__.py
use_cases.py
infrastructure/
__init__.py
database.py
presentation/
__init__.py
api.py
tests/
test_orders.py
requirements.txt
docker-compose.yml
The dependency direction is reflected in the folders: domain code imports only domain or standard-library code; the application imports domain code; and infrastructure and presentation import the inner layers as needed.
Create requirements.txt:
fastapi>=0.115,<1
uvicorn>=0.30,<1
sqlalchemy>=2.0,<3
psycopg[binary]>=3.2,<4
pytest>=8,<10
httpx>=0.27,<1
Use an application lockfile for reproducible production deployments. Compatible version ranges are convenient for learning, but should not replace dependency testing and artifact pinning.
Implement the Domain Model and Domain Services
The domain objects define invariants without importing FastAPI or SQLAlchemy. An order has one or more items, and every item requires a valid SKU, positive quantity, and nonnegative unit price.
The domain service applies a volume discount: orders with at least ten total units receive 5% off their subtotal. This is an illustrative business rule that we can test without involving persistence.
Create app/domain/models.py and app/domain/services.py:
# app/domain/models.py
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderItem:
sku: str
quantity: int
unit_price_cents: int
def __post_init__(self) -> None:
if not self.sku.strip():
raise ValueError("SKU is required")
if type(self.quantity) is not int or self.quantity <= 0:
raise ValueError("Quantity must be positive")
if (
type(self.unit_price_cents) is not int
or self.unit_price_cents < 0
):
raise ValueError("Invalid unit price")
@property
def subtotal_cents(self) -> int:
return self.quantity * self.unit_price_cents
@dataclass(frozen=True)
class Order:
order_id: str
customer_id: str
items: tuple[OrderItem, ...]
def __post_init__(self) -> None:
if not self.order_id.strip():
raise ValueError("Order ID is required")
if not self.customer_id.strip():
raise ValueError("Customer ID is required")
if not self.items:
raise ValueError("Order must contain items")
skus = [item.sku for item in self.items]
if len(skus) != len(set(skus)):
raise ValueError("Duplicate SKUs are not allowed")
@property
def subtotal_cents(self) -> int:
return sum(
item.subtotal_cents
for item in self.items
)
@property
def total_quantity(self) -> int:
return sum(
item.quantity
for item in self.items
)
# app/domain/services.py
from app.domain.models import Order
class DiscountPolicy:
"""Business policy independent of infrastructure."""
DISCOUNT_PERCENT = 5
MINIMUM_QUANTITY = 10
def calculate_total_cents(self, order: Order) -> int:
subtotal = order.subtotal_cents
if order.total_quantity < self.MINIMUM_QUANTITY:
return subtotal
# Integer arithmetic avoids floating-point
# rounding differences for whole cents.
return (
subtotal * (100 - self.DISCOUNT_PERCENT)
) // 100
The entity enforces invariants directly. An Order with no items or repeated SKU identifiers cannot be created as a valid domain object.
The domain service owns the discount policy. Changes to the pricing rule should not require edits to FastAPI endpoints or SQLAlchemy models.
Production monetary models should also account for currency, tax calculations, refunds, and explicit rounding policy. Integer cents are sufficient here to keep the architectural example focused.
Implement Application Services and Repository Interfaces
The application layer coordinates use cases. It defines the repository capability required for persistence and knows nothing about PostgreSQL internals.
Create app/application/use_cases.py:
from dataclasses import dataclass
from typing import Protocol
from app.domain.models import Order, OrderItem
from app.domain.services import DiscountPolicy
class OrderAlreadyExists(Exception):
pass
class OrderNotFound(Exception):
pass
class OrderRepository(Protocol):
def add(
self,
order: Order,
total_cents: int,
) -> bool:
"""Atomically create an order if ID is unused."""
def get(
self,
order_id: str,
) -> tuple[Order, int] | None:
"""Return order and its recorded total."""
@dataclass(frozen=True)
class CreateOrderCommand:
order_id: str
customer_id: str
items: tuple[OrderItem, ...]
@dataclass(frozen=True)
class OrderResult:
order_id: str
customer_id: str
total_cents: int
item_count: int
class CreateOrder:
def __init__(
self,
repository: OrderRepository,
discount_policy: DiscountPolicy,
) -> None:
self.repository = repository
self.discount_policy = discount_policy
def execute(
self,
command: CreateOrderCommand,
) -> OrderResult:
order = Order(
order_id=command.order_id,
customer_id=command.customer_id,
items=command.items,
)
total = self.discount_policy.calculate_total_cents(
order
)
created = self.repository.add(order, total)
if not created:
raise OrderAlreadyExists(
f"Order {order.order_id} already exists"
)
return OrderResult(
order_id=order.order_id,
customer_id=order.customer_id,
total_cents=total,
item_count=len(order.items),
)
class GetOrder:
def __init__(
self,
repository: OrderRepository,
) -> None:
self.repository = repository
def execute(self, order_id: str) -> OrderResult:
record = self.repository.get(order_id)
if record is None:
raise OrderNotFound(
f"Order {order_id} not found"
)
order, total_cents = record
return OrderResult(
order_id=order.order_id,
customer_id=order.customer_id,
total_cents=total_cents,
item_count=len(order.items),
)
CreateOrder coordinates the entity, domain service, and persistence requirement. It does not import an HTTP exception or know the SQL used to insert a record.
The calculated order total is stored with the order. This matters because pricing policies can change: retrieving an existing order should not unexpectedly recalculate its historical total using today's discount rules.
The repository returns a creation result that distinguishes successful insertion from an existing ID. The infrastructure implementation must ensure this check is safe under concurrency.
Implement the PostgreSQL Infrastructure Layer
The infrastructure layer implements persistence using SQLAlchemy and PostgreSQL. It owns ORM models, database sessions, SQL statements, and transaction handling.
Create app/infrastructure/database.py:
import os
from sqlalchemy import (
Integer,
JSON,
String,
create_engine,
)
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
Session,
mapped_column,
)
from app.domain.models import Order, OrderItem
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+psycopg://app:app@localhost:5432/orders",
)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
)
class Base(DeclarativeBase):
pass
class OrderRow(Base):
__tablename__ = "orders"
order_id: Mapped[str] = mapped_column(
String(100),
primary_key=True,
)
customer_id: Mapped[str] = mapped_column(
String(100),
nullable=False,
)
items: Mapped[list] = mapped_column(
JSON,
nullable=False,
)
total_cents: Mapped[int] = mapped_column(
Integer,
nullable=False,
)
class PostgresOrderRepository:
def __init__(
self,
session: Session,
) -> None:
self.session = session
def add(
self,
order: Order,
total_cents: int,
) -> bool:
items = [
{
"sku": item.sku,
"quantity": item.quantity,
"unit_price_cents": item.unit_price_cents,
}
for item in order.items
]
stmt = (
insert(OrderRow)
.values(
order_id=order.order_id,
customer_id=order.customer_id,
items=items,
total_cents=total_cents,
)
.on_conflict_do_nothing(
index_elements=["order_id"]
)
.returning(OrderRow.order_id)
)
# One local database transaction.
with self.session.begin():
created_id = self.session.execute(
stmt
).scalar_one_or_none()
return created_id is not None
def get(
self,
order_id: str,
) -> tuple[Order, int] | None:
row = self.session.get(
OrderRow,
order_id,
)
if row is None:
return None
domain_order = Order(
order_id=row.order_id,
customer_id=row.customer_id,
items=tuple(
OrderItem(
sku=item["sku"],
quantity=item["quantity"],
unit_price_cents=item["unit_price_cents"],
)
for item in row.items
),
)
return domain_order, row.total_cents
The ON CONFLICT DO NOTHING statement relies on PostgreSQL's primary-key constraint to prevent duplicate order identifiers safely under concurrent requests.
The repository maps database rows into domain objects. SQLAlchemy entities do not escape into application services or HTTP response models.
For simplicity, line items are stored as a JSON value. A larger relational application may use a separate order_items table with foreign keys, indexes, and constraints appropriate to its query patterns.
The adapter commits a single order insertion transaction. If several repositories must participate in one atomic business operation, move transaction coordination to a shared unit-of-work abstraction rather than allowing each repository to commit independently.
Implement FastAPI and Dependency Injection
The presentation layer converts HTTP input into application commands and maps use-case outcomes into responses.
Create app/presentation/api.py:
from contextlib import asynccontextmanager
from typing import Annotated, Iterator
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.domain.models import OrderItem
from app.domain.services import DiscountPolicy
from app.application.use_cases import (
CreateOrder,
CreateOrderCommand,
GetOrder,
OrderAlreadyExists,
OrderNotFound,
OrderRepository,
OrderResult,
)
from app.infrastructure.database import (
Base,
PostgresOrderRepository,
engine,
)
class OrderItemRequest(BaseModel):
sku: str = Field(min_length=1)
quantity: int = Field(gt=0)
unit_price_cents: int = Field(ge=0)
class CreateOrderRequest(BaseModel):
order_id: str = Field(min_length=1)
customer_id: str = Field(min_length=1)
items: list[OrderItemRequest] = Field(
min_length=1
)
class OrderResponse(BaseModel):
order_id: str
customer_id: str
total_cents: int
item_count: int
@asynccontextmanager
async def lifespan(app: FastAPI):
# Local demonstration only.
# Use Alembic for production migrations.
Base.metadata.create_all(bind=engine)
yield
engine.dispose()
app = FastAPI(
title="Onion Architecture Orders API",
lifespan=lifespan,
)
def get_session() -> Iterator[Session]:
with Session(engine) as session:
yield session
def get_repository(
session: Annotated[
Session,
Depends(get_session),
],
) -> OrderRepository:
return PostgresOrderRepository(session)
@app.post(
"/orders",
status_code=201,
response_model=OrderResponse,
)
def create_order(
request: CreateOrderRequest,
repository: Annotated[
OrderRepository,
Depends(get_repository),
],
):
use_case = CreateOrder(
repository=repository,
discount_policy=DiscountPolicy(),
)
try:
result = use_case.execute(
CreateOrderCommand(
order_id=request.order_id,
customer_id=request.customer_id,
items=tuple(
OrderItem(
sku=item.sku,
quantity=item.quantity,
unit_price_cents=item.unit_price_cents,
)
for item in request.items
),
)
)
return OrderResponse(
**result.__dict__
)
except ValueError as exc:
raise HTTPException(
status_code=422,
detail=str(exc),
) from exc
except OrderAlreadyExists as exc:
raise HTTPException(
status_code=409,
detail=str(exc),
) from exc
@app.get(
"/orders/{order_id}",
response_model=OrderResponse,
)
def get_order(
order_id: str,
repository: Annotated[
OrderRepository,
Depends(get_repository),
],
):
try:
result = GetOrder(repository).execute(
order_id
)
return OrderResponse(
**result.__dict__
)
except OrderNotFound as exc:
raise HTTPException(
status_code=404,
detail=str(exc),
) from exc
The HTTP adapter owns Pydantic request models and FastAPI exceptions. The application services return plain application results rather than framework-specific response objects.
The get_repository dependency is part of the composition root. It connects the inner repository contract to the outer PostgreSQL implementation.
FastAPI endpoints are synchronous because the database adapter uses synchronous SQLAlchemy sessions. An asynchronous implementation should consistently use async-compatible database operations and dependency lifetimes.
Test Business Rules Without Infrastructure
The domain and application layers can be tested using an in-memory repository. These tests should not require Docker, FastAPI, PostgreSQL, or SQLAlchemy models.
Create tests/test_orders.py:
import pytest
from app.domain.models import Order, OrderItem
from app.domain.services import DiscountPolicy
from app.application.use_cases import (
CreateOrder,
CreateOrderCommand,
GetOrder,
OrderAlreadyExists,
OrderNotFound,
)
class InMemoryOrderRepository:
def __init__(self):
self.orders: dict[
str,
tuple[Order, int],
] = {}
def add(
self,
order: Order,
total_cents: int,
) -> bool:
if order.order_id in self.orders:
return False
self.orders[order.order_id] = (
order,
total_cents,
)
return True
def get(self, order_id: str):
return self.orders.get(order_id)
def make_order(quantity: int = 2) -> CreateOrderCommand:
return CreateOrderCommand(
order_id="order-1001",
customer_id="customer-42",
items=(
OrderItem(
sku="keyboard",
quantity=quantity,
unit_price_cents=2000,
),
),
)
def test_order_total_without_discount():
repo = InMemoryOrderRepository()
use_case = CreateOrder(
repo,
DiscountPolicy(),
)
result = use_case.execute(
make_order(quantity=2)
)
assert result.total_cents == 4000
assert result.item_count == 1
def test_volume_discount():
repo = InMemoryOrderRepository()
result = CreateOrder(
repo,
DiscountPolicy(),
).execute(
make_order(quantity=10)
)
# 10 * 2000 = 20000
# 5% discount = 19000
assert result.total_cents == 19000
def test_duplicate_order():
repo = InMemoryOrderRepository()
use_case = CreateOrder(
repo,
DiscountPolicy(),
)
command = make_order()
use_case.execute(command)
with pytest.raises(OrderAlreadyExists):
use_case.execute(command)
def test_invalid_order_item():
with pytest.raises(ValueError):
OrderItem(
sku="keyboard",
quantity=0,
unit_price_cents=2000,
)
def test_duplicate_sku_is_rejected():
item = OrderItem(
sku="keyboard",
quantity=1,
unit_price_cents=2000,
)
with pytest.raises(ValueError):
Order(
order_id="order-1002",
customer_id="customer-42",
items=(item, item),
)
def test_get_existing_order():
repo = InMemoryOrderRepository()
created = CreateOrder(
repo,
DiscountPolicy(),
).execute(make_order())
retrieved = GetOrder(repo).execute(
created.order_id
)
assert retrieved == created
def test_get_missing_order():
repo = InMemoryOrderRepository()
with pytest.raises(OrderNotFound):
GetOrder(repo).execute("missing")
The tests execute actual domain and application code. The infrastructure dependency is replaced with a lightweight fake that implements the same repository behavior.
Testing without PostgreSQL is useful for speed and isolation, but it does not validate SQL statements, uniqueness constraints, migrations, or connection behavior.
Production projects should also run integration tests against a real PostgreSQL instance. For example, verify that concurrent attempts to insert the same order ID result in only one committed record.
Run and Verify the Application
Use Docker Compose to start PostgreSQL locally. The FastAPI application runs through Uvicorn on your development machine.
Create docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: app
POSTGRES_PASSWORD: app
ports:
- "127.0.0.1:5432:5432"
healthcheck:
test:
- CMD-SHELL
- pg_isready -U app -d orders
interval: 5s
timeout: 3s
retries: 10
These credentials are only for local development. Production credentials should be managed through a suitable secrets management system rather than copied into deployment files.
Run the application and its tests:
# Start PostgreSQL.
docker compose up -d --wait
# Create and activate the Python environment.
python3 -m venv .venv
source .venv/bin/activate
# Install project dependencies.
pip install -r requirements.txt
# Run domain and application tests.
pytest -q
# Configure the database connection.
export DATABASE_URL="postgresql+psycopg://app:app@localhost:5432/orders"
# Start FastAPI.
uvicorn app.presentation.api:app --reload
# In a separate terminal:
# Create an order without a discount.
curl -i -X POST \
http://127.0.0.1:8000/orders \
-H "Content-Type: application/json" \
-d '{
"order_id": "order-1001",
"customer_id": "customer-42",
"items": [
{
"sku": "keyboard",
"quantity": 2,
"unit_price_cents": 2000
}
]
}'
# Retrieve the persisted order.
curl -i \
http://127.0.0.1:8000/orders/order-1001
# Submit the same order again to test conflicts.
curl -i -X POST \
http://127.0.0.1:8000/orders \
-H "Content-Type: application/json" \
-d '{
"order_id": "order-1001",
"customer_id": "customer-42",
"items": [
{
"sku": "keyboard",
"quantity": 2,
"unit_price_cents": 2000
}
]
}'
# Remove the local PostgreSQL container.
docker compose down
The first request should create the order with a total of 4,000 cents. Retrieving it returns the recorded total, and repeating the creation request produces HTTP 409 Conflict.
To test the discount policy, submit another order with a quantity of ten at a unit price of 2,000 cents. The application should record a total of 19,000 cents after the illustrative 5% discount.
The business rules remain the same regardless of whether they are called through FastAPI or directly in pytest.
For production deployments, replace startup-time schema creation with versioned database migrations, configure a suitable connection pool, and add integration tests for the PostgreSQL repository.
Onion vs. Layered, Hexagonal, and Clean Architecture
Onion Architecture shares several principles with Hexagonal and Clean Architecture. All three seek to protect business logic from infrastructure dependencies, but they emphasize different conceptual boundaries.
Traditional layered architecture can also achieve good separation when dependencies are designed carefully. Architectural names are less important than the actual coupling and responsibilities visible in the code.
Key Differences and Similarities
The following table summarizes their usual emphasis.
| Architecture | Main Emphasis | Typical Structure |
|---|---|---|
| Traditional Layered | Technical responsibility separation | Presentation, business logic, data access |
| Onion | Domain model at the center and inward dependencies | Domain, domain services, application services, infrastructure |
| Hexagonal | Ports and adapters separating application and external systems | Application core, driving adapters, driven adapters |
| Clean | Entities, use cases, boundary interfaces, and dependency rules | Entities, use cases, interface adapters, frameworks |
In practice, an Onion Architecture implementation may use repository ports and adapters exactly as a Hexagonal Architecture implementation does.
The difference is that Onion Architecture makes the layered domain model and inward dependency rule particularly explicit, while Hexagonal Architecture emphasizes the boundary between application behavior and external actors.
Clean Architecture describes a similar dependency principle using entities, use cases, interface adapters, and frameworks as conceptual boundaries.
These patterns can overlap within a single implementation. They should not be interpreted as three mutually exclusive software architectures.
When to Use Onion Architecture
Onion Architecture is especially useful when a system contains meaningful business behavior, is expected to evolve for years, or integrates with several external technologies.
Good candidates include:
- Order, inventory, and payment management systems.
- Financial applications with complex validation and transaction rules.
- Business platforms with frequent rule changes.
- Modular monoliths with multiple domain areas.
- Applications that need isolated domain and use-case tests.
- Long-lived services where frameworks and infrastructure may change.
Onion Architecture may add little value to a small application that mainly forwards CRUD requests to a database with minimal business logic.
For such systems, a simple service and repository separation may be easier to understand and maintain.
Adopt Onion Architecture because the domain needs protection from technical coupling, not because a project must contain a particular number of directories.
Production Practices and Common Mistakes
Onion Architecture can improve maintainability, but production correctness still depends on transaction boundaries, concurrency handling, migration procedures, monitoring, and disciplined deployment practices.
Developers should also periodically review whether their layer boundaries remain meaningful as new use cases and integrations are added.
Common Implementation Mistakes
Frequent mistakes include:
- Putting only data fields in domain entities: Business behavior ends up scattered across controllers and services.
- Importing SQLAlchemy models into application services: The core becomes coupled to persistence.
- Using FastAPI exceptions in domain logic: Business rules depend on the HTTP delivery mechanism.
- Creating interfaces for every class: Adds complexity without improving meaningful boundaries.
- Making domain services responsible for database transactions: Infrastructure concerns move into the domain.
- Recalculating historical values from current policies: Existing orders can appear to change when business rules evolve.
- Handling uniqueness with read-before-write checks: Concurrent requests can violate assumptions.
- Committing every repository operation independently: Multi-step use cases lose atomic transaction boundaries.
- Assuming application layers guarantee distributed consistency: External messages and API calls still require recovery strategies.
- Testing only with mocks or fakes: PostgreSQL adapters and external integrations remain unverified.
- Creating an overly generic repository framework: Application code inherits abstractions unrelated to its business needs.
- Treating layer boundaries as deployment boundaries: Onion Architecture does not require microservices or independent infrastructure stacks.
An important warning is that domain services and application services are not interchangeable. Domain services express business rules, while application services coordinate use cases, infrastructure abstractions, and operation boundaries.
The distinction is not always perfectly sharp, but responsibilities should remain understandable and deliberate.
Production Checklist
Before deploying an Onion Architecture application, verify that:
- Domain models contain meaningful business behavior and enforce their invariants.
- Domain code imports no web framework or database ORM packages.
- Application services depend on domain types and application-defined abstractions.
- Infrastructure adapters implement core interfaces without exposing ORM entities outward.
- Presentation adapters translate transport-specific input and output.
- Dependency injection is configured at an explicit composition boundary.
- Database uniqueness constraints enforce critical concurrency rules.
- Transaction scope corresponds to the complete business operation.
- External API calls include suitable timeouts, retries, and idempotency handling.
- Multi-system workflows use appropriate consistency and recovery patterns.
- Historical financial values remain stable when pricing rules change.
- Database changes are managed through versioned migrations.
- Unit tests cover domain entities, domain services, and application use cases.
- Integration tests verify SQL behavior and external adapters.
- Operational logs and metrics make failed use cases diagnosable.
Frequently Asked Questions
Onion Architecture is frequently discussed alongside domain-driven design, Clean Architecture, and Hexagonal Architecture. Understanding what the pattern requires—and what it leaves flexible—helps avoid unnecessary complexity.
How Many Layers Does Onion Architecture Have?
There is no mandatory number of layers. Implementations commonly distinguish the domain model, domain services, application services, and infrastructure or presentation concerns.
The essential requirement is that dependencies point inward and the application core remains independent of external infrastructure.
Does Onion Architecture Require Domain-Driven Design?
No. Onion Architecture works well with domain-driven design, but it does not require every DDD pattern, such as aggregates, bounded contexts, domain events, or value objects.
A team can apply inward dependency rules to a simpler domain model while introducing additional DDD concepts only when justified by business complexity.
Where Does the Database Belong?
The database and its access implementation belong outside the application core, usually in the infrastructure layer.
The application defines the operations it needs from persistence, and infrastructure implements those operations using SQLAlchemy, PostgreSQL, or another suitable technology.
Can Onion Architecture Be Used in Microservices?
Yes. Onion Architecture can organize a monolith, modular monolith, or individual microservice.
It describes internal source-code dependencies rather than prescribing how many processes, containers, or independently deployed services a system must have.
Do All Classes Need Interfaces?
No. Interfaces are valuable when they establish a meaningful architectural boundary, particularly between application logic and infrastructure dependencies.
Creating an interface for every domain entity or internal utility often produces unnecessary indirection. Use abstractions where they protect important behavior or make external dependencies replaceable and testable.
Is Onion Architecture the Same as Hexagonal Architecture?
They share the goal of separating application behavior from infrastructure and directing dependencies toward stable business abstractions.
Onion Architecture emphasizes domain-centric layers, while Hexagonal Architecture emphasizes communication contracts and adapters at the application's boundary. Many real implementations use ideas from both.
Conclusion
Onion Architecture organizes software around an independent domain model, with application behavior and external infrastructure arranged in layers around it. By requiring source-code dependencies to point inward, it protects business logic from changes in frameworks, databases, and other technical systems.
The practical benefit is not simply the ability to replace a database. It is clearer ownership of business rules, independently testable use cases, predictable infrastructure boundaries, and a codebase that can evolve without forcing every technology change into the domain model.
However, Onion Architecture is not a substitute for transaction design, reliable event delivery, database constraints, or production observability. The architecture helps place these responsibilities correctly, but engineers must still implement and test them.
Key Takeaway: Keep the domain at the center. Define application behavior around domain concepts, make infrastructure depend on the application core, and introduce only the abstractions necessary to preserve meaningful boundaries.
More Articles to Read
Continue exploring architecture patterns and practical implementation concepts through related SysDesPro topics:
- What Is Hexagonal Architecture? Ports and Adapters Explained — Compare how ports and adapters organize external dependencies.
- What Is Clean Architecture? Principles, Layers, and Practical Examples — Explore entities, use cases, and interface adapters.
- What Is the Repository Pattern? — Understand persistence abstractions around domain objects.
- What Is Domain-Driven Design? — Explore domain models, bounded contexts, and domain behaviors.
- What Is the Transactional Outbox Pattern? — Learn how to coordinate database changes and event publication.
- What Is Dependency Injection? — Understand how concrete infrastructure implementations are supplied to application code.
Comments (0)