Skip to main content
โšก Calmops

Hexagonal Architecture: Ports and Adapters Pattern for Clean Software Design

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

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