Hexagonal Architecture — Ports and Adapters for Clean Domain Isolation
The problem with layered architecture#
Traditional layered architecture (controller → service → repository) seems clean until you realize the entire application depends on the database. Change the database, and you change everything. Swap REST for gRPC, and you rewrite half the app.
The domain — the most valuable part of your code — is trapped between infrastructure concerns.
What hexagonal architecture solves#
Hexagonal architecture (also called ports and adapters) puts the domain at the center. Infrastructure lives on the outside. The domain never imports anything from the outside world.
The core idea: your business logic should not know or care how data arrives or where it goes.
The three layers#
Domain (center)#
Pure business logic. No frameworks, no HTTP, no SQL. Just rules.
class Order:
def __init__(self, items, customer_id):
self.items = items
self.customer_id = customer_id
self.status = "pending"
def confirm(self):
if not self.items:
raise ValueError("Cannot confirm empty order")
self.status = "confirmed"
def total(self):
return sum(item.price * item.quantity for item in self.items)
Ports (interfaces)#
Ports define what the domain needs from the outside world (driven ports) and what the outside world can ask of the domain (driving ports).
# Driven port — the domain needs this
class OrderRepository:
def save(self, order: Order) -> None: ...
def find_by_id(self, order_id: str) -> Order: ...
# Driven port — the domain needs this
class PaymentGateway:
def charge(self, amount: float, customer_id: str) -> bool: ...
# Driving port — the outside world calls this
class OrderService:
def place_order(self, items, customer_id) -> Order: ...
Adapters (implementations)#
Adapters plug into ports. They translate between external systems and the domain.
# Adapter for OrderRepository port
class PostgresOrderRepository(OrderRepository):
def __init__(self, connection):
self.conn = connection
def save(self, order):
self.conn.execute(
"INSERT INTO orders ...",
[order.customer_id, order.status]
)
def find_by_id(self, order_id):
row = self.conn.execute(
"SELECT * FROM orders WHERE id = %s", [order_id]
)
return self._to_domain(row)
# Adapter for PaymentGateway port
class StripePaymentGateway(PaymentGateway):
def charge(self, amount, customer_id):
return stripe.Charge.create(
amount=int(amount * 100),
customer=customer_id
)
Dependency inversion in practice#
The key rule: dependencies point inward. The domain defines interfaces. Adapters implement them. The domain never imports an adapter.
Adapter → Port ← Domain
Wiring happens at the composition root — typically your main function or dependency injection container:
# composition root
db = PostgresConnection(config.DATABASE_URL)
repo = PostgresOrderRepository(db)
payment = StripePaymentGateway(config.STRIPE_KEY)
order_service = OrderServiceImpl(repo, payment)
app = create_flask_app(order_service)
The domain has zero knowledge of Postgres, Stripe, or Flask.
Testing with fake adapters#
This is where hexagonal architecture pays for itself. You test the domain with fake adapters — no database, no network, no containers.
class InMemoryOrderRepository(OrderRepository):
def __init__(self):
self.orders = {}
def save(self, order):
self.orders[order.id] = order
def find_by_id(self, order_id):
return self.orders.get(order_id)
class FakePaymentGateway(PaymentGateway):
def __init__(self, should_succeed=True):
self.should_succeed = should_succeed
self.charges = []
def charge(self, amount, customer_id):
self.charges.append((amount, customer_id))
return self.should_succeed
def test_place_order():
repo = InMemoryOrderRepository()
payment = FakePaymentGateway(should_succeed=True)
service = OrderServiceImpl(repo, payment)
order = service.place_order(
items=[Item("widget", 10.0, 2)],
customer_id="cust-1"
)
assert order.status == "confirmed"
assert len(payment.charges) == 1
assert payment.charges[0][0] == 20.0
Tests run in milliseconds. No Docker. No test database. No flaky network calls.
Driving vs driven adapters#
Driving adapters (left side) initiate actions into the domain:
- REST controllers
- gRPC handlers
- CLI commands
- Message consumers (Kafka, SQS)
Driven adapters (right side) are called by the domain through ports:
- Database repositories
- Payment gateways
- Email services
- Message publishers
Hexagonal vs clean architecture vs onion architecture#
All three share the same core principle: depend inward, not outward.
| Aspect | Hexagonal | Clean Architecture | Onion |
|---|---|---|---|
| Creator | Alistair Cockburn | Robert C. Martin | Jeffrey Palermo |
| Layers | Domain + Ports + Adapters | Entities + Use Cases + Interface Adapters + Frameworks | Domain + Domain Services + Application Services + Infrastructure |
| Key metaphor | Hexagon with ports | Concentric circles | Onion layers |
| Emphasis | Symmetry of driving/driven | Use case as first-class concept | Domain model at the core |
In practice, they produce nearly identical code. The vocabulary differs more than the architecture.
When hexagonal architecture helps#
- Multiple delivery mechanisms — same domain served via REST, gRPC, CLI, and event consumers
- Swappable infrastructure — migrate from Postgres to DynamoDB without touching business logic
- Testability matters — you want fast, reliable unit tests for complex domain logic
- Long-lived projects — the investment in separation pays off over years of maintenance
When it is overkill#
- CRUD apps with minimal business logic
- Prototypes and throwaway code
- Small services with a single adapter per port (you are writing interfaces for one implementation)
Common mistakes#
- Leaking infrastructure into the domain — importing ORM models in business logic
- Too many ports — not every function call needs an interface
- Anemic domain — putting all logic in services while entities are just data bags
- Skipping the composition root — scattering wiring logic across the codebase
Visualize your architecture boundaries#
Map your ports, adapters, and domain boundaries — try Codelit to generate an interactive architecture diagram.
Key takeaways#
- Domain at the center — business logic depends on nothing external
- Ports are interfaces — contracts between domain and infrastructure
- Adapters are implementations — plug in Postgres, Stripe, or in-memory fakes
- Dependencies point inward — adapters depend on ports, never the reverse
- Test with fakes — millisecond tests with no infrastructure
- Hexagonal, clean, and onion are variations of the same dependency inversion principle
Article #307 on Codelit — Keep building, keep shipping.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Related articles
Try these templates
Netflix Video Streaming Architecture
Global video streaming platform with adaptive bitrate, CDN distribution, and recommendation engine.
10 componentsSearch Engine Architecture
Web-scale search with crawling, indexing, ranking, and sub-second query serving.
8 componentsMultiplayer Game Backend
Real-time multiplayer game server with matchmaking, state sync, leaderboards, and anti-cheat.
8 componentsBuild this architecture
Generate an interactive Hexagonal Architecture in seconds.
Try it in Codelit →
Comments