Hexagonal Architecture: Ports and Adapters Pattern for Maintainable Systems
Hexagonal Architecture: Ports and Adapters Pattern for Maintainable Systems
Introduction
Hexagonal Architecture, also known as Ports and Adapters pattern, was introduced by Alistair Cockburn to address a fundamental problem in software design: the tight coupling between business logic and external dependencies. This pattern creates a clear separation between the core domain logic and the outside world, making systems more testable, maintainable, and adaptable to change.
Unlike traditional layered architectures where dependencies flow in one direction (UI → Business Logic → Database), hexagonal architecture places the business logic at the center, with all external concerns connected through well-defined interfaces.
Core Concepts
The Hexagon (Core Domain)
At the center lies your application’s business logic—the rules, workflows, and domain models that define what your application does. This core is:
- Framework-agnostic: No dependencies on web frameworks, databases, or external libraries
- Technology-agnostic: Can work with any infrastructure
- Testable in isolation: No mocks of external systems required for core logic tests
Ports (Interfaces)
Ports are the boundaries between the core and the outside world. They define contracts without specifying implementation:
- Inbound/Driving Ports: How the outside world uses your application (e.g., API interfaces, command handlers)
- Outbound/Driven Ports: How your application uses external systems (e.g., repository interfaces, notification services)
Adapters (Implementations)
Adapters implement the ports and translate between the core domain and external systems:
- Inbound/Driving Adapters: REST APIs, GraphQL, CLI, message queue consumers
- Outbound/Driven Adapters: Database repositories, HTTP clients, email services, cache implementations
When to Use Hexagonal Architecture
Ideal Scenarios
- Complex Business Logic: When domain rules are sophisticated and change frequently
- Multiple Interfaces: System accessed via REST API, GraphQL, CLI, and message queues
- Testing Requirements: Need for extensive unit testing without infrastructure dependencies
- Technology Evolution: Anticipate changing databases, frameworks, or external services
- Microservices: Each service benefits from clear boundaries and independence
- Long-lived Systems: Projects expected to evolve over many years
When to Skip It
- Simple CRUD Applications: Overhead outweighs benefits for basic data operations
- Prototypes: Premature abstraction slows down experimentation
- Small Scripts: One-off utilities don’t need architectural rigor
- Tight Deadlines with Stable Requirements: When requirements won’t change and time is critical
Implementation Examples
Go Implementation
// Core Domain - No external dependencies
package domain
type Order struct {
ID string
CustomerID string
Items []OrderItem
TotalAmount float64
Status OrderStatus
}
type OrderStatus string
const (
OrderPending OrderStatus = "pending"
OrderConfirmed OrderStatus = "confirmed"
OrderShipped OrderStatus = "shipped"
)
// Outbound Port - Interface for persistence
type OrderRepository interface {
Save(order *Order) error
FindByID(id string) (*Order, error)
FindByCustomer(customerID string) ([]*Order, error)
}
// Outbound Port - Interface for notifications
type NotificationService interface {
SendOrderConfirmation(order *Order) error
}
// Core Business Logic - Domain Service
type OrderService struct {
repo OrderRepository
notification NotificationService
}
func NewOrderService(repo OrderRepository, notif NotificationService) *OrderService {
return &OrderService{
repo: repo,
notification: notif,
}
}
// Business logic independent of infrastructure
func (s *OrderService) PlaceOrder(order *Order) error {
// Validate business rules
if err := s.validateOrder(order); err != nil {
return err
}
// Calculate total
order.TotalAmount = s.calculateTotal(order.Items)
order.Status = OrderPending
// Persist through port
if err := s.repo.Save(order); err != nil {
return err
}
// Notify through port
return s.notification.SendOrderConfirmation(order)
}
// Outbound Adapter - PostgreSQL implementation
package postgres
import "database/sql"
type PostgresOrderRepository struct {
db *sql.DB
}
func NewPostgresOrderRepository(db *sql.DB) *PostgresOrderRepository {
return &PostgresOrderRepository{db: db}
}
func (r *PostgresOrderRepository) Save(order *domain.Order) error {
query := `INSERT INTO orders (id, customer_id, total_amount, status)
VALUES ($1, $2, $3, $4)`
_, err := r.db.Exec(query, order.ID, order.CustomerID,
order.TotalAmount, order.Status)
return err
}
// Inbound Adapter - HTTP REST API
package http
import (
"encoding/json"
"net/http"
)
type OrderHandler struct {
service *domain.OrderService
}
func NewOrderHandler(service *domain.OrderService) *OrderHandler {
return &OrderHandler{service: service}
}
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var order domain.Order
if err := json.NewDecoder(r.Body).Decode(&order); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Delegate to core domain
if err := h.service.PlaceOrder(&order); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
Python Implementation
# Core Domain
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
@dataclass
class Order:
id: str
customer_id: str
items: List[dict]
total_amount: float = 0.0
status: OrderStatus = OrderStatus.PENDING
# Outbound Ports
class OrderRepository(ABC):
@abstractmethod
def save(self, order: Order) -> None:
pass
@abstractmethod
def find_by_id(self, order_id: str) -> Optional[Order]:
pass
class NotificationService(ABC):
@abstractmethod
def send_order_confirmation(self, order: Order) -> None:
pass
# Core Business Logic
class OrderService:
def __init__(self,
repository: OrderRepository,
notification: NotificationService):
self.repository = repository
self.notification = notification
def place_order(self, order: Order) -> None:
# Business logic
self._validate_order(order)
order.total_amount = self._calculate_total(order.items)
order.status = OrderStatus.PENDING
# Use ports
self.repository.save(order)
self.notification.send_order_confirmation(order)
def _validate_order(self, order: Order) -> None:
if not order.items:
raise ValueError("Order must contain items")
def _calculate_total(self, items: List[dict]) -> float:
return sum(item['price'] * item['quantity'] for item in items)
# Outbound Adapter - SQLAlchemy
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
class SQLAlchemyOrderRepository(OrderRepository):
def __init__(self, session: Session):
self.session = session
def save(self, order: Order) -> None:
# ORM mapping logic
order_model = OrderModel(
id=order.id,
customer_id=order.customer_id,
total_amount=order.total_amount,
status=order.status.value
)
self.session.add(order_model)
self.session.commit()
# Inbound Adapter - FastAPI
from fastapi import FastAPI, HTTPException
app = FastAPI()
# Dependency injection
def get_order_service() -> OrderService:
repo = SQLAlchemyOrderRepository(session)
notification = EmailNotificationService()
return OrderService(repo, notification)
@app.post("/orders")
def create_order(order_data: dict,
service: OrderService = Depends(get_order_service)):
try:
order = Order(**order_data)
service.place_order(order)
return {"status": "created", "order_id": order.id}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
React/TypeScript Frontend Hexagonal Architecture
// Core Domain - Business Logic
export interface User {
id: string;
email: string;
name: string;
}
// Outbound Port
export interface UserRepository {
findById(id: string): Promise<User>;
save(user: User): Promise<void>;
findAll(): Promise<User[]>;
}
// Core Business Logic
export class UserService {
constructor(private repository: UserRepository) {}
async getUser(id: string): Promise<User> {
const user = await this.repository.findById(id);
if (!user) {
throw new Error('User not found');
}
return user;
}
async updateUserProfile(id: string, name: string): Promise<void> {
const user = await this.getUser(id);
user.name = name;
await this.repository.save(user);
}
}
// Outbound Adapter - HTTP API
export class HttpUserRepository implements UserRepository {
constructor(private baseUrl: string) {}
async findById(id: string): Promise<User> {
const response = await fetch(`${this.baseUrl}/users/${id}`);
if (!response.ok) throw new Error('User not found');
return response.json();
}
async save(user: User): Promise<void> {
await fetch(`${this.baseUrl}/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
}
async findAll(): Promise<User[]> {
const response = await fetch(`${this.baseUrl}/users`);
return response.json();
}
}
// Inbound Adapter - React Component
import { useEffect, useState } from 'react';
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<string | null>(null);
// Dependency injection via context or props
const userService = useUserService();
useEffect(() => {
userService
.getUser(userId)
.then(setUser)
.catch((e) => setError(e.message));
}, [userId, userService]);
if (error) return <div>Error: {error}</div>;
if (!user) return <div>Loading...</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
Trade-offs and Considerations
Advantages
- Testability: Core logic tested without databases, APIs, or frameworks
- Flexibility: Swap implementations without changing business logic
- Technology Independence: Defer infrastructure decisions
- Clear Boundaries: Prevents business logic leakage into infrastructure
- Multiple Interfaces: Same core logic serves REST, GraphQL, CLI, events
Disadvantages
- Initial Complexity: More interfaces and indirection
- Learning Curve: Team needs to understand the pattern
- Boilerplate: More files and interfaces for simple operations
- Over-engineering Risk: Can be excessive for simple applications
- Performance Overhead: Abstraction layers can impact performance (usually negligible)
Best Practices
- Start Simple: Begin with clear ports, add adapters as needed
- Keep Domain Pure: No framework imports in core domain
- Use Dependency Injection: Wire adapters to ports at application startup
- Test at the Right Level: Unit test core logic, integration test adapters
- Document Port Contracts: Clear specifications for what each port does
- Limit Port Scope: Each port should have a single, well-defined responsibility
- Consider CQRS: Separate read and write ports for complex domains
Conclusion
Hexagonal Architecture provides a powerful pattern for building maintainable, testable systems by isolating business logic from infrastructure concerns. While it introduces upfront complexity, the long-term benefits of flexibility, testability, and clarity make it invaluable for systems with complex domains or evolving requirements.
For principal engineers, this pattern offers a proven approach to managing technical complexity, enabling teams to evolve infrastructure independently from business logic, and creating systems that can adapt to changing technology landscapes while preserving core domain knowledge.