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:

Ports (Interfaces)

Ports are the boundaries between the core and the outside world. They define contracts without specifying implementation:

Adapters (Implementations)

Adapters implement the ports and translate between the core domain and external systems:

When to Use Hexagonal Architecture

Ideal Scenarios

  1. Complex Business Logic: When domain rules are sophisticated and change frequently
  2. Multiple Interfaces: System accessed via REST API, GraphQL, CLI, and message queues
  3. Testing Requirements: Need for extensive unit testing without infrastructure dependencies
  4. Technology Evolution: Anticipate changing databases, frameworks, or external services
  5. Microservices: Each service benefits from clear boundaries and independence
  6. Long-lived Systems: Projects expected to evolve over many years

When to Skip It

  1. Simple CRUD Applications: Overhead outweighs benefits for basic data operations
  2. Prototypes: Premature abstraction slows down experimentation
  3. Small Scripts: One-off utilities don’t need architectural rigor
  4. 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

  1. Testability: Core logic tested without databases, APIs, or frameworks
  2. Flexibility: Swap implementations without changing business logic
  3. Technology Independence: Defer infrastructure decisions
  4. Clear Boundaries: Prevents business logic leakage into infrastructure
  5. Multiple Interfaces: Same core logic serves REST, GraphQL, CLI, events

Disadvantages

  1. Initial Complexity: More interfaces and indirection
  2. Learning Curve: Team needs to understand the pattern
  3. Boilerplate: More files and interfaces for simple operations
  4. Over-engineering Risk: Can be excessive for simple applications
  5. Performance Overhead: Abstraction layers can impact performance (usually negligible)

Best Practices

  1. Start Simple: Begin with clear ports, add adapters as needed
  2. Keep Domain Pure: No framework imports in core domain
  3. Use Dependency Injection: Wire adapters to ports at application startup
  4. Test at the Right Level: Unit test core logic, integration test adapters
  5. Document Port Contracts: Clear specifications for what each port does
  6. Limit Port Scope: Each port should have a single, well-defined responsibility
  7. 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.