Skip to content

Clean & Layered Architecture

Layered and clean architecture patterns address a fundamental challenge in software engineering: how do you organize code so that the business logic remains independent of frameworks, databases, and delivery mechanisms? Getting this right makes your application easier to test, maintain, and adapt to changing requirements.


Traditional Layered Architecture

The most common starting point for structuring an application is a three-tier layered architecture. Each layer has a specific responsibility and depends only on the layer directly below it.

┌──────────────────────────────────────────────┐
│ Presentation Layer │
│ (Controllers, Views, API endpoints, CLI) │
├──────────────────────────────────────────────┤
│ ▼ depends on ▼ │
├──────────────────────────────────────────────┤
│ Business Logic Layer │
│ (Services, Domain models, Use cases) │
├──────────────────────────────────────────────┤
│ ▼ depends on ▼ │
├──────────────────────────────────────────────┤
│ Data Access Layer │
│ (Repositories, ORM, Database queries) │
└──────────────────────────────────────────────┘
┌─────────────┐
│ Database │
└─────────────┘

How It Works

  • Presentation Layer: Handles HTTP requests, renders views, or formats API responses. It delegates all work to the business logic layer.
  • Business Logic Layer: Contains the core rules and processes. It orchestrates operations and enforces domain constraints.
  • Data Access Layer: Manages persistence. It reads from and writes to databases, file systems, or external APIs.

Benefits

  • Separation of concerns: Each layer has a clear, well-defined responsibility
  • Ease of understanding: Developers can quickly locate where a change belongs
  • Team organization: Different developers or teams can work on different layers

Problems with Naive Layering

While simple layered architecture is a good starting point, it suffers from several issues in practice:

  1. Business logic depends on infrastructure: The business layer directly references the data access layer. If you want to swap databases, you must change the business layer too.
  2. Testing is difficult: Testing business logic requires a real database (or complex mocks of database-specific interfaces).
  3. Domain model leaks: Database entities often become the domain model, coupling business rules to the persistence schema.
  4. Transitive dependency trap: Changes in the database layer ripple upward through the business layer to the presentation layer.

Hexagonal Architecture (Ports and Adapters)

Hexagonal architecture, introduced by Alistair Cockburn in 2005, solves the dependency problem by inverting the relationship between business logic and infrastructure. The core idea: the application defines ports (interfaces), and the outside world connects through adapters.

┌─────────────────┐
│ REST Adapter │
│ (Controller) │
└────────┬────────┘
┌────────▼────────┐
│ Input Port │
│ (Interface) │
┌──────── ├──────────────────┤ ────────┐
│ │ │ │
│ │ APPLICATION │ │
│ │ CORE │ │
┌──────┴──────┐ │ │ ┌──────┴──────┐
│ CLI │ │ Domain Models │ │ Message │
│ Adapter │──│ Use Cases │──│ Queue │
│ │ │ Business Rules │ │ Adapter │
└─────────────┘ │ │ └─────────────┘
│ │ │ │
│ ├──────────────────┤ │
│ │ Output Port │ │
└──────── │ (Interface) │ ────────┘
└────────┬────────┘
┌────────▼────────┐
│ Database │
│ Adapter (Repo) │
└─────────────────┘

Key Concepts

  • Application Core: Contains all business logic, domain models, and use cases. It has zero dependencies on external frameworks or infrastructure.
  • Ports: Interfaces defined by the core that describe what it needs from the outside world.
    • Input ports (driving ports): Define how the outside world can interact with the core (e.g., CreateOrderUseCase)
    • Output ports (driven ports): Define what infrastructure the core needs (e.g., OrderRepository, PaymentGateway)
  • Adapters: Concrete implementations that connect external systems to the ports.
    • Input adapters (driving adapters): REST controllers, CLI handlers, GraphQL resolvers
    • Output adapters (driven adapters): PostgreSQL repositories, Stripe payment adapters, SMTP email senders

The Inversion

In traditional layered architecture, business logic depends on the database layer. In hexagonal architecture, the database adapter depends on the business logic (through the output port interface). This inversion is the key insight — it keeps the core clean and testable.

Traditional: Controller ──► Service ──► Repository (concrete)
Database
Hexagonal: Controller ──► UseCase ◄── RepositoryPort (interface)
(adapter) (core) ▲
RepositoryAdapter ──► Database
(implements port)

Clean Architecture

Robert C. Martin (Uncle Bob) synthesized ideas from hexagonal architecture, onion architecture, and others into Clean Architecture in 2012. It organizes code into concentric layers with a strict dependency rule.

┌───────────────────────────────────────────────────────────┐
│ Frameworks & Drivers │
│ (Web framework, Database driver, UI, External APIs) │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Interface Adapters │ │
│ │ (Controllers, Presenters, Gateways, Repos) │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────┐ │ │
│ │ │ Application Layer │ │ │
│ │ │ (Use Cases / Application │ │ │
│ │ │ Services / Interactors) │ │ │
│ │ │ │ │ │
│ │ │ ┌───────────────────────────────────┐ │ │ │
│ │ │ │ Enterprise Layer │ │ │ │
│ │ │ │ (Entities / Domain Models / │ │ │ │
│ │ │ │ Business Rules) │ │ │ │
│ │ │ └───────────────────────────────────┘ │ │ │
│ │ │ │ │ │
│ │ └───────────────────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────┘

The Four Layers

1. Entities (Enterprise Business Rules)

The innermost layer contains the core business objects and the rules that govern them. Entities encapsulate the most general, high-level business rules — rules that would exist even if there were no software system.

  • Domain models with business logic methods
  • Value objects with validation rules
  • Domain exceptions and enumerations

2. Use Cases (Application Business Rules)

This layer contains application-specific business rules. Each use case orchestrates the flow of data to and from entities, directing them to apply their business rules to achieve a specific goal.

  • One class per use case (e.g., PlaceOrderUseCase, CancelOrderUseCase)
  • Defines input/output DTOs (Data Transfer Objects)
  • Orchestrates entity interactions
  • Defines repository and service interfaces (output ports)

3. Interface Adapters

This layer converts data between the format most convenient for the use cases and the format required by external systems.

  • Controllers that parse HTTP requests and call use cases
  • Presenters that format use case output for the view
  • Repository implementations that convert domain entities to database rows
  • Gateway implementations for external APIs

4. Frameworks & Drivers

The outermost layer contains frameworks, tools, and delivery mechanisms. This is where the “details” live — the web framework, the database driver, the UI rendering engine.

  • Web frameworks (Flask, Spring, Express)
  • Database engines (PostgreSQL, MongoDB)
  • Message queue clients (Kafka, RabbitMQ)
  • External service SDKs

The Dependency Rule

This rule ensures that:

  • Entities know nothing about use cases, controllers, or databases
  • Use cases know nothing about controllers or frameworks — they only reference entity classes and port interfaces
  • Adapters know about use cases (to call them) but not about frameworks
  • Frameworks know about adapters but are kept at the outer edge

Crossing Boundaries with Dependency Injection

When a use case needs to call a repository, it does not import the concrete implementation. Instead, it depends on an interface (port) defined in the use case layer. The concrete implementation in the outer layer is injected at runtime.

Use Case Layer: Interface Adapters Layer:
┌─────────────────────┐ ┌────────────────────────────┐
│ PlaceOrderUseCase │ │ PostgresOrderRepository │
│ │ │ │
│ - repo: OrderRepo │◄─ injects ─│ implements OrderRepo │
│ (interface) │ │ uses SQLAlchemy / psycopg2 │
│ │ │ │
│ + execute(input) │ │ + save(order) │
│ self.repo.save() │ │ + find_by_id(id) │
└─────────────────────┘ └────────────────────────────┘

Code Examples: Clean Architecture in Practice

Folder Structure

order_service/
├── domain/ # Entities (innermost layer)
│ ├── __init__.py
│ ├── order.py # Order entity with business rules
│ ├── order_item.py # OrderItem value object
│ └── exceptions.py # Domain-specific exceptions
├── application/ # Use Cases
│ ├── __init__.py
│ ├── ports/ # Interfaces (output ports)
│ │ ├── __init__.py
│ │ ├── order_repository.py # Abstract repository interface
│ │ └── payment_gateway.py # Abstract payment interface
│ ├── place_order.py # PlaceOrderUseCase
│ └── cancel_order.py # CancelOrderUseCase
├── adapters/ # Interface Adapters
│ ├── __init__.py
│ ├── api/ # Input adapters (driving)
│ │ ├── __init__.py
│ │ ├── order_controller.py # REST controller
│ │ └── schemas.py # Request/Response schemas
│ └── persistence/ # Output adapters (driven)
│ ├── __init__.py
│ ├── postgres_order_repo.py
│ └── models.py # SQLAlchemy models
├── infrastructure/ # Frameworks & Drivers
│ ├── __init__.py
│ ├── config.py # App configuration
│ ├── database.py # DB connection setup
│ └── container.py # Dependency injection container
└── main.py # Application entry point

Entity (Domain Layer)

domain/order.py
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import List
from uuid import UUID, uuid4
from domain.exceptions import (
EmptyOrderError,
OrderAlreadyCancelledError,
)
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
CANCELLED = "cancelled"
@dataclass
class OrderItem:
"""Value object representing a line item in an order."""
product_id: str
product_name: str
quantity: int
unit_price: float
def __post_init__(self):
if self.quantity <= 0:
raise ValueError("Quantity must be positive")
if self.unit_price < 0:
raise ValueError("Unit price cannot be negative")
@property
def subtotal(self) -> float:
return self.quantity * self.unit_price
@dataclass
class Order:
"""Entity: the aggregate root for orders."""
id: UUID = field(default_factory=uuid4)
customer_id: str = ""
items: List[OrderItem] = field(default_factory=list)
status: OrderStatus = OrderStatus.PENDING
created_at: datetime = field(default_factory=datetime.utcnow)
@property
def total(self) -> float:
return sum(item.subtotal for item in self.items)
def add_item(self, item: OrderItem) -> None:
self.items.append(item)
def place(self) -> None:
"""Business rule: an order must have at least one item."""
if not self.items:
raise EmptyOrderError("Cannot place an order with no items")
self.status = OrderStatus.CONFIRMED
def cancel(self) -> None:
"""Business rule: only pending/confirmed orders can be cancelled."""
if self.status == OrderStatus.CANCELLED:
raise OrderAlreadyCancelledError("Order is already cancelled")
if self.status == OrderStatus.SHIPPED:
raise OrderAlreadyCancelledError("Cannot cancel a shipped order")
self.status = OrderStatus.CANCELLED

Use Case (Application Layer)

application/ports/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional
from uuid import UUID
from domain.order import Order
class OrderRepository(ABC):
"""Output port: defines what the use case needs from persistence."""
@abstractmethod
def save(self, order: Order) -> None:
...
@abstractmethod
def find_by_id(self, order_id: UUID) -> Optional[Order]:
...
# application/place_order.py
from dataclasses import dataclass
from typing import List
from domain.order import Order, OrderItem
from application.ports.order_repository import OrderRepository
@dataclass
class PlaceOrderInput:
customer_id: str
items: List[dict] # [{"product_id": ..., "quantity": ..., ...}]
@dataclass
class PlaceOrderOutput:
order_id: str
total: float
status: str
class PlaceOrderUseCase:
"""Application service: orchestrates placing an order."""
def __init__(self, order_repo: OrderRepository):
# Depends on the INTERFACE, not a concrete implementation
self._order_repo = order_repo
def execute(self, input_data: PlaceOrderInput) -> PlaceOrderOutput:
order = Order(customer_id=input_data.customer_id)
for item_data in input_data.items:
order.add_item(OrderItem(
product_id=item_data["product_id"],
product_name=item_data["product_name"],
quantity=item_data["quantity"],
unit_price=item_data["unit_price"],
))
order.place() # Validates business rules
self._order_repo.save(order)
return PlaceOrderOutput(
order_id=str(order.id),
total=order.total,
status=order.status.value,
)

Adapter (Infrastructure Layer)

adapters/persistence/postgres_order_repo.py
from typing import Optional
from uuid import UUID
from sqlalchemy.orm import Session
from domain.order import Order, OrderItem, OrderStatus
from application.ports.order_repository import OrderRepository
from adapters.persistence.models import OrderModel, OrderItemModel
class PostgresOrderRepository(OrderRepository):
"""Output adapter: implements the repository port using PostgreSQL."""
def __init__(self, session: Session):
self._session = session
def save(self, order: Order) -> None:
model = OrderModel(
id=str(order.id),
customer_id=order.customer_id,
status=order.status.value,
created_at=order.created_at,
items=[
OrderItemModel(
product_id=item.product_id,
product_name=item.product_name,
quantity=item.quantity,
unit_price=item.unit_price,
)
for item in order.items
],
)
self._session.merge(model)
self._session.commit()
def find_by_id(self, order_id: UUID) -> Optional[Order]:
model = self._session.query(OrderModel).get(str(order_id))
if model is None:
return None
return self._to_domain(model)
@staticmethod
def _to_domain(model: OrderModel) -> Order:
order = Order(
id=UUID(model.id),
customer_id=model.customer_id,
status=OrderStatus(model.status),
created_at=model.created_at,
)
for item_model in model.items:
order.add_item(OrderItem(
product_id=item_model.product_id,
product_name=item_model.product_name,
quantity=item_model.quantity,
unit_price=item_model.unit_price,
))
return order
# adapters/api/order_controller.py
from flask import Blueprint, request, jsonify
from application.place_order import PlaceOrderUseCase, PlaceOrderInput
order_bp = Blueprint("orders", __name__)
def create_order_controller(place_order: PlaceOrderUseCase):
"""Input adapter: converts HTTP requests into use case calls."""
@order_bp.route("/orders", methods=["POST"])
def place_order_endpoint():
data = request.get_json()
input_data = PlaceOrderInput(
customer_id=data["customer_id"],
items=data["items"],
)
output = place_order.execute(input_data)
return jsonify({
"order_id": output.order_id,
"total": output.total,
"status": output.status,
}), 201
return order_bp

Testing Benefits

The dependency rule makes unit testing the business logic trivial because the core has no infrastructure dependencies. You can test use cases with simple in-memory fakes:

tests/test_place_order.py
from application.place_order import PlaceOrderUseCase, PlaceOrderInput
from application.ports.order_repository import OrderRepository
from domain.order import Order
from typing import Optional, Dict
from uuid import UUID
class InMemoryOrderRepository(OrderRepository):
"""A simple fake for testing -- no database needed."""
def __init__(self):
self._store: Dict[str, Order] = {}
def save(self, order: Order) -> None:
self._store[str(order.id)] = order
def find_by_id(self, order_id: UUID) -> Optional[Order]:
return self._store.get(str(order_id))
def test_place_order_creates_confirmed_order():
repo = InMemoryOrderRepository()
use_case = PlaceOrderUseCase(order_repo=repo)
result = use_case.execute(PlaceOrderInput(
customer_id="customer-123",
items=[{
"product_id": "prod-1",
"product_name": "Widget",
"quantity": 2,
"unit_price": 9.99,
}],
))
assert result.status == "confirmed"
assert result.total == 19.98
assert len(repo._store) == 1
def test_place_order_fails_with_empty_items():
repo = InMemoryOrderRepository()
use_case = PlaceOrderUseCase(order_repo=repo)
try:
use_case.execute(PlaceOrderInput(
customer_id="customer-123",
items=[],
))
assert False, "Should have raised an error"
except Exception:
assert len(repo._store) == 0 # Nothing was saved

Comparing the Three Approaches

AspectTraditional LayeredHexagonal (Ports & Adapters)Clean Architecture
Dependency directionTop-down (presentation to data)Inward (adapters depend on core)Inward (dependency rule)
Business logic isolationPartial — depends on data layerFull — core has no infrastructure depsFull — entities are the innermost layer
TestabilityRequires mocking infrastructureEasy — swap adapters with fakesEasy — inner layers are pure logic
ComplexityLowMediumMedium-High
When to useSimple CRUD apps, prototypesMedium-complexity apps with multiple I/OComplex domains with rich business rules
Key insightSeparate concerns by layerDefine ports, plug in adaptersDependency rule with concentric layers

Practical Guidelines

When to Use Clean / Hexagonal Architecture

  • The application has complex business rules that need protection from infrastructure changes
  • You need to support multiple delivery mechanisms (REST, GraphQL, CLI, message queues)
  • Testability is a high priority
  • The data storage might change (e.g., migrating from SQL to NoSQL)
  • The project will be maintained for years by multiple teams

When to Keep It Simple

  • The application is a simple CRUD with little business logic
  • It is a prototype or proof of concept
  • The team is small and the domain is well-understood
  • The added indirection would slow down development without proportional benefit

Next Steps