Clean Architecture

Concentric rings where Use Cases are king, ports make every boundary explicit, and nothing in the core ever touches the outside world

Introduction

Clean Architecture is a software design philosophy proposed by Robert C. Martin (Uncle Bob) in 2012. Like Onion Architecture, it organizes code into concentric rings with a strict inward-only dependency rule. At its core, it follows the same simple principle: high-level components must not depend on low-level components. Its central insight is that the Use Case ring, not the Entity ring, is the most important layer, because it expresses exactly what the application does.

What sets Clean Architecture apart from its siblings is the explicitness of its boundaries: Input Ports define how callers invoke a use case, and Output Ports define what a use case needs from the outside world. Every crossing of a layer boundary is a named interface, making the architecture self-documenting and every layer independently replaceable.

Quick Navigation

1. The Four Rings

Entities, Use Cases, Interface Adapters, Frameworks

2. Input and Output Ports

The contracts that make every boundary explicit

3. Key Concepts

Interactors, Gateways, DTOs, Composition Root

4. When to Use / Avoid

Fit criteria and anti-patterns

5. Pros and Cons

Trade-offs at a glance

6. vs. Onion and Hexagonal

Side-by-side comparison of the family

1. The Four Rings

Clean Architecture defines four concentric rings. The fundamental constraint is the same as in Onion Architecture: source-code dependencies can only point inward. An outer ring may import from any inner ring; an inner ring must never import from an outer one.

Clean Architecture: Ring Overview

EntitiesEnterprise rulesUse CasesApp business rulesInterface AdaptersGateways / ControllersFrameworks & DriversDB / Web / UI / CLIdepends onimplements portsdepends oninjects via DI

Figure 1: All dependencies point inward. The Composition Root (Frameworks & Drivers) is the only place that touches all rings.

Entities (innermost)

Enterprise-wide business rules. These are the objects that would exist even if the application did not, they represent the core business domain.

  • Aggregate roots - e.g., Order with status transition enforcement
  • Value Objects - Money (frozen), OrderStatus (state machine)
  • Domain Exceptions - InsufficientStockError, InvalidOrderTransitionError
Use Cases

Application-specific business rules. This ring is the heart of Clean Architecture, it defines exactly what the application does, expressed as named use cases.

  • Input Ports - abstract interfaces defining use case entry points (IPlaceOrderUseCase)
  • Output Ports - abstract interfaces for what use cases need (IOrderGateway)
  • Interactors - concrete use case implementations (PlaceOrderInteractor)
  • DTOs + Mappers - PlaceOrderRequest, OrderResponse, _mappers.py
Interface Adapters

The translation zone. Converts data from the format used by Use Cases and Entities into the format expected by external tools, and vice versa.

  • Gateways - implement Output Ports (InMemoryOrderGateway implements IOrderGateway)
  • Controllers / Presenters - translate HTTP requests into use case requests and responses back into HTTP responses
  • No business logic - only format conversion and routing
Frameworks and Drivers (outermost)

The most volatile ring. Contains all external tools and the composition root that wires everything together.

  • Database drivers - SQLAlchemy, psycopg2, motor
  • Web frameworks - FastAPI, Django, Flask
  • Composition root - main.py instantiates and injects all concrete types

2. Input and Output Ports

Ports are the defining feature of Clean Architecture. Every crossing of a layer boundary is mediated by a named interface. This is what makes Clean Architecture the most explicit member of the inward-dependency family.

Input Ports: Driving the Application

An Input Port is an abstract interface that defines the contract any caller must satisfy to invoke a use case. It lives in the Use Cases ring. The caller (a controller, a CLI command, a task runner) depends on the Input Port interface, not on the concrete Interactor. This means you can swap the Interactor implementation without changing a single line of calling code.

# service/use_cases/ports/input/i_place_order_use_case.py
# Input Port: defines the contract any caller must use to invoke this use case.
# The task.py (Frameworks & Drivers) depends on this interface, NOT on
# the concrete PlaceOrderInteractor. This decouples the delivery mechanism
# from the implementation.

from abc import ABC, abstractmethod
from service.use_cases.dtos.order_dtos import OrderResponse, PlaceOrderRequest

class IPlaceOrderUseCase(ABC):
    """Input port that PlaceOrderInteractor must implement."""

    @abstractmethod
    def execute(self, request: PlaceOrderRequest) -> OrderResponse:
        """Place a new order; raise domain exceptions on validation failure."""

Why it matters: In the demo, EcommerceOrderTask stores a reference typed as IPlaceOrderUseCase. In tests, you can inject a fake interactor that returns canned responses without touching any real business logic.

Output Ports: What the Use Case Needs

An Output Port is an abstract interface that the Use Case defines to express what it needs from the outside world. It also lives in the Use Cases ring, the use case owns the contract. Interface Adapters implement it. This is the key difference from Onion Architecture, where repository interfaces belong to the Domain ring.

# service/use_cases/ports/output/i_order_gateway.py
# Output Port: defines what THIS use case needs from the outside world.
# It lives in the Use Cases layer, the use case decides what it needs.
# Concrete implementations (InMemoryOrderGateway, PostgresOrderGateway)
# live in the Interface Adapters layer and implement this interface.

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

class IOrderGateway(ABC):
    """Output port for Order persistence. Concrete adapters live in interface_adapters."""

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

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

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

    @abstractmethod
    def delete(self, order_id: str) -> None: ...

The Interactor receives Output Port implementations through constructor injection:

# service/use_cases/interactors/place_order_interactor.py
# The Interactor is the concrete use case implementation.
# It implements the Input Port (IPlaceOrderUseCase) and
# depends ONLY on Output Ports and Entities, never on Interface Adapters.

class PlaceOrderInteractor(IPlaceOrderUseCase):

    def __init__(
        self,
        order_gateway: IOrderGateway,      # <- output port
        product_gateway: IProductGateway,  # <- output port
        customer_gateway: ICustomerGateway,# <- output port
        pricing_service: OrderPricingService,  # <- use cases service
    ) -> None:
        self._order_gateway    = order_gateway
        self._product_gateway  = product_gateway
        self._customer_gateway = customer_gateway
        self._pricing          = pricing_service

    def execute(self, request: PlaceOrderRequest) -> OrderResponse:
        customer = self._customer_gateway.find_by_id(request.customer_id)
        if customer is None:
            raise CustomerNotFoundError(...)

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

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

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

Why Output Ports belong in Use Cases, not Entities: The use case is the one that decides it needs find_by_customer_id(). Entities have no opinion about persistence, they are pure business objects. Moving the port to the Use Cases ring keeps Entities completely clean of any persistence knowledge.

3. Key Concepts

An Interactor is Clean Architecture's name for a concrete use case implementation. One Interactor per use case operation. Each Interactor implements an Input Port interface and depends on Output Port interfaces.

In the demo: PlaceOrderInteractor, CancelOrderInteractor, GetOrderInteractor, ListCustomerOrdersInteractor. The naming convention makes the application's capabilities self-documenting, you can read the interactors folder and immediately know everything the system does.

A Gateway is a concrete implementation of an Output Port. It lives in the Interface Adapters ring and contains all knowledge of a specific persistence or external service technology.

In the demo: InMemoryOrderGateway implements IOrderGateway. Swapping to a PostgreSQL backend means writing a PostgresOrderGatewaythat also implements IOrderGateway, zero changes to interactors or entities. The term "Gateway" (vs. "Repository" in Onion) signals that it can front any external resource: a database, an HTTP API, a message queue, or a cache.

In Clean Architecture, Entities are the innermost ring and contain enterprise-wide business rules, rules that would be true even if the application did not exist. They are not "the domain layer" in the DDD sense; they are the highest-level policies.

Importantly, Entities in Clean Architecture carry no persistence knowledge at all. They do not define repository interfaces (that is the job of Output Ports in the Use Cases ring). The Orderaggregate root, Money value object, and OrderStatusstate machine are pure logic with zero external dependencies.

Clean Architecture uses named request/response DTOs rather than generic commands. PlaceOrderRequest carries input into the interactor; OrderResponse carries output back to the caller. Both live in the Use Cases ring alongside the ports.

_mappers.py in the Use Cases ring handles the conversion between domain Entities and response DTOs. Entities never escape the Use Cases ring as raw objects, callers always receive DTOs.

The composition root lives in the Frameworks and Drivers ring. It is the single point where all concrete classes from all rings are instantiated and injected. It is the only file in the codebase that imports from both Interface Adapters and Use Cases simultaneously.

# service/main.py: Composition Root (Frameworks & Drivers layer)
# The ONLY place in the codebase where concrete implementations are instantiated.
# All dependencies flow inward from here via constructor injection.

def execute() -> bool:

    # Interface Adapters: concrete gateway implementations
    order_gateway   = InMemoryOrderGateway()
    product_gateway = InMemoryProductGateway()
    customer_gateway= InMemoryCustomerGateway()

    # Use Cases: domain service
    pricing_service = OrderPricingService()

    # Use Cases: interactors, receive Output Port implementations via injection
    place_order  = PlaceOrderInteractor(
        order_gateway=order_gateway,
        product_gateway=product_gateway,
        customer_gateway=customer_gateway,
        pricing_service=pricing_service,
    )
    cancel_order = CancelOrderInteractor(order_gateway=order_gateway, product_gateway=product_gateway)
    get_order    = GetOrderInteractor(order_gateway=order_gateway)
    list_orders  = ListCustomerOrdersInteractor(order_gateway=order_gateway, customer_gateway=customer_gateway)

    # Frameworks & Drivers: task depends on Input Ports (not concrete interactors)
    task = EcommerceOrderTask(
        place_order=place_order,   # IPlaceOrderUseCase
        cancel_order=cancel_order, # ICancelOrderUseCase
        get_order=get_order,       # IGetOrderUseCase
        list_orders=list_orders,   # IListCustomerOrdersUseCase
        ...
    )
    task.execute()

4. When to Use and When to Avoid

Use Clean Architecture when...
  • Application business rules are complex and central - the Use Cases ring gives them a protected, first-class home with explicit named interfaces.
  • Maximum replaceability is required - you anticipate swapping databases, frameworks, or external APIs over the system's lifetime.
  • Multiple delivery mechanisms share the same logic - REST API, CLI, and queue consumer all depend on the same Input Ports.
  • Explicit use-case contracts are a priority - Input Ports make the API of each use case a first-class artifact that teams can review and version.
  • High testability at every layer - Interactors are testable with fake Output Port implementations; no real database or HTTP server needed.
Avoid Clean Architecture when...
  • The app is mostly CRUD - Input Ports, Output Ports, Interactors, Gateways, and DTOs for every entity is heavy ceremony when there are no real business rules to protect.
  • You want less ceremony than this - Onion Architecture delivers 90% of the same protection with less boilerplate; prefer it when explicit Input Ports are not needed.
  • The team is new to the pattern - the terminology (interactor, gateway, port) and the extra interface layer require a period of adjustment.
  • Short-lived prototypes - the structural investment pays back over a long application lifetime; for throwaway code it is overkill.

5. Pros and Cons

AdvantagesTrade-offs
Use cases are first-class citizens with explicit Input Port interfaces - the application's capabilities are self-documentingMost verbose of the clean-architecture family: Input Ports + Output Ports + Interactors + Gateways + DTOs per use case
Entities are completely pure - no persistence contracts, no framework knowledge, zero external dependenciesSteeper learning curve than Onion Architecture due to additional port layer and different terminology
Swapping databases or external APIs requires only a new Gateway in Interface Adapters - entities and interactors are untouchedFor simple CRUD the overhead of interfaces per boundary is significant ceremony with little payoff
Input Ports decouple delivery mechanisms from interactors - a new controller or queue consumer just depends on the portComposition root grows in complexity proportionally with the number of use cases and gateways
Every layer is independently testable: entities with unit tests, interactors with fake gateways, adapters with integration testsTerminology (interactor, gateway, port) can cause confusion in teams accustomed to standard repository or service naming

6. Clean Architecture vs. Onion and Hexagonal

These three patterns enforce the same core rule (inward-only dependencies) but differ in where they draw layer boundaries, how explicit the ports are, and what terminology they use.

DimensionClean ArchitectureOnion ArchitectureHexagonal
Coined byUncle Bob, 2012Jeffrey Palermo, 2008Alistair Cockburn, 2005
Ring / layer count4 explicit rings4 rings (Domain / App / Infra / Presentation)No fixed ring count
Gateway interfaces live inUse Cases ring (Output Ports)Domain ring (Repository interfaces)Inside the application hexagon
Input Ports (use-case interfaces)Explicit ABCs per use caseUsually absent, concrete classes onlyExplicit (driving ports)
Use case implementations calledInteractorsUseCasesNot standardized
Data-access implementations calledGatewaysRepositoriesAdapters
Most central / protected ringUse CasesDomain (Entities)Application core
Dependency inversion formalized viaInput + Output Ports (explicit)Repository ABCs (partial)Driving + driven ports (explicit)
Clean Architecture

Maximum explicitness. Every layer boundary is a named interface. Output Ports in the Use Cases ring signal exactly what each use case needs from the outside world. Entities are the purest they can be, zero persistence knowledge. Choose this when explicit use-case contracts and maximum replaceability are the priority.

Onion Architecture

Same inward-dependency rule with less ceremony. Repository interfaces live in the domain, giving the domain ownership of its own persistence contracts. No Input Ports by default, use cases are concrete classes. Choose this when you want the same structural protections without the extra port layer.

Hexagonal Architecture

Emphasizes the diversity of entry and exit points: any number of driving adapters (REST, CLI, tests) and driven adapters (DB, email, queue). No fixed ring count, just an inner application core and an outer adapter layer. Choose this when the primary concern is supporting many interaction surfaces.

Practical guidance

In practice, teams often use the names interchangeably. All three enforce the same core rule and are architecturally equivalent at the conceptual level. If you see a codebase labeled "Hexagonal" with explicit Output Port interfaces and a composition root, it is functionally identical to Clean Architecture. Pick the name that resonates with your team, but enforce the dependency rule consistently.

Key Takeaways

  • Use Cases are the most protected ring - not Entities. Clean Architecture places application behavior at the center of the design.
  • Output Ports live in the Use Cases ring - the use case decides what it needs from the outside world; Entities have zero persistence knowledge.
  • Input Ports make use cases explicitly callable - callers depend on the port interface, not on the concrete Interactor, enabling independent replacement.
  • Interface Adapters are the translation zone - they convert between the format Use Cases need and the format external tools speak. No business logic belongs here.
  • The composition root is the single wiring point - the only file that touches all rings simultaneously.
  • Clean, Onion, and Hexagonal enforce the same rule - they differ in terminology and where they draw port boundaries. The dependency direction (always inward) is the invariant that matters.

Bonus: Working Code Demo

A complete Python implementation of Clean Architecture is available as a reference project. It models the same e-commerce order domain as the Onion Architecture demo, making a direct structural comparison possible between the two patterns.

The project includes:

  • Entities ring - Order aggregate root, Moneyvalue object, OrderStatus state machine, domain exceptions, zero external dependencies
  • Input Ports - one ABC per use case in service/use_cases/ports/input/: IPlaceOrderUseCase, ICancelOrderUseCase, IGetOrderUseCase, IListCustomerOrdersUseCase
  • Output Ports - IOrderGateway, IProductGateway, ICustomerGateway in service/use_cases/ports/output/
  • Interactors - concrete use case implementations in service/use_cases/interactors/ including PlaceOrderInteractorwith two-pass stock validation
  • Interface Adapters - in-memory gateway implementations in service/interface_adapters/gateways/
  • Composition root - service/main.py wires all rings; run with python manager.py run, tests with python manager.py run-tests
Architecture EssentialsLesson 10