Hexagonal Architecture
Design applications with Ports and Adapters for testability, flexibility, and independence from external systems. Covers domain isolation, port interfaces, adapter implementations, dependency inversion, and the patterns that make applications resilient to infrastructure changes.
Hexagonal Architecture (Ports and Adapters) isolates your business logic from external systems — databases, APIs, message queues, and UI frameworks. The core domain knows nothing about how data is stored or how users interact. This makes the domain testable without any infrastructure, swappable without rewriting business logic, and resilient to technology changes.
Architecture
Traditional Layered Architecture:
Controller → Service → Repository → Database
Problem: Domain logic depends on database technology
Problem: Cannot test service without database
Problem: Changing database requires rewriting service
Hexagonal Architecture:
┌─────────────────────────────┐
Driving │ │ Driven
Adapters │ ┌───────────────┐ │ Adapters
│ │ │ │
┌──────────┐ ┌┤ │ DOMAIN │ ├┐ ┌──────────┐
│ REST API ├────┤│ │ (Business │ ├│────┤PostgreSQL│
└──────────┘ ││ Port│ Logic) │Port ││ └──────────┘
┌──────────┐ ├┤ │ │ ├┤ ┌──────────┐
│ CLI ├────┤│ │ Pure. │ ├│────┤ Redis │
└──────────┘ ││ │ No imports │ ││ └──────────┘
┌──────────┐ ├┤ │ from infra. │ ├┤ ┌──────────┐
│ gRPC ├────┤│ │ │ ├│────┤ Stripe │
└──────────┘ └┤ └───────────────┘ ├┘ └──────────┘
│ │
└─────────────────────────────┘
Ports: Interfaces that the domain defines
Adapters: Implementations that connect to real systems
Domain: Pure business logic, no infrastructure imports
Implementation
# PORTS: Interfaces defined by the domain
# The domain says WHAT it needs, not HOW to get it
class OrderRepository(Protocol):
"""Port: How the domain stores orders."""
def save(self, order: Order) -> None: ...
def get(self, order_id: str) -> Order | None: ...
def find_by_customer(self, customer_id: str) -> list[Order]: ...
class PaymentGateway(Protocol):
"""Port: How the domain processes payments."""
def charge(self, amount: Decimal, customer_id: str) -> PaymentResult: ...
def refund(self, payment_id: str) -> RefundResult: ...
class NotificationService(Protocol):
"""Port: How the domain sends notifications."""
def send(self, recipient: str, message: str) -> None: ...
# DOMAIN: Pure business logic, depends only on ports
class OrderService:
def __init__(
self,
orders: OrderRepository, # Port, not PostgreSQL
payments: PaymentGateway, # Port, not Stripe
notifications: NotificationService, # Port, not SendGrid
):
self.orders = orders
self.payments = payments
self.notifications = notifications
def place_order(self, customer_id: str, items: list[OrderItem]) -> Order:
order = Order.create(customer_id=customer_id, items=items)
# Business rules — no infrastructure knowledge
if order.total > Decimal("10000"):
order.require_approval()
payment = self.payments.charge(order.total, customer_id)
if not payment.success:
raise PaymentFailed(payment.error)
order.mark_paid(payment.id)
self.orders.save(order)
self.notifications.send(customer_id, f"Order {order.id} confirmed")
return order
# ADAPTERS: Real implementations of ports
class PostgresOrderRepository:
"""Adapter: PostgreSQL implementation of OrderRepository port."""
def __init__(self, session: Session):
self.session = session
def save(self, order: Order) -> None:
self.session.merge(OrderModel.from_domain(order))
self.session.commit()
def get(self, order_id: str) -> Order | None:
model = self.session.query(OrderModel).filter_by(id=order_id).first()
return model.to_domain() if model else None
class StripePaymentGateway:
"""Adapter: Stripe implementation of PaymentGateway port."""
def charge(self, amount: Decimal, customer_id: str) -> PaymentResult:
intent = stripe.PaymentIntent.create(
amount=int(amount * 100),
currency="usd",
customer=customer_id,
)
return PaymentResult(success=True, id=intent.id)
Testing
# Test domain logic WITHOUT any infrastructure
class InMemoryOrderRepository:
"""Test adapter: In-memory implementation."""
def __init__(self):
self.orders = {}
def save(self, order): self.orders[order.id] = order
def get(self, order_id): return self.orders.get(order_id)
class FakePaymentGateway:
"""Test adapter: Always succeeds."""
def charge(self, amount, customer_id):
return PaymentResult(success=True, id="fake-payment-123")
def test_place_order():
service = OrderService(
orders=InMemoryOrderRepository(),
payments=FakePaymentGateway(),
notifications=FakeNotificationService(),
)
order = service.place_order("customer-1", [OrderItem("widget", 2, Decimal("25.00"))])
assert order.total == Decimal("50.00")
assert order.status == "paid"
# No database, no Stripe, no HTTP — pure domain logic tested
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Domain imports infrastructure | Domain cannot be tested in isolation | Ports as interfaces, inject adapters |
| Too many ports | Over-abstraction, hard to navigate | One port per external concern, not per method |
| Adapters contain business logic | Logic duplicated, hard to test | All business logic in domain |
| No dependency inversion | Compile-time coupling to infrastructure | Domain defines ports, adapters implement them |
| Skip for simple CRUD | Unnecessary complexity | Hexagonal for complex domains, simple CRUD is fine without it |
Hexagonal Architecture is about protecting your most valuable code — the domain logic — from the most volatile code — the infrastructure. When your database, payment provider, or notification service changes, only the adapters change. The domain remains untouched.