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.

JavaScript Runtime Comparison
Runtime Cold Start HTTP Throughput TypeScript
Node.js 22~50 ms~85k req/sVia tsx/ts-node
Deno 2~30 ms~120k req/sBuilt-in
Bun 1.x~10 ms~260k req/sBuilt-in
Cloudflare Workers0 ms (pre-warm)N/A (edge)Via Wrangler

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.

This connects to the ideas in Monitoring and Alerting: Avoiding Alert Fatigue with Actiona.

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.

We covered a related topic in Terraform for Teams: State Management, Modules, and CI/CD In.

# 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:

This connects to the ideas in Secrets Management: HashiCorp Vault, AWS Secrets Manager, an.

  • 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.

References & Standards: MDN Web Docs · W3C Standards