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:
Strategy 1: Two-Way Mapping (Recommended)
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.
Strategy 4: Weakening Boundaries (Not Recommended)
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:
- High-level modules should not depend on low-level modules. Both should depend on abstractions.
- 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.
2025-2026 Developments and Trends
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
- Hexagonal Architecture by Alistair Cockburn (original article)
- Hexagonal Architecture Pattern - AWS Prescriptive Guidance
- Hexagonal Architecture: What Is It? Why Use It? - HappyCoders
- Hexagonal Architecture with Go (LordMoMA)
- Hexagonal Architecture: Complete Guide - Chakray
- AWS Lambda Domain Model Sample
- Clean Architecture and Hexagonal Patterns in Java - NareshIT
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