Skip to main content

Hexagonal Architecture: Ports and Adapters Pattern for Clean Software Design

Created: March 6, 2026 Larry Qu 20 min read
Table of Contents

Introduction

Software architecture has a profound impact on maintainability, testability, and longevity. Hexagonal architecture — also known as ports and adapters — provides a powerful pattern for building applications that are isolated from external dependencies, easy to test, and adaptable to changing requirements.

First introduced by Alistair Cockburn in 2005, hexagonal architecture has gained renewed relevance in 2025-2026 as organizations adopt cloud-native architectures, serverless computing, and microservices. AWS now officially recommends hexagonal architecture in its Prescriptive Guidance for decoupling business logic from infrastructure in Lambda functions. Cockburn himself published a comprehensive book on the subject in 2024, co-authored with Juan Manuel Garrido de Paz.

This guide explores hexagonal architecture principles, the distinction between driving and driven ports, mapping strategies, testing approaches, and best practices for building maintainable software.

Understanding Hexagonal Architecture

What Is Hexagonal Architecture?

Hexagonal architecture, pioneered by Alistair Cockburn, 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. 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. The hexagon shape has no special meaning — Cockburn chose it because squares are overused and pentagons are hard to draw. The six sides simply provide enough space to represent the typical number of interfaces a system needs.

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
  • Infrastructure decisions can be deferred until domain logic is well understood

Driving and Driven Ports

One of the most important distinctions in hexagonal architecture is between driving (primary) and driven (secondary) ports.

Driving Side (Primary Ports)

Driving actors start the interaction with the application. They initiate queries or commands. Examples include a web UI, a REST API client, a CLI tool, or an automated test.

flowchart LR
    subgraph "Driving Side"
        UI[Web UI]
        API[REST Client]
        CLI[CLI Tool]
    end
    subgraph "Application Core"
        PP[Primary Port\nInterface]
        LOGIC[Business Logic]
    end
    UI --> PP
    API --> PP
    CLI --> PP
    PP --> LOGIC

On the driving side, the source code dependency follows the same direction as the control flow. The adapter (e.g., a REST controller) depends on the port interface, which is defined inside the application core.

Driven Side (Secondary Ports)

Driven actors are those that the application core needs to interact with. Examples include databases, message brokers, email services, and external APIs.

flowchart LR
    subgraph "Application Core"
        LOGIC[Business Logic]
        SP[Secondary Port\nInterface]
    end
    subgraph "Driven Side"
        DB[(Database)]
        MQ[Message Queue]
        SMTP[Email Service]
    end
    LOGIC --> SP
    SP -.-> DB
    SP -.-> MQ
    SP -.-> SMTP

On the driven side, the direction of the source code dependency is inverted relative to the control flow. The control flows from the core to the database, but the source code dependency goes the other way — the adapter implements the port interface defined in the core. This is the Dependency Inversion Principle (DIP) in action.

Practical Example

A typical order management system has both driving and driven ports:

Direction Port Adapters
Driving CreateOrderUseCase REST controller, CLI command, message consumer
Driving GetOrderQuery GraphQL resolver, gRPC handler
Driven OrderRepository PostgreSQL adapter, MongoDB adapter, in-memory test double
Driven PaymentGateway Stripe adapter, PayPal adapter, mock adapter
Driven NotificationService SendGrid adapter, SES adapter, SMS adapter

Architecture Layers

Domain Layer (Core)

The innermost layer contains pure business logic. Domain entities have no dependencies on frameworks, databases, or external libraries.

## 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 orchestrate business rules using entities:

## 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. These are split into driving ports (use cases) and driven ports (repositories, services).

Driving ports — the use cases the application exposes:

## ports/in/create_order.py (Driving Port)
from abc import ABC, abstractmethod

class CreateOrderUseCase(ABC):
    @abstractmethod
    def handle(self, customer_id: str, items: list[dict], payment_token: str, email: str) -> Order:
        pass

Driven ports — the interfaces the application needs from the outside:

## ports/out/repositories.py (Driven 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/out/services.py (Driven 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

Adapters Layer

Adapters implement ports for specific technologies. A driving adapter receives external input and translates it into port calls. A driven adapter implements a port interface using a specific technology.

## adapters/in/web/order_controller.py (Driving Adapter)
from fastapi import FastAPI, Depends
from ports.in.create_order import CreateOrderUseCase

app = FastAPI()

@app.post("/orders")
def create_order(
    customer_id: str,
    items: list[dict],
    payment_token: str,
    email: str,
    use_case: CreateOrderUseCase = Depends(get_create_order_use_case)
):
    order = use_case.handle(customer_id, items, payment_token, email)
    return {"id": order.id, "status": order.status}
## adapters/out/persistence/sqlalchemy_repository.py (Driven Adapter)
from sqlalchemy.orm import Session
from domain.entities import Order
from ports.out.repositories import OrderRepository

class SQLAlchemyOrderRepository(OrderRepository):
    def __init__(self, session: Session):
        self.session = session

    def save(self, order: Order) -> None:
        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/out/notifications/email_adapter.py (Driven Adapter)
from ports.out.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)

The application layer orchestrates domain logic through use case handlers. These sit inside the hexagon and coordinate between driving and driven ports.

## application/commands/create_order_handler.py
from dataclasses import dataclass
from domain.services import OrderService
from ports.out.repositories import OrderRepository
from ports.out.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):
        transaction_id = self.payment_gateway.charge(
            amount=1000,
            currency="USD",
            customer_id=command.customer_id
        )

        order = self.order_service.create_order(
            customer_id=command.customer_id,
            items=command.items
        )

        self.notification_service.send_order_confirmation(
            order_id=order.id,
            email=command.email
        )

        return order

The Mapping Problem

One recurring challenge in hexagonal architecture is the “mapping problem” — how to handle ORM annotations and framework-specific concerns without leaking them into the domain core.

flowchart TD
    subgraph "Domain Core"
        E[Domain Entity\n(Pure Python)]
    end
    subgraph "Persistence Adapter"
        DB[Database Model\n(with ORM annotations)]
        M[Mapper\nEntity ↔ DB Model]
    end
    E <--> M
    M <--> DB

Four strategies exist for handling this, each with tradeoffs:

Create a separate model class in the adapter with ORM annotations. The adapter maps between the domain entity and the database model in both directions.

## domain/entities.py (No ORM dependencies)
class User:
    def __init__(self, user_id: str, email: str, name: str):
        self.user_id = user_id
        self.email = email
        self.name = name
## adapters/out/persistence/models.py (ORM annotations here)
from sqlalchemy import Column, String
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class UserModel(Base):
    __tablename__ = "users"
    user_id = Column(String, primary_key=True)
    email = Column(String)
    name = Column(String)
## adapters/out/persistence/user_repository.py (Mapping layer)
from domain.entities import User

class SQLAlchemyUserRepository(UserRepository):
    def save(self, user: User) -> None:
        model = UserModel(
            user_id=user.user_id,
            email=user.email,
            name=user.name
        )
        self.session.add(model)
        self.session.commit()

    def find_by_id(self, user_id: str) -> Optional[User]:
        model = self.session.query(UserModel).filter_by(user_id=user_id).first()
        if not model:
            return None
        return User(user_id=model.user_id, email=model.email, name=model.name)

This is the most maintainable approach despite the boilerplate overhead. It guarantees zero framework leakage into the domain.

Strategy 2: One-Way Mapping with Shared Interface

Define a common interface in the core. Both the domain entity and the database model implement it. Mapping is needed only in one direction.

Strategy 3: External Configuration

Use XML or JSON configuration files for ORM mappings instead of annotations. Keep the domain entity annotation-free. This is possible with Hibernate and some other ORMs but tends to be more confusing than code-based configuration.

Place ORM annotations directly on domain entities. This is the most convenient but breaks the dependency rule. Once you start leaking framework concerns into the core, architectural boundaries tend to erode further over time.

Recommendation: Use two-way mapping for persistence adapters and DTO mapping for REST adapters (to control which fields are exposed and how they are formatted). The extra code is a small price for long-term architectural integrity.

Dependency Inversion Principle

The Dependency Inversion Principle (DIP) is the mechanism that makes hexagonal architecture work for driven ports. It states:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend on abstractions.
flowchart LR
    subgraph "Without DIP (Layered)"
        BL[Business Logic] --> DB[Database]
    end
    subgraph "With DIP (Hexagonal)"
        BL2[Business Logic] -->|"depends on"| PI[Port Interface\n{abstract}]
        PI <--|"implements"| AD[DB Adapter]
        AD --> DB2[(Database)]
    end

In the layered approach, the business logic depends directly on the database access code. In hexagonal architecture, the business logic depends on an interface (port), and the adapter depends on that same interface. The database dependency has been inverted — the adapter, not the core, depends on the database driver.

The following Go example illustrates DIP for a repository:

## ports/out/user_repository.go
package ports

type UserRepository interface {
    Save(user *domain.User) error
    FindByID(id string) (*domain.User, error)
}
## adapters/out/postgres/user_repository.go
package postgres

import (
    "database/sql"
    "your-app/domain"
    "your-app/ports/out"
)

type PostgresUserRepository struct {
    db *sql.DB
}

func (r *PostgresUserRepository) Save(user *domain.User) error {
    _, err := r.db.Exec(
        "INSERT INTO users (id, email, name) VALUES ($1, $2, $3)",
        user.ID, user.Email, user.Name,
    )
    return err
}

func (r *PostgresUserRepository) FindByID(id string) (*domain.User, error) {
    row := r.db.QueryRow("SELECT id, email, name FROM users WHERE id = $1", id)
    user := &domain.User{}
    err := row.Scan(&user.ID, &user.Email, &user.Name)
    return user, err
}

The CreateOrderHandler depends on UserRepository (the port), never on PostgresUserRepository (the adapter). This makes the handler testable without a database and swappable to any database implementation.

Testing with Hexagonal Architecture

Hexagonal architecture enables testing at multiple levels, each with distinct isolation guarantees.

Unit Testing Business Logic

Domain tests run without any infrastructure. Driven ports are replaced with test doubles:

## tests/unit/test_order_service.py
import pytest
from unittest.mock import Mock
from domain.entities import Order, OrderItem
from domain.services import OrderService

class TestOrderService:
    def test_create_order(self):
        mock_repo = Mock()
        order_service = OrderService(mock_repo)

        order = order_service.create_order(
            customer_id="cust_123",
            items=[{"product_id": "prod_1", "quantity": 2, "price": 10.00}]
        )

        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()

Integration Testing for Adapters

Driven adapters can be tested in isolation with real infrastructure using lightweight containers:

## tests/integration/test_postgres_repository.py
import pytest
from adapters.out.persistence.postgres_repository import PostgresOrderRepository
from domain.entities import Order, OrderItem

class TestPostgresOrderRepository:
    @pytest.fixture
    def repository(self, test_db):
        return PostgresOrderRepository(test_db)

    def test_save_and_find_by_id(self, repository):
        order = Order("cust_1", [OrderItem("prod_1", 2, 10.00)])
        repository.save(order)

        found = repository.find_by_id(order.id)
        assert found is not None
        assert found.customer_id == "cust_1"
        assert found.status == "pending"

    def test_find_by_customer(self, repository):
        order = Order("cust_1", [])
        repository.save(order)

        orders = repository.find_by_customer("cust_1")
        assert len(orders) == 1

Driving Adapter Integration Tests

Test REST adapters by starting the web server and making real HTTP calls, with driven ports mocked:

## tests/integration/test_order_api.py
from fastapi.testclient import TestClient
from unittest.mock import Mock
from adapters.in.web.order_controller import app
from ports.in.create_order import CreateOrderUseCase

def test_create_order_endpoint():
    mock_use_case = Mock()
    mock_use_case.handle.return_value = Mock(id="ord_123", status="pending")

    app.dependency_overrides[CreateOrderUseCase] = lambda: mock_use_case
    client = TestClient(app)

    response = client.post("/orders", json={
        "customer_id": "cust_1",
        "items": [{"product_id": "p1", "quantity": 1, "price": 10.00}],
        "payment_token": "tok_123",
        "email": "[email protected]"
    })

    assert response.status_code == 200
    assert response.json()["status"] == "pending"

Benefits of Hexagonal Architecture

Testability

Business logic tests run in isolation — no database, no network calls, fast execution, reliable results.

Maintainability

Changes are isolated. Database changes don’t affect domain logic. New adapters can be added without modifying core code. Business logic is easy to understand without framework noise.

Flexibility

Swap implementations easily — migrate from SQL to NoSQL, change payment providers, add new API protocols, support multiple interfaces.

Deferred Infrastructure Decisions

Start developing the domain logic first. Decide on databases, frameworks, and external services later when you have better understanding of requirements.

Challenges and Solutions

Complexity and Initial Overhead

Hexagonal architecture adds upfront complexity. For a simple CRUD app, the overhead of ports, adapters, and mapping may not be worth it.

Solution: Start with simpler architecture. Introduce hexagonal patterns gradually as the application grows in complexity. For small projects, implement only partial hexagonal patterns — define interfaces for external dependencies without the full port/adapter machinery.

The Mapping Overhead

Converting between domain models and adapter-specific DTOs takes effort.

Solution: Two-way mapping is the most reliable approach. Use mapping libraries (like MapStruct in Java, or simple dataclass conversions in Python) to reduce boilerplate.

Over-Abstraction (“Destructive Decoupling”)

Creating interfaces for everything leads to code that is harder to follow and maintain than it would be without the architecture.

Solution: Only abstract when there is a genuine reason — multiple implementations, testing needs, or anticipated change. Follow YAGNI (“You Aren’t Gonna Need It”).

The Hidden Maze

Use cases that become interdependent create long chains of calls between components, making the flow hard to follow.

Solution: Keep use cases independent. Each use case should be a self-contained unit. If use cases share logic, extract it into domain services.

Anemic Domain Models

Domain classes that contain only data (getters and setters) with business logic spread across services defeat the purpose of hexagonal architecture.

Solution: Put business logic in domain entities and value objects. Use domain services only for logic that doesn’t fit naturally in a single entity.

When Hexagonal Architecture Is Overkill

The pattern is not suited for every project. Avoid it when:

  • Building a simple CRUD application with minimal business logic
  • Creating a purely technical microservice (e.g., a log aggregator)
  • Developing a prototype or MVP where speed is the priority
  • The team lacks experience with the pattern — learning curve will slow development

Hexagonal Architecture in Different Contexts

Web Applications

A driving REST adapter translates HTTP requests into use case calls:

## adapters/in/web/fastapi_controller.py
from fastapi import FastAPI, Depends
from application.commands.create_order_handler import CreateOrderCommand, CreateOrderHandler

app = FastAPI()

@app.post("/orders")
def create_order(body: dict, handler: CreateOrderHandler = Depends(get_handler)):
    command = CreateOrderCommand(**body)
    order = handler.handle(command)
    return {"id": order.id, "status": order.status}

Message-Driven Applications

A Kafka consumer acts as a driving adapter, invoking use cases from incoming events:

## adapters/in/messaging/kafka_consumer.py
from kafka import KafkaConsumer
from application.commands.create_order_handler import CreateOrderCommand

consumer = KafkaConsumer('orders')
for message in consumer:
    command = CreateOrderCommand(**json.loads(message.value))
    handler.handle(command)

CLI Applications

A CLI tool drives the same use cases as the web API:

## adapters/in/cli/commands.py
import click
from application.commands.create_order_handler 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)

AWS Lambda / Serverless

AWS Prescriptive Guidance explicitly recommends hexagonal architecture for Lambda functions. The Lambda handler acts as the driving adapter, transforming API Gateway events into use case calls:

## adapters/in/lambda/handler.py
import json
from adapters.out.persistence.dynamodb_adapter import DynamoDBOrderRepository
from ports.out.repositories import OrderRepository
from application.commands.create_order_handler import CreateOrderHandler
from domain.services import OrderService

def lambda_handler(event, context):
    order_repo = DynamoDBOrderRepository()
    order_service = OrderService(order_repo)
    handler = CreateOrderHandler(order_service=order_service, ...)

    body = json.loads(event["body"])
    result = handler.handle(CreateOrderCommand(**body))

    return {
        "statusCode": 200,
        "body": json.dumps({"id": result.id, "status": result.status})
    }

The DynamoDB adapter implements the same OrderRepository port that a PostgreSQL adapter would, making the business logic completely infrastructure-agnostic.

Go (Golang) Implementation

Go’s interface system and lack of built-in DI make it a natural fit for hexagonal architecture:

// ports/out/order_repository.go
package ports

import "your-project/domain"

type OrderRepository interface {
    Save(order *domain.Order) error
    FindByID(id string) (*domain.Order, error)
    FindByCustomer(customerID string) ([]*domain.Order, error)
}
// adapters/out/postgres/order_repository.go
package postgres

import (
    "database/sql"
    "your-project/domain"
    "your-project/ports/out"
)

type OrderRepository struct {
    db *sql.DB
}

func (r *OrderRepository) Save(order *domain.Order) error {
    _, err := r.db.Exec(
        "INSERT INTO orders (id, customer_id, status, created_at) VALUES ($1, $2, $3, $4)",
        order.ID, order.CustomerID, order.Status, order.CreatedAt,
    )
    return err
}

func (r *OrderRepository) FindByID(id string) (*domain.Order, error) {
    row := r.db.QueryRow("SELECT id, customer_id, status, created_at FROM orders WHERE id = $1", id)
    order := &domain.Order{}
    err := row.Scan(&order.ID, &order.CustomerID, &order.Status, &order.CreatedAt)
    return order, err
}
// application/handlers/create_order_handler.go
package handlers

import (
    "your-project/domain"
    "your-project/ports/out"
)

type CreateOrderHandler struct {
    orderService      *domain.OrderService
    paymentGateway    ports.PaymentGateway
    notificationSvc   ports.NotificationService
}

func NewCreateOrderHandler(
    orderService *domain.OrderService,
    paymentGateway ports.PaymentGateway,
    notificationSvc ports.NotificationService,
) *CreateOrderHandler {
    return &CreateOrderHandler{
        orderService:    orderService,
        paymentGateway:  paymentGateway,
        notificationSvc: notificationSvc,
    }
}

func (h *CreateOrderHandler) Handle(cmd CreateOrderCommand) (*domain.Order, error) {
    _, err := h.paymentGateway.Charge(cmd.Amount, "USD", cmd.CustomerID)
    if err != nil {
        return nil, err
    }

    order, err := h.orderService.CreateOrder(cmd.CustomerID, cmd.Items)
    if err != nil {
        return nil, err
    }

    h.notificationSvc.SendOrderConfirmation(order.ID, cmd.Email)
    return order, nil
}

Implementing a Go Project

Go projects following hexagonal architecture typically use this structure:

your-project/
├── cmd/
│   └── api/main.go           # Entry point, DI wiring
├── internal/
│   ├── domain/               # Entities, value objects, domain services
│   ├── application/          # Use case handlers
│   ├── ports/
│   │   ├── in/               # Driving port interfaces
│   │   └── out/              # Driven port interfaces
│   └── adapters/
│       ├── in/
│       │   ├── rest/         # HTTP handlers (Gin, Echo, Chi)
│       │   └── consumer/     # Message consumers (Kafka, RabbitMQ)
│       └── out/
│           ├── postgres/     # PostgreSQL repository
│           ├── redis/        # Redis cache
│           └── stripe/       # Stripe payment gateway
├── go.mod
└── go.sum

Comparison with Other Architectures

vs. Layered Architecture

Aspect Layered Hexagonal
Dependencies Each layer depends on below All depend on domain (inward)
Testing Database needed for logic tests Domain testable in isolation
Flexibility Database changes ripple through layers Swappable via adapter replacement
Complexity Simpler at start More abstraction, better at scale
Focus Technology-driven (DB first) Business-driven (domain first)

The fundamental difference is focus. Layered architecture tends toward database-driven design — you plan tables first. Hexagonal architecture is business-driven — you model behavior first, infrastructure later.

vs. Clean Architecture

Hexagonal architecture and Clean Architecture (Robert C. Martin, 2012) share identical goals and nearly identical structure:

Aspect Hexagonal Clean Architecture
Metaphor Hexagon with ports/adapters Concentric rings
Core Business logic (unstructured) Entities + Use Cases
Outer layer Adapters Interface Adapters + Frameworks
Dependency rule Point inward Point inward
Testing Isolated via port mocks Isolated via boundary interfaces

The practical difference is that Clean Architecture further subdivides the core into entities (enterprise business rules) and use cases (application business rules), while hexagonal architecture deliberately leaves the internal structure open. Cockburn’s view: “The hexagonal design pattern represents a single design decision: wrap your app in an API and put tests around it.”

See the Clean Architecture and Layered Architecture guide for a deeper comparison.

vs. Onion Architecture

Jeffrey Palermo’s Onion Architecture (2008) is also nearly identical:

Aspect Hexagonal Onion
Core Business logic Domain Model (mandatory)
Middle Ports (interfaces) Application Services + Domain Services
Outer Adapters Infrastructure (DB, UI, tests)
Direction Inward dependencies Inward dependencies

The main difference is that Onion Architecture makes the Domain Model an explicit innermost ring, while hexagonal architecture leaves the core structure ambiguous.

vs. Domain-Driven Design (DDD)

Hexagonal architecture and DDD complement each other exceptionally well:

DDD Concept Hexagonal Equivalent
Domain entities Domain layer entities
Value objects Domain layer value objects
Aggregates Domain layer aggregates
Application services Use case handlers
Repository interfaces Driven ports (OrderRepository)
Infrastructure implementations Driven adapters (PostgreSQL adapter)
Bounded contexts Hexagon boundaries

DDD’s tactical patterns (entities, value objects, aggregates) provide the content for the hexagon’s core, while hexagonal architecture provides the structural container.

AWS Official Recommendation

AWS Prescriptive Guidance now formally documents hexagonal architecture as a recommended pattern for Lambda functions. This marks a significant endorsement — the pattern is no longer just a theoretical best practice but is recognized at the cloud infrastructure level.

Cockburn’s 2024 Book

In April 2024, Alistair Cockburn published a comprehensive book on hexagonal architecture, co-authored with Juan Manuel Garrido de Paz. This is the first authoritative book-length treatment of the pattern since its 2005 introduction.

Microservices + Hexagonal

The trend continues toward structuring each microservice internally as a hexagon. Chris Richardson’s “Microservice patterns” book explicitly states that each microservice’s internal architecture is typically hexagonal. This ensures loose coupling at both the system level (microservices) and the component level (hexagon).

Serverless and Hexagonal

Serverless architectures benefit particularly well because Lambda functions often mix business logic with infrastructure code. Hexagonal architecture forces the separation, making each function independently testable and its logic reusable across different triggers (API Gateway, SQS, EventBridge).

Modular Monoliths First

A growing best practice is to start new projects as modular monoliths using hexagonal structure. If scaling requires it, adapters can be extracted into separate microservices without touching the domain core. This avoids premature distribution while keeping the option open.

Best Practices

Keep Domain Pure

No imports from outside domain. No ORM annotations, no framework decorators, no JSON serialization logic. Pure language constructs only.

Distinguish Driving from Driven Ports

Use separate package or naming conventions for driving ports (use cases) and driven ports (repositories, services). This clarifies which side of the hexagon each interface belongs to.

Drive Ports with Use Cases

Design ports around business operations, not data structures. A port should say CreateOrder, CancelOrder, GetOrderByID, not SaveOrder, DeleteOrder.

Implement Adapters Completely

Adapters handle all external concerns — serialization, error translation, retry logic, connection management. The domain never catches network errors or database exceptions.

Use Dependency Injection

Don’t instantiate adapters in domain code. Pass dependencies explicitly at the composition root. Use DI containers if they help, but avoid framework annotations in the core.

Start as a Modular Monolith

Begin with all adapters in the same process. Extract into separate services only when scaling demands it. The hexagonal structure makes this extraction straightforward without code changes to the domain.

Apply YAGNI

Don’t create interfaces for everything upfront. Abstract only when you have a concrete second implementation or a clear testing need.

Test at Every Level

Unit tests for domain logic, integration tests for adapters with real infrastructure, contract tests for port-adapter boundaries, and system tests for end-to-end flows.

Project Structure

A recommended directory layout for a Python hexagonal project:

src/
├── domain/               # Core business logic
│   ├── entities.py
│   ├── value_objects.py
│   └── services.py
├── application/          # Use cases
│   ├── commands/
│   ├── queries/
│   └── handlers/
├── ports/                # Interfaces
│   ├── in/              # Driving ports
│   │   ├── create_order.py
│   │   └── cancel_order.py
│   └── out/             # Driven ports
│       ├── repositories.py
│       └── services.py
├── adapters/             # Implementations
│   ├── in/              # Driving adapters
│   │   ├── web/
│   │   │   └── order_controller.py
│   │   ├── cli/
│   │   │   └── commands.py
│   │   └── messaging/
│   │       └── kafka_consumer.py
│   └── out/             # Driven adapters
│       ├── persistence/
│       │   └── postgres_repository.py
│       ├── messaging/
│       │   └── kafka_producer.py
│       └── payments/
│           └── stripe_adapter.py
└── main.py               # DI composition root

Dependency Injection Composition

Wire everything together at the application entry point:

## main.py
from adapters.out.persistence.postgres_repository import PostgresOrderRepository
from adapters.out.notifications.email_adapter import EmailNotificationAdapter
from adapters.out.payments.stripe_adapter import StripePaymentAdapter
from domain.services import OrderService
from application.handlers.create_order_handler import CreateOrderHandler

session = create_db_session()
order_repository = PostgresOrderRepository(session)
email_service = EmailNotificationAdapter(smtp_client)
payment_gateway = StripePaymentAdapter(api_key)
order_service = OrderService(order_repository)

handler = CreateOrderHandler(
    order_service=order_service,
    payment_gateway=payment_gateway,
    notification_service=email_service
)

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. When combined with domain-driven design and microservices, it forms the backbone of modern, resilient software systems.

Start with clear domain logic, define ports for external communication, implement adapters for specific technologies, and keep dependencies pointing inward. The result is software that’s a pleasure to work with and easy to evolve.

Comments

👍 Was this article helpful?