Clean Architecture in Practice: Hexagonal, Onion, and Ports-and-Adapters

The Problem Clean Architecture Solves

You've got a Flask app that started simple. Routes call service functions, service functions query the database directly, and business logic is scattered across all three layers. Six months in, you want to swap PostgreSQL for DynamoDB and realize the database-specific code is woven into everything. Or you need to add a CLI interface alongside the HTTP API, and your "services" are tightly coupled to Flask's request context.

Clean architecture addresses this by enforcing a dependency rule: inner layers (business logic) don't know about outer layers (frameworks, databases, HTTP). The business rules don't care whether data comes from a REST API or a CLI command, or whether it's stored in PostgreSQL or a CSV file.

Three Flavors, One Idea

Hexagonal Architecture (Alistair Cockburn, 2005), Onion Architecture (Jeffrey Palermo, 2008), and Clean Architecture (Robert Martin, 2012) are all expressing the same core concept with different terminology. The differences are mostly academic.

The Common Structure

At the center: Domain entities and business rules. These are pure objects with no dependencies on frameworks or infrastructure. An Order entity knows that it can't have a negative total and that it transitions through specific states. It doesn't know about databases or HTTP.

Next ring: Use cases / Application services. These orchestrate business rules. A PlaceOrder use case coordinates creating the order, checking inventory, and triggering payment. It depends on the domain layer and on abstract interfaces (ports) for infrastructure.

Outer ring: Adapters / Infrastructure. Database repositories, HTTP controllers, message queue consumers, email services. These implement the abstract interfaces defined by the inner layers.

# Domain entity — no imports from frameworks or infrastructure
class Order:
    def __init__(self, customer_id: str, items: list[OrderItem]):
        if not items:
            raise ValueError("Order must have at least one item")
        self.customer_id = customer_id
        self.items = items
        self.status = OrderStatus.PENDING
        self.total = sum(item.price * item.quantity for item in items)

    def confirm(self):
        if self.status != OrderStatus.PENDING:
            raise InvalidStateTransition(
                f"Can't confirm order in {self.status} state"
            )
        self.status = OrderStatus.CONFIRMED

    def cancel(self):
        if self.status == OrderStatus.SHIPPED:
            raise InvalidStateTransition("Can't cancel shipped order")
        self.status = OrderStatus.CANCELLED

Ports and Adapters (Hexagonal)

In hexagonal architecture, "ports" are interfaces that the application core defines. "Adapters" are implementations that connect the core to the outside world.

Driving ports (left side) are how the outside world triggers application logic: an HTTP controller, a CLI command handler, a message consumer. They call into the application core.

Driven ports (right side) are how the application core accesses infrastructure: a repository interface, a payment gateway interface, a notification service interface. The core defines the interface; the infrastructure provides the implementation.

# Port (defined by the application core)
from abc import ABC, abstractmethod

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

    @abstractmethod
    def find_by_id(self, order_id: str) -> Order | None: ...

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


# Adapter (infrastructure implementation)
class PostgresOrderRepository(OrderRepository):
    def __init__(self, connection_pool):
        self.pool = connection_pool

    def save(self, order: Order) -> None:
        with self.pool.connection() as conn:
            conn.execute(
                "INSERT INTO orders (id, customer_id, status, total) "
                "VALUES (%s, %s, %s, %s) "
                "ON CONFLICT (id) DO UPDATE SET status=%s, total=%s",
                (order.id, order.customer_id, order.status.value,
                 order.total, order.status.value, order.total)
            )

    def find_by_id(self, order_id: str) -> Order | None:
        with self.pool.connection() as conn:
            row = conn.execute(
                "SELECT * FROM orders WHERE id = %s", (order_id,)
            ).fetchone()
            return self._to_entity(row) if row else None

Use Cases as the Application's API

Use cases (or application services) are where your business workflows live. They receive a command, orchestrate domain entities and driven ports, and return a result.

class PlaceOrderUseCase:
    def __init__(
        self,
        order_repo: OrderRepository,
        inventory: InventoryService,
        payment: PaymentGateway,
        notifier: NotificationService
    ):
        self.order_repo = order_repo
        self.inventory = inventory
        self.payment = payment
        self.notifier = notifier

    def execute(self, command: PlaceOrderCommand) -> OrderResult:
        # Build domain entity
        items = [OrderItem(i.sku, i.quantity, i.price)
                 for i in command.items]
        order = Order(command.customer_id, items)

        # Check inventory
        for item in items:
            if not self.inventory.check_available(item.sku, item.quantity):
                raise InsufficientInventory(item.sku)

        # Reserve inventory
        self.inventory.reserve(order.id, items)

        try:
            # Process payment
            self.payment.charge(command.customer_id, order.total)
            order.confirm()
        except PaymentFailed:
            self.inventory.release(order.id, items)
            order.cancel()
            raise

        self.order_repo.save(order)
        self.notifier.send_order_confirmation(order)
        return OrderResult(order_id=order.id, status=order.status)

Notice that the use case depends only on abstractions (interfaces). It doesn't import Flask, SQLAlchemy, or Stripe. You could test it with in-memory fakes for every dependency.

When Is This Worth the Complexity?

Here's where I'll push back on clean architecture evangelists: this pattern adds real complexity. More files, more abstractions, more indirection. You need to weigh that against the benefits.

Worth it when:

  • The application has complex business logic that changes independently from infrastructure concerns
  • You expect to swap infrastructure components (different database, different message broker)
  • Multiple interfaces exist (HTTP API + CLI + event consumer all triggering the same workflows)
  • The team is large enough that clear boundaries prevent stepping on each other's code
  • Thorough unit testing of business logic is important (testing without database setup)

Not worth it when:

  • It's a CRUD app with minimal business logic — the "clean" architecture just adds ceremony around pass-through database calls
  • You're building a prototype or MVP that might be thrown away
  • The team is small (2-3 people) and everyone understands the whole codebase
  • You have one interface and one database and no plans to change either

The worst outcome is applying clean architecture to a CRUD service and ending up with a UserRepository interface that has exactly one implementation and a CreateUserUseCase that does nothing but call repo.save(user). That's not architecture — it's bureaucracy.

Project Structure

Here's a directory layout that works well for medium-sized Python projects:

src/
  domain/
    entities/        # Order, Customer, Product
    value_objects/   # Money, Address, Email
    exceptions.py    # Domain-specific errors
  application/
    use_cases/       # PlaceOrder, CancelOrder
    ports/           # Repository interfaces, service interfaces
    dto.py           # Command/result objects
  infrastructure/
    persistence/     # PostgresOrderRepository, RedisCache
    http/            # Flask routes, FastAPI endpoints
    messaging/       # Kafka consumer, RabbitMQ publisher
    external/        # Stripe adapter, SendGrid adapter
  config.py          # Dependency injection / wiring

The key rule: arrows point inward. infrastructure imports from application and domain. application imports from domain. domain imports from nothing outside itself.

Dependency injection wires everything together at the entry point. In Python, you can use a simple factory function or a DI container like dependency-injector. Don't overthink this part — constructor injection and a composition root function work fine for most projects.