Introduction
Software architecture has a profound impact on maintainability, testability, and longevity. Hexagonal architectureโalso known as ports and adapters or clean architectureโprovides a powerful pattern for building applications that are isolated from external dependencies, easy to test, and adaptable to changing requirements.
In 2026, hexagonal architecture remains highly relevant as organizations seek to build systems that can evolve over time. This guide explores hexagonal architecture principles, implementation strategies, and best practices for creating maintainable software.
Understanding Hexagonal Architecture
What Is Hexagonal Architecture?
Hexagonal architecture, pioneered by Alistair Cockburn, is a software design pattern that organizes code around business logic while isolating it from external concerns. The core idea is simple: the application has no knowledge of the outside world. Instead, external systems connect through “ports” that define interfaces, with “adapters” implementing those interfaces.
The visual metaphor is a hexagon: the core application sits in the middle, with ports on the sides, and adapters connecting to external systems.
Core Principles
The dependency rule: Dependencies point inward. Core domain logic knows nothing about adapters, databases, or frameworks.
Ports define boundaries: Ports are interfaces that define how the core communicates with the outside world.
Adapters implement ports: Adapters are implementations that connect to specific technologiesโdatabases, APIs, message queues.
Why Hexagonal Architecture Matters
Traditional architectures couple business logic to infrastructure:
- Database schema changes break business logic
- Testing requires actual databases
- Changing frameworks requires rewriting application code
Hexagonal architecture solves these problems:
- Business logic is independent of infrastructure
- Tests run fast without external dependencies
- Frameworks and databases can be swapped easily
Architecture Layers
Domain Layer (Core)
The innermost layer contains pure business logic:
# domain/entities.py
class Order:
def __init__(self, customer_id: str, items: list[OrderItem]):
self.id = str(uuid.uuid4())
self.customer_id = customer_id
self.items = items
self.status = "pending"
self.created_at = datetime.now()
def total(self) -> Money:
return sum(item.price * item.quantity for item in self.items)
def can_cancel(self) -> bool:
return self.status == "pending"
def cancel(self):
if not self.can_cancel():
raise ValueError("Order cannot be cancelled")
self.status = "cancelled"
# domain/services.py
class OrderService:
def __init__(self, order_repository: OrderRepository):
self.order_repository = order_repository
def create_order(self, customer_id: str, items: list[dict]) -> Order:
order_items = [
OrderItem(
product_id=item["product_id"],
quantity=item["quantity"],
price=item["price"]
)
for item in items
]
order = Order(customer_id, order_items)
self.order_repository.save(order)
return order
Ports Layer
Ports define interfaces for external communication:
# ports/repositories.py (Input Port)
from abc import ABC, abstractmethod
from typing import Optional
from domain.entities import Order
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None:
pass
@abstractmethod
def find_by_id(self, order_id: str) -> Optional[Order]:
pass
@abstractmethod
def find_by_customer(self, customer_id: str) -> list[Order]:
pass
# ports/services.py (Output Ports)
from abc import ABC, abstractmethod
class NotificationService(ABC):
@abstractmethod
def send_order_confirmation(self, order_id: str, email: str) -> None:
pass
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: int, currency: str, customer_id: str) -> str:
pass # Returns transaction ID
Adapters Layer
Adapters implement ports for specific technologies:
# adapters/persistence/sqlalchemy_repository.py
from sqlalchemy import Column, String, Integer
from sqlalchemy.orm import Session
from domain.entities import Order, OrderItem
from ports.repositories import OrderRepository
class SQLAlchemyOrderRepository(OrderRepository):
def __init__(self, session: Session):
self.session = session
def save(self, order: Order) -> None:
# Convert domain entity to database model
db_order = DBOrder(
id=order.id,
customer_id=order.customer_id,
status=order.status
)
self.session.add(db_order)
self.session.commit()
def find_by_id(self, order_id: str) -> Optional[Order]:
db_order = self.session.query(DBOrder).filter_by(id=order_id).first()
if not db_order:
return None
return self._to_domain(db_order)
# adapters/notifications/email_adapter.py
from ports.services import NotificationService
class EmailNotificationAdapter(NotificationService):
def __init__(self, smtp_client: SMTPClient):
self.smtp = smtp_client
def send_order_confirmation(self, order_id: str, email: str) -> None:
self.smtp.send(
to=email,
subject=f"Order Confirmation: {order_id}",
body="Your order has been confirmed!"
)
Application Layer (Use Cases)
Orchestrates domain logic:
# application/commands.py
from dataclasses import dataclass
from domain.services import OrderService
from ports.repositories import OrderRepository
from ports.services import NotificationService, PaymentGateway
@dataclass
class CreateOrderCommand:
customer_id: str
items: list[dict]
payment_token: str
email: str
class CreateOrderHandler:
def __init__(
self,
order_service: OrderService,
payment_gateway: PaymentGateway,
notification_service: NotificationService
):
self.order_service = order_service
self.payment_gateway = payment_gateway
self.notification_service = notification_service
def handle(self, command: CreateOrderCommand):
# Process payment
transaction_id = self.payment_gateway.charge(
amount=1000, # $10.00
currency="USD",
customer_id=command.customer_id
)
# Create order
order = self.order_service.create_order(
customer_id=command.customer_id,
items=command.items
)
# Send confirmation
self.notification_service.send_order_confirmation(
order_id=order.id,
email=command.email
)
return order
Implementing Hexagonal Architecture
Project Structure
src/
โโโ domain/ # Core business logic
โ โโโ entities.py
โ โโโ value_objects.py
โ โโโ services.py
โ
โโโ application/ # Use cases
โ โโโ commands.py
โ โโโ queries.py
โ โโโ handlers.py
โ
โโโ ports/ # Interfaces
โ โโโ repositories.py
โ โโโ services.py
โ
โโโ adapters/ # Implementations
โ โโโ persistence/
โ โ โโโ sqlalchemy_repository.py
โ โโโ api/
โ โ โโโ rest_controller.py
โ โโโ notifications/
โ โโโ email_adapter.py
โ
โโโ main.py # Application entry point
Dependency Injection
Wire components together:
# main.py
from adapters.persistence.sqlalchemy_repository import SQLAlchemyOrderRepository
from adapters.notifications.email_adapter import EmailNotificationAdapter
from adapters.payment.stripe_adapter import StripePaymentAdapter
from application.commands import CreateOrderHandler
# Create adapters
session = create_session() # SQLAlchemy session
order_repository = SQLAlchemyOrderRepository(session)
email_service = EmailNotificationAdapter(smtp_client)
payment_gateway = StripePaymentAdapter(api_key)
# Create handlers with dependencies
handler = CreateOrderHandler(
order_service=OrderService(order_repository),
payment_gateway=payment_gateway,
notification_service=email_service
)
Testing with Hexagonal Architecture
Tests are straightforward:
# tests/test_order_service.py
import pytest
from domain.entities import Order, OrderItem
from domain.services import OrderService
from unittest.mock import Mock
class TestOrderService:
def test_create_order(self):
# Arrange: Create mock repository
mock_repo = Mock()
mock_repo.save = Mock()
order_service = OrderService(mock_repo)
# Act: Create order
order = order_service.create_order(
customer_id="cust_123",
items=[{"product_id": "prod_1", "quantity": 2, "price": 10.00}]
)
# Assert: Verify order created
assert order.customer_id == "cust_123"
assert order.status == "pending"
mock_repo.save.assert_called_once()
def test_cannot_cancel_shipped_order(self):
mock_repo = Mock()
order_service = OrderService(mock_repo)
order = Order("cust_123", [])
order.status = "shipped"
with pytest.raises(ValueError):
order.cancel()
Benefits of Hexagonal Architecture
Testability
Business logic tests run in isolation:
- No database required
- No network calls
- Fast execution
- Reliable results
Maintainability
Changes are isolated:
- Database changes don’t affect domain
- New adapters can be added without modifying core
- Business logic is easy to understand
Flexibility
Swap implementations easily:
- Migrate from SQL to NoSQL
- Change payment providers
- Add new API protocols
- Support multiple interfaces
Clarity
Code organization is clear:
- Domain logic is the center
- Dependencies flow inward
- Responsibilities are obvious
Challenges and Solutions
Complexity
Hexagonal architecture adds initial complexity:
Solution: Start with simpler applications. The pattern pays off as complexity grows.
Over-Abstraction
Too many layers can confuse:
Solution: Keep it simple. Only add abstractions when there’s real benefit.
Mapping Overhead
Converting between layers takes effort:
Solution: Use domain models consistently. Don’t leak database models into domain.
Hexagonal Architecture in Different Contexts
Web Applications
# adapters/api/fastapi_controller.py
from fastapi import FastAPI, Depends
from application.commands import CreateOrderCommand, CreateOrderHandler
app = FastAPI()
def get_handler():
return CreateOrderHandler(...)
@app.post("/orders")
def create_order(command: CreateOrderCommand, handler: CreateOrderHandler = Depends(get_handler)):
order = handler.handle(command)
return {"id": order.id, "status": order.status}
Message-Driven Applications
# adapters/messaging/kafka_consumer.py
from kafka import KafkaConsumer
from application.commands import CreateOrderCommand
consumer = KafkaConsumer('orders')
for message in consumer:
command = CreateOrderCommand(**json.loads(message.value))
handler.handle(command)
CLI Applications
# adapters/cli/commands.py
import click
from application.commands import CreateOrderCommand
@click.command()
@click.option('--customer-id')
@click.option('--items', multiple=True)
def create_order(customer_id, items):
command = CreateOrderCommand(customer_id=customer_id, items=parse_items(items))
handler.handle(command)
Comparison with Other Architectures
vs. Layered Architecture
| Aspect | Layered | Hexagonal |
|---|---|---|
| Dependencies | Each layer depends on below | All depend on domain |
| Testing | Database needed for tests | Domain testable alone |
| Flexibility | Database coupled | Swappable databases |
| Complexity | Simpler | More abstraction |
vs. Clean Architecture
Hexagonal and Clean Architecture share similar principles:
- Domain is central
- Dependencies point inward
- Use cases are explicit
Hexagonal emphasizes ports/adapters metaphor; Clean Architecture emphasizes layers.
vs. DDD
Hexagonal architecture works well with Domain-Driven Design:
- Domain entities = DDD entities
- Application services = DDD application services
- Ports = DDD repositories (interfaces)
- Adapters = infrastructure implementations
Best Practices
Keep Domain Pure
- No imports from outside domain
- No annotations for specific frameworks
- Pure Python classes
Define Clear Ports
- Ports are stable interfaces
- Don’t expose database concepts
- Keep ports simple
Implement Adapters Completely
- Adapters handle all external concerns
- Transform between domain and external models
- Handle errors appropriately
Use Dependency Injection
- Don’t instantiate adapters in domain
- Pass dependencies explicitly
- Use DI containers if helpful
Resources
- Hexagonal Architecture (Alistair Cockburn)
- Ports and Adapters (IEEE)
- Getting Started with Hexagonal Architecture
- Python Hexagonal Architecture Template
Conclusion
Hexagonal architecture provides a robust foundation for building maintainable software. By isolating business logic from external concerns, applications become testable, flexible, and long-lived.
The initial investment in structure pays dividends over time. As requirements change and technologies evolve, hexagonal architecture enables adaptation without rewriting core logic.
Start with clear domain logic, define ports for external communication, and implement adapters for specific technologies. The result is software that’s a pleasure to work with and easy to evolve.
Comments