What Is Clean Architecture? Principles, Layers, and Practical Examples
Clean Architecture is a software architecture approach that separates business rules from external technologies such as web frameworks, databases, message brokers, and third-party APIs. Introduced by Robert C. Martin in 2012, it organizes applications around a fundamental dependency rule: source-code dependencies must point inward, toward higher-level business policies. Application logic should not depend directly on FastAPI, SQLAlchemy, PostgreSQL, or an external SDK merely because those technologies are used to run the application.
The pattern builds on ideas shared by Hexagonal Architecture, Onion Architecture, and other approaches that emphasize separation of concerns. Clean Architecture makes an important distinction between enterprise-level business rules, application-specific use cases, interface adapters, and implementation frameworks. This guide explains those boundaries and demonstrates them through an order management API using Python, FastAPI, PostgreSQL, SQLAlchemy, and pytest. The example includes explicit input and output models, a repository gateway, a presenter, and business-rule tests that run without a database or HTTP server.
Table of Contents
- What Is Clean Architecture?
- The Four Layers of Clean Architecture
- The Dependency Rule and Crossing Boundaries
- Production Design Example: Order Management API
- Ready-to-Use Example with FastAPI and PostgreSQL
- Clean vs. Hexagonal, Onion, and Layered Architecture
- Production Practices and Common Mistakes
- Frequently Asked Questions
- Conclusion
What Is Clean Architecture?
Clean Architecture organizes software so the most important business rules are the least dependent on implementation details. Entities and use cases define what the system does. Interface adapters translate between those internal models and external representations. Frameworks, databases, and delivery technologies remain at the edge.
Its main objective is not to eliminate dependencies, but to control their direction. Higher-level policies should not need to change when a developer replaces an HTTP framework, modifies a database schema, or integrates a different external provider.
Origins and Core Principles
Robert C. Martin introduced Clean Architecture in a 2012 article that brought together principles from several established architectural approaches, including Hexagonal Architecture and related boundary-oriented designs.
The defining characteristics are:
- Framework independence: Frameworks are tools used by the application, not foundations that dictate its business rules.
- Testability: Core behaviors can be tested without a web server or production database.
- UI independence: HTTP, CLI, messaging, and other interfaces can invoke the same application behavior.
- Database independence: Business rules do not directly use database drivers or ORM entities.
- External-system independence: Third-party integrations are separated behind explicit contracts.
- Inward dependencies: Source code in inner layers does not import code from outer layers.
The original diagram contains four concentric circles, but these are conceptual boundaries rather than a mandatory directory count. An implementation can use additional or fewer physical modules as long as the dependency rule remains intact.
Why Business Logic Needs Architectural Boundaries
Consider a backend service that implements order creation. It validates customer input, calculates pricing, stores records using SQLAlchemy, calls a payment provider, and raises FastAPI exceptions directly from the service class.
That design can become difficult to maintain as the application grows. Unit tests require patching framework and infrastructure behavior, while changes in a database model or payment SDK can affect code that should primarily express business rules.
Clean Architecture separates these responsibilities:
- The entity enforces valid business state.
- The use case coordinates the requested operation.
- The controller translates incoming HTTP input into a use-case request.
- The database gateway translates domain objects into persistence operations.
- The presenter translates application output into a response representation.
The result is a clearer distinction between policies and mechanisms. A change in an API response format can be handled at the outer boundary without rewriting how an order is validated or calculated.
This separation has a cost: additional contracts and translation code. Its value increases as business behavior and integration complexity increase.
The Four Layers of Clean Architecture
The traditional Clean Architecture model describes four conceptual layers. The innermost contains the most general business rules, while outer layers contain more technology-specific decisions.
Each layer is defined by responsibility and dependency direction, not merely by its folder name.
Entities: Enterprise Business Rules
Entities represent fundamental domain concepts and rules that remain meaningful independently of one application workflow.
For example, an Order entity can enforce that an order contains valid items and calculate its subtotal. These rules are relevant whether an order is created through a website, a mobile application, or a background import process.
Typical entity responsibilities include:
- Protecting domain invariants.
- Representing valid business state.
- Calculating domain values.
- Enforcing state transitions.
- Expressing business behavior without transport or persistence dependencies.
An entity should not know that an order is stored in a PostgreSQL table named orders, or that an invalid request eventually produces an HTTP 422 response.
Entities are not necessarily database entities. A SQLAlchemy model and a Clean Architecture domain entity may represent similar data while serving very different responsibilities.
Use Cases: Application Business Rules
Use cases describe the operations the application supports. They coordinate entities and required external capabilities to achieve a specific outcome.
Examples for an order management system include:
CreateOrder: Validate and persist a new order.GetOrder: Retrieve a previously created order.CancelOrder: Enforce cancellation rules and persist the updated state.AuthorizePayment: Coordinate payment authorization under business constraints.
Use cases contain application-specific policies. They are allowed to change when a workflow changes, but they should not require changes because an HTTP library or SQL driver has been replaced.
A use case can depend on interfaces for persistence, notifications, or payment operations. Those interfaces are defined according to the application's needs and fulfilled by outer implementations.
Use cases should exchange simple application input and output models rather than exposing ORM rows, FastAPI request objects, or provider-specific SDK responses.
Interface Adapters: Controllers, Presenters, and Gateways
The interface adapters layer converts data between the application's internal representation and formats required by external systems.
Three useful adapter roles are:
- Controller: Converts incoming HTTP requests, CLI arguments, or messages into use-case inputs.
- Presenter: Converts use-case outputs into a representation appropriate for a delivery mechanism.
- Gateway: Implements an application-defined interface for persistence or another external capability.
For example, a FastAPI request model may use HTTP-oriented validation and field names. The controller converts it into an internal CreateOrderInput object. The use case returns a CreatedOrder result, which a presenter converts into an HTTP response schema.
Likewise, a PostgreSQL gateway converts between domain objects and SQLAlchemy rows.
These translation steps are not always large, but they protect the application core from formats that are controlled by external systems or frameworks.
Frameworks and Drivers: External Details
The outermost layer contains technologies used to execute and connect the application.
Typical examples include:
- FastAPI, Django, and other web frameworks.
- PostgreSQL and database drivers.
- SQLAlchemy and other persistence libraries.
- External payment provider SDKs.
- Cloud infrastructure integrations.
- Messaging systems and runtime infrastructure.
Frameworks remain useful and powerful. Clean Architecture does not argue against using them. It argues against allowing them to dictate the core business model unnecessarily.
Some implementations place adapter and framework integration code in the same Python package for simplicity. That can be reasonable when the source-code dependency boundaries remain clear.
The Dependency Rule and Crossing Boundaries
The central principle of Clean Architecture is that source-code dependencies point inward. Inner layers know nothing about the names or types declared by outer layers.
This is especially important when execution requires calling an external component. Runtime control flow and source-code dependency direction are not necessarily the same.
Source-Code Dependencies vs. Runtime Control Flow
Suppose a use case needs to save an order in PostgreSQL.
At runtime, the operation must reach the database. But that does not mean the use case needs to import a PostgreSQL repository class.
Instead, the use case depends on an OrderGateway interface. A PostgreSQL implementation conforms to that interface and is supplied when the application starts.
The runtime call travels from the use case into the gateway implementation, but the use case's source code depends only on a contract defined in the application core.
This is the dependency inversion principle applied across an architectural boundary.
The important distinction is between the direction in which a function call executes and the direction in which modules import or reference one another.
Input and Output Boundaries
Clean Architecture pays particular attention to what crosses a layer boundary.
A controller should not hand a FastAPI Request object directly to a use case. Likewise, a use case should not return SQLAlchemy ORM rows for an HTTP endpoint to serialize.
Instead, use plain application request and response data structures.
A typical request flow is:
- An HTTP controller receives and validates a request.
- The controller constructs an application input model.
- The use case invokes entities and gateway interfaces.
- The use case produces an application output model.
- A presenter converts the output into a transport-friendly representation.
- The HTTP layer returns the response.
In Martin's more explicit boundary model, the use case may call an output boundary implemented by a presenter. In simpler applications, the use case can return an output DTO that a presenter formats afterward.
Both approaches can preserve clean source dependencies. The key is that the use case does not depend on the concrete presenter or its web framework types.
Dependency Injection and the Composition Root
Dependency injection connects application-defined interfaces to concrete adapter implementations.
For example, production may supply a SQLAlchemy-backed gateway to CreateOrder, while unit tests supply an in-memory gateway.
The composition root is where these concrete implementations are constructed and connected.
In a FastAPI application, dependency provider functions can serve this purpose. They are allowed to know about both use cases and PostgreSQL adapters because they exist at the outer boundary.
There is no requirement to introduce a dedicated dependency injection framework. Constructor injection and small dependency factories often provide enough control for Python services.
Production Design Example: Order Management API
Consider an order management API serving an e-commerce application. Customers submit orders containing line items, and the backend calculates their total and saves them to PostgreSQL.
The application must reject invalid orders, protect against duplicate order IDs under concurrent requests, remain testable without PostgreSQL, and support additional interfaces such as a CLI or background worker in the future.
Components and Their Responsibilities
The production design separates the components as follows:
- Order entity: Validates line items and calculates totals.
- CreateOrder use case: Creates the entity and requests persistence.
- GetOrder use case: Retrieves an existing order through a gateway.
- OrderGateway interface: Defines required persistence capabilities.
- PostgreSQL gateway: Executes SQL and maps stored rows to domain objects.
- FastAPI controller: Translates HTTP input into application commands.
- Presenter: Produces an API response representation from use-case results.
- Composition root: Builds the gateway, use cases, and framework integration.
The use cases do not know that the application is accessed through HTTP. The entity does not know that it is persisted using SQLAlchemy.
If a background SQS consumer later needs to create orders, it can invoke the same use case through another controller adapter. Business validation remains centralized.
Transactions, Idempotency, and External Failures
Clean Architecture protects code boundaries, but it does not automatically solve concurrency, retries, or distributed transactions.
For example, checking whether an order ID exists before inserting an order creates a race condition. Two requests may both see no existing record and proceed simultaneously.
The database needs an appropriate uniqueness constraint and an atomic insert strategy. The persistence gateway should return a meaningful application outcome for duplicates rather than leaking a raw SQL exception.
Creating an order and charging a payment provider introduces another risk. A payment may succeed while the database operation fails, or the database commit may succeed while an outgoing event publication fails.
A production system may use idempotency keys, durable operation state, compensating actions, and patterns such as the Transactional Outbox to coordinate these failures.
For multi-step database operations, transaction ownership should be explicit. An application-level unit of work can coordinate several repositories through one transaction without making use cases depend on SQLAlchemy.
The implementation below deliberately keeps the operation small: one order creation corresponds to one atomic database insert. This lets the example demonstrate Clean Architecture boundaries without hiding a distributed workflow behind incomplete error handling.
Ready-to-Use Example with FastAPI and PostgreSQL
This example builds a minimal order management API with Python 3.12 or newer, FastAPI, SQLAlchemy 2, PostgreSQL, and pytest.
The application follows the four conceptual boundaries. In particular, the entity and use-case modules have no FastAPI, SQLAlchemy, or PostgreSQL imports.
Project Structure and Dependencies
Create the following directory structure. Add empty __init__.py files to Python package directories.
clean-orders/
app/
__init__.py
entities/
__init__.py
order.py
use_cases/
__init__.py
orders.py
adapters/
__init__.py
postgres.py
presenter.py
frameworks/
__init__.py
api.py
tests/
test_orders.py
requirements.txt
docker-compose.yml
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
The version ranges are suitable for an educational example. Production deployments should use a tested dependency lockfile and reproducible build artifacts.
Implement the Domain Entities
The innermost layer defines Order and OrderItem. Both express business invariants without references to HTTP or persistence.
Create app/entities/order.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("Unit price cannot be negative")
@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 total_cents(self) -> int:
return sum(
item.subtotal_cents
for item in self.items
)
OrderItem validates quantity and price, while Order ensures that the order contains items and has no duplicate SKUs.
The total is calculated from immutable line items using integer cents. This avoids floating-point precision problems in the simple example.
For a real commerce platform, pricing generally requires additional rules around currency, taxes, discounts, refunds, and rounding. Those rules should have explicit domain representations rather than being scattered across request handlers.
Implement Use Cases and Gateway Interfaces
The use-case layer defines the operations supported by the application and the persistence contract they require.
It uses input and output DTOs that are independent of FastAPI and SQLAlchemy.
Create app/use_cases/orders.py:
from dataclasses import dataclass
from typing import Protocol
from app.entities.order import Order, OrderItem
class OrderAlreadyExists(Exception):
pass
class OrderNotFound(Exception):
pass
class OrderGateway(Protocol):
def create(self, order: Order) -> bool:
"""Create atomically; False means ID exists."""
def get(self, order_id: str) -> Order | None:
"""Return an order or None."""
@dataclass(frozen=True)
class ItemInput:
sku: str
quantity: int
unit_price_cents: int
@dataclass(frozen=True)
class CreateOrderInput:
order_id: str
customer_id: str
items: tuple[ItemInput, ...]
@dataclass(frozen=True)
class OrderOutput:
order_id: str
customer_id: str
total_cents: int
item_count: int
def to_output(order: Order) -> OrderOutput:
return OrderOutput(
order_id=order.order_id,
customer_id=order.customer_id,
total_cents=order.total_cents,
item_count=len(order.items),
)
class CreateOrder:
def __init__(
self,
gateway: OrderGateway,
) -> None:
self.gateway = gateway
def execute(
self,
data: CreateOrderInput,
) -> OrderOutput:
order = Order(
order_id=data.order_id,
customer_id=data.customer_id,
items=tuple(
OrderItem(
sku=item.sku,
quantity=item.quantity,
unit_price_cents=item.unit_price_cents,
)
for item in data.items
),
)
if not self.gateway.create(order):
raise OrderAlreadyExists(
f"Order {order.order_id} already exists"
)
return to_output(order)
class GetOrder:
def __init__(
self,
gateway: OrderGateway,
) -> None:
self.gateway = gateway
def execute(
self,
order_id: str,
) -> OrderOutput:
order = self.gateway.get(order_id)
if order is None:
raise OrderNotFound(
f"Order {order_id} not found"
)
return to_output(order)
Notice the separation between CreateOrderInput, the Order entity, and OrderOutput. These represent distinct boundaries.
The controller is responsible for producing the input DTO, the use case coordinates the entity and gateway, and the presenter converts the output DTO for the external interface.
Errors are application-specific rather than HTTP-specific. The use case reports an existing or missing order without choosing an HTTP status code.
Implement the PostgreSQL Gateway
The database gateway uses SQLAlchemy to implement the persistence operations defined by the use-case layer.
It contains the ORM model, PostgreSQL-specific conflict handling, session usage, and translation back into domain entities.
Create app/adapters/postgres.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.entities.order 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 PostgresOrderGateway:
def __init__(
self,
session: Session,
) -> None:
self.session = session
def create(self, order: Order) -> bool:
items = [
{
"sku": item.sku,
"quantity": item.quantity,
"unit_price_cents": item.unit_price_cents,
}
for item in order.items
]
statement = (
insert(OrderRow)
.values(
order_id=order.order_id,
customer_id=order.customer_id,
items=items,
total_cents=order.total_cents,
)
.on_conflict_do_nothing(
index_elements=["order_id"]
)
.returning(OrderRow.order_id)
)
# One atomic insert transaction.
with self.session.begin():
inserted_id = self.session.execute(
statement
).scalar_one_or_none()
return inserted_id is not None
def get(
self,
order_id: str,
) -> Order | None:
row = self.session.get(
OrderRow,
order_id,
)
if row is None:
return None
return 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
),
)
The primary key and PostgreSQL's atomic ON CONFLICT DO NOTHING operation prevent concurrent requests from inserting duplicate order IDs.
The gateway never returns an ORM row to the use case. It reconstructs a domain Order using stored data.
In this small example, the gateway owns the transaction for a single insert. In a more complex use case that updates inventory, orders, and outbox records together, transaction ownership should move to a shared unit-of-work boundary.
The JSON representation of order items is a simplification. A real application may normalize line items into a separate table, enforce additional constraints, and use database migrations for schema evolution.
Implement FastAPI Controllers and Presenters
The HTTP layer converts incoming requests into use-case DTOs and converts application output into response models.
For a small Python application, the presenter can be a simple class. It does not need a dedicated dependency injection container or a large hierarchy of presenter interfaces.
Create app/adapters/presenter.py:
from dataclasses import asdict
from app.use_cases.orders import OrderOutput
class OrderPresenter:
def present(self, result: OrderOutput) -> dict:
# The presenter owns the external
# representation of the use-case result.
return asdict(result)
Create app/frameworks/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.adapters.postgres import (
Base,
PostgresOrderGateway,
engine,
)
from app.adapters.presenter import OrderPresenter
from app.use_cases.orders import (
CreateOrder,
CreateOrderInput,
GetOrder,
ItemInput,
OrderAlreadyExists,
OrderNotFound,
)
class ItemRequest(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[ItemRequest] = 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):
# Demonstration only: use versioned
# migrations in production.
Base.metadata.create_all(bind=engine)
yield
engine.dispose()
app = FastAPI(
title="Clean Architecture Orders API",
lifespan=lifespan,
)
def get_session() -> Iterator[Session]:
with Session(engine) as session:
yield session
def get_gateway(
session: Annotated[
Session,
Depends(get_session),
],
) -> PostgresOrderGateway:
return PostgresOrderGateway(session)
@app.post(
"/orders",
status_code=201,
response_model=OrderResponse,
)
def create_order(
request: CreateOrderRequest,
gateway: Annotated[
PostgresOrderGateway,
Depends(get_gateway),
],
):
# HTTP controller translates transport data
# into an application-owned input DTO.
input_data = CreateOrderInput(
order_id=request.order_id,
customer_id=request.customer_id,
items=tuple(
ItemInput(
sku=item.sku,
quantity=item.quantity,
unit_price_cents=item.unit_price_cents,
)
for item in request.items
),
)
try:
result = CreateOrder(gateway).execute(
input_data
)
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
return OrderPresenter().present(result)
@app.get(
"/orders/{order_id}",
response_model=OrderResponse,
)
def get_order(
order_id: str,
gateway: Annotated[
PostgresOrderGateway,
Depends(get_gateway),
],
):
try:
result = GetOrder(gateway).execute(
order_id
)
except OrderNotFound as exc:
raise HTTPException(
status_code=404,
detail=str(exc),
) from exc
return OrderPresenter().present(result)
The controller converts Pydantic models into plain application DTOs. The presenter converts the use-case output into the representation returned to the client.
In this implementation, the presenter is deliberately simple. If the API later needs localized currency formatting, public field renaming, or different representations for different clients, those transformations can belong to presenter implementations rather than application use cases.
The composition root is located at the framework boundary. It creates PostgreSQL gateway instances and injects them into the use cases.
The endpoint functions are synchronous because the database gateway uses synchronous SQLAlchemy sessions. For an async database implementation, use the appropriate async driver, session lifecycle, and use-case interfaces consistently.
Test Business Rules Without a Database
The principal testing benefit is that domain entities and use cases can be exercised without starting PostgreSQL or FastAPI.
An in-memory gateway implements the same application contract as the production PostgreSQL gateway.
Create tests/test_orders.py:
import pytest
from app.entities.order import Order, OrderItem
from app.use_cases.orders import (
CreateOrder,
CreateOrderInput,
GetOrder,
ItemInput,
OrderAlreadyExists,
OrderNotFound,
)
class InMemoryOrderGateway:
def __init__(self) -> None:
self.orders: dict[str, Order] = {}
def create(self, order: Order) -> bool:
if order.order_id in self.orders:
return False
self.orders[order.order_id] = order
return True
def get(
self,
order_id: str,
) -> Order | None:
return self.orders.get(order_id)
def sample_input() -> CreateOrderInput:
return CreateOrderInput(
order_id="order-1001",
customer_id="customer-42",
items=(
ItemInput(
sku="keyboard",
quantity=2,
unit_price_cents=2000,
),
),
)
def test_order_calculates_total():
order = Order(
order_id="order-1",
customer_id="customer-1",
items=(
OrderItem("keyboard", 2, 2000),
OrderItem("mouse", 1, 1500),
),
)
assert order.total_cents == 5500
def test_create_order():
gateway = InMemoryOrderGateway()
result = CreateOrder(gateway).execute(
sample_input()
)
assert result.order_id == "order-1001"
assert result.total_cents == 4000
assert result.item_count == 1
def test_duplicate_order_is_rejected():
gateway = InMemoryOrderGateway()
use_case = CreateOrder(gateway)
use_case.execute(sample_input())
with pytest.raises(OrderAlreadyExists):
use_case.execute(sample_input())
def test_invalid_quantity():
with pytest.raises(ValueError):
OrderItem(
sku="keyboard",
quantity=0,
unit_price_cents=2000,
)
def test_duplicate_sku():
item = OrderItem(
sku="keyboard",
quantity=1,
unit_price_cents=2000,
)
with pytest.raises(ValueError):
Order(
order_id="order-2",
customer_id="customer-1",
items=(item, item),
)
def test_get_existing_order():
gateway = InMemoryOrderGateway()
created = CreateOrder(gateway).execute(
sample_input()
)
loaded = GetOrder(gateway).execute(
created.order_id
)
assert loaded == created
def test_get_missing_order():
gateway = InMemoryOrderGateway()
with pytest.raises(OrderNotFound):
GetOrder(gateway).execute("missing")
These tests use the same domain entities and use cases as production. They do not patch the use cases or require an HTTP test client.
However, passing unit tests does not establish that the PostgreSQL gateway is correct. Separate integration tests should verify database inserts, uniqueness constraints, transaction behavior, connection handling, and schema migrations.
A useful integration test runs two concurrent requests with the same order ID and verifies that only one record is created.
Run and Verify the Application
Use Docker Compose to start a local PostgreSQL instance. FastAPI can run directly on the host through Uvicorn.
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
The password and database settings are for local development only. A production deployment should use managed credentials and appropriate network restrictions.
Run the following commands:
# Start PostgreSQL and wait for readiness.
docker compose up -d --wait
# Create the Python environment.
python3 -m venv .venv
source .venv/bin/activate
# Install the required packages.
pip install -r requirements.txt
# Run core business-rule tests.
pytest -q
# Configure the PostgreSQL connection.
export DATABASE_URL="postgresql+psycopg://app:app@localhost:5432/orders"
# Start the API.
uvicorn app.frameworks.api:app --reload
# In another terminal, create an order.
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 order.
curl -i \
http://127.0.0.1:8000/orders/order-1001
# Repeat the creation request 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
}
]
}'
# Stop the local database when finished.
docker compose down
The initial request should return 201 Created with a total of 4,000 cents. Retrieving the order should return 200 OK. Repeating the same creation request should return 409 Conflict because the order ID already exists.
Notice where those decisions are implemented: the entity calculates the total, the use case attempts creation, the PostgreSQL gateway enforces uniqueness through the database, and the controller converts the application conflict into an HTTP response.
For production use, replace startup-time schema creation with versioned migrations, configure connection pooling, add structured logs and request correlation, and test gateway behavior against a real database.
Clean vs. Hexagonal, Onion, and Layered Architecture
Clean Architecture shares much of its philosophy with Hexagonal and Onion Architecture. All three seek to protect application behavior from external implementation details.
The differences are mostly about which concepts they emphasize and how the boundaries are presented.
Key Differences and Similarities
| Architecture | Main Emphasis | Typical Organization |
|---|---|---|
| Traditional Layered | Technical responsibility separation | Controllers, services, repositories |
| Hexagonal | Ports and adapters separating application behavior from external actors | Core, driving adapters, driven adapters |
| Onion | Domain model at the center, with inward dependencies | Domain, domain services, application services, infrastructure |
| Clean | Entities, application use cases, interface adapters, and dependency boundaries | Entities, use cases, controllers/presenters/gateways, frameworks |
A Clean Architecture system can also use Hexagonal Architecture's ports and adapters. Its repository gateway is functionally similar to an outbound port and adapter.
Likewise, the domain and use-case layers in Clean Architecture overlap significantly with inner layers in Onion Architecture.
These are not mutually exclusive technologies. A project can follow the shared dependency principle without attempting to implement three separate architectures.
When to Use Clean Architecture
Clean Architecture is especially useful when the application has meaningful business rules, multiple external integrations, a long expected lifetime, or strict testing requirements.
Good examples include:
- Payment and order processing systems.
- Insurance and financial applications.
- Complex business workflow platforms.
- Modular monoliths with multiple business capabilities.
- Applications exposed through HTTP and messaging interfaces.
- Systems that integrate with several third-party services.
- Backends where regulatory or business requirements evolve frequently.
For a small CRUD application with almost no business behavior, a simpler service-oriented structure may be easier to maintain.
Clean Architecture is most valuable when there are important policies worth protecting from technology changes.
Production Practices and Common Mistakes
Clean Architecture provides boundaries for source code, but it does not automatically make an application reliable, secure, or operationally mature.
Production systems still need deliberate choices around transactions, concurrency, event delivery, authorization, deployment, and observability.
Common Implementation Mistakes
Frequent mistakes include:
- Creating interfaces for every class: Adds indirection without establishing a meaningful boundary.
- Using ORM entities as domain entities: Couples business rules to persistence mapping and session behavior.
- Returning FastAPI responses from use cases: Makes application behavior depend on HTTP.
- Passing Pydantic request objects into the domain: Introduces framework schemas into inner layers.
- Building generic CRUD use cases for everything: Obscures actual business operations.
- Creating a presenter with no meaningful transformation: Adds unnecessary ceremony in simple applications.
- Committing each repository action independently: Can break atomicity across multi-step use cases.
- Using read-before-write uniqueness checks: Creates race conditions under concurrency.
- Hiding database-specific semantics behind misleading abstractions: Can conceal important consistency and transaction differences.
- Testing only through mocks: Leaves real adapters and integrations unverified.
- Assuming framework independence means effortless database replacement: Storage behavior and capabilities may differ substantially.
- Putting all application logic into one service class: Recreates a monolithic service layer under different names.
The solution is not to eliminate abstractions. It is to make each abstraction justify its existence through a clear responsibility, boundary, or independent testing benefit.
Production Checklist
Before deploying a Clean Architecture application, verify that:
- Entity code has no dependencies on HTTP frameworks or database libraries.
- Use cases express meaningful application operations.
- Input and output models belong to the application boundary.
- Controllers translate external requests into use-case inputs.
- Presenters translate results without changing business decisions.
- Gateway contracts reflect application needs.
- Database adapters do not leak ORM entities into the core.
- Dependencies are wired explicitly at the outer boundary.
- Transaction scopes correspond to complete business operations.
- Critical uniqueness and concurrency rules are enforced atomically.
- External calls have timeouts, retries, and idempotency strategies.
- Multi-system workflows have explicit consistency and recovery mechanisms.
- Schema changes use versioned migrations.
- Unit tests cover entities and use cases without external resources.
- Integration tests validate database gateways and external adapters.
- Logs, traces, and metrics support troubleshooting real business operations.
Frequently Asked Questions
Clean Architecture is often confused with a fixed folder template, a dependency injection framework, or a particular microservices structure. Its essential requirements are simpler: protect business policies and control source-code dependency direction.
Does Clean Architecture Require Exactly Four Layers?
No. Robert C. Martin's four circles illustrate conceptual responsibilities, not an obligatory number of folders or packages.
An implementation may add or combine modules as long as inner business policies do not depend on outer implementation details.
Do You Need an Interface for Every Class?
No. Interfaces are useful when they define meaningful boundaries, especially where use cases depend on external capabilities.
A simple domain value object does not automatically need an interface. Excessive interfaces can make otherwise straightforward code harder to navigate.
What Is a Presenter in Clean Architecture?
A presenter converts application output into a representation suitable for a user interface or delivery mechanism.
For an HTTP API, it may map field names, format values, or construct response data. In a small API, a simple formatting function may be enough. Complex applications can use an explicit output boundary implemented by a presenter.
Can Clean Architecture Use FastAPI or Django?
Yes. Clean Architecture does not reject frameworks. FastAPI, Django, and similar tools can handle routing, validation, authentication integration, and request lifecycles at the outer boundary.
The important rule is that business entities and use cases should not depend on those frameworks merely to express application behavior.
Is Clean Architecture Only for Microservices?
No. Clean Architecture can organize a monolith, modular monolith, microservice, desktop application, or command-line system.
It concerns source-code boundaries inside an application rather than how many independently deployed services the overall system contains.
Does Clean Architecture Cause Overengineering?
It can if applied mechanically. Too many interfaces, presenters, DTOs, and abstraction layers can create more code than the application's complexity justifies.
Use the pattern where independent business rules, integration boundaries, and testability provide practical value. Keep small applications simple when additional structure does not solve a real maintenance problem.
Conclusion
Clean Architecture separates software into business policies and implementation mechanisms. Entities express fundamental domain behavior, use cases implement application-specific operations, interface adapters translate data across boundaries, and frameworks and infrastructure remain at the edge.
The most important rule is that source-code dependencies point inward. This allows application logic to remain independent of web frameworks, database models, and external SDKs, while still using those technologies through well-defined adapters.
The practical benefits include fast business-rule tests, clearer responsibilities, controlled integration changes, and a codebase organized around application behavior rather than technical dependencies.
However, Clean Architecture should not become an exercise in maximizing layers or interfaces. Introduce boundaries where they protect real business behavior, and supplement them with sound transaction design, concurrency handling, integration testing, and operational practices.
Key Takeaway: Keep entities and use cases independent of infrastructure. Translate data at application boundaries, implement external capabilities through adapters, and choose the simplest structure that preserves clear dependency rules.
More Articles to Read
Continue exploring related architecture patterns and technologies:
- What Is Hexagonal Architecture? Ports and Adapters Explained — Understand driving and driven adapters and application ports.
- What Is Onion Architecture? Layers and Dependency Rules Explained — Explore domain-centric layers and inward dependencies.
- What Is Docker? — Learn how application code and infrastructure can be packaged into reproducible containers.
- What Is Infrastructure as Code? — Explore repeatable infrastructure management outside the business core.
Comments (0)