Skip to main content
Clean Architecture 80 XP · 8 min

Ports and Adapters (Hexagonal Architecture)

The inside is the domain; the outside is infrastructure — outside depends on inside, ports speak the domain's language, and adapters translate between domain and external systems.

Showing
Ad (728×90)

Why this matters

Hexagonal Architecture (Ports and Adapters), introduced by Alistair Cockburn, organizes a system into two zones: the inside (domain entities + use cases + port interfaces) and the outside (adapters for HTTP, database, email, queues). The cardinal rule: outside depends on inside — never the reverse.

Ports are interfaces defined in the domain using domain language. They declare what the domain needs, not how it will be supplied. There are two kinds: Driving ports (called by adapters to trigger use cases — e.g. HTTP controller calling a use case) and Driven ports (implemented by adapters at the domain's request — e.g. a repository interface implemented by a PostgreSQL adapter).

Port naming is critical. A port belongs to the domain and must speak the domain's language. A port named OrderPersistenceGateway with a method persistEntity() has been contaminated by infrastructure vocabulary. The port should be named Orders with a method save(). The adapter translates; the port describes.

The problem

Port interface named OrderPersistenceGateway with methods using infrastructure vocabulary — infrastructure concepts leak into the domain language.

Bad

from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class OrderEntity:
    id: str
    total: float
    status: str

# Named "port" but speaks infrastructure language
class OrderPersistenceGateway(ABC):    # ← technical name
    @abstractmethod
    def fetchById(self, record_id: str) -> OrderEntity: ...  # "fetch", "record"

    @abstractmethod
    def persistEntity(self, entity: OrderEntity) -> None: ...  # "persist", "entity"

    @abstractmethod
    def removeRecord(self, record_id: str) -> None: ...        # "record"

# A business analyst reading this sees SQL vocabulary, not business operations.
# The infrastructure is leaking into the domain through naming.
// domain/ports/OrderPersistenceGateway.ts  ← wrong name, wrong language
export interface OrderEntity { id: string; total: number; status: string; }

export interface OrderPersistenceGateway {
  fetchById(recordId: string): Promise;    // "fetch", "record" ← DB language
  persistEntity(entity: OrderEntity): Promise;   // "persist", "entity" ← ORM language
  removeRecord(recordId: string): Promise;        // "record" ← DB language
}

// Infrastructure vocabulary leaked into the domain.
// This port cannot be read without thinking about databases.

The solution

Port Orders with methods find, save, remove — pure domain language. PostgresOrders and InMemoryOrders both implement the domain port.

Good

from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class Order:
    id: str
    total: float
    status: str

# domain/ports/orders.py  ← pure domain language
class Orders(ABC):                              # domain name: "Orders"
    @abstractmethod
    def find(self, order_id: str) -> Order: ... # business verb: "find"

    @abstractmethod
    def save(self, order: Order) -> None: ...   # business verb: "save"

    @abstractmethod
    def remove(self, order_id: str) -> None: ... # business verb: "remove"

# adapters/postgres_orders.py  ← adapter translates domain to SQL
import psycopg2

class PostgresOrders(Orders):
    def __init__(self, dsn: str): self._conn = psycopg2.connect(dsn)

    def find(self, order_id: str) -> Order:
        cur = self._conn.cursor()
        cur.execute("SELECT id, total, status FROM orders WHERE id = %s", (order_id,))
        row = cur.fetchone()
        return Order(id=row[0], total=row[1], status=row[2])

    def save(self, order: Order) -> None:
        cur = self._conn.cursor()
        cur.execute(
            "INSERT INTO orders (id, total, status) VALUES (%s, %s, %s) "
            "ON CONFLICT (id) DO UPDATE SET total=%s, status=%s",
            (order.id, order.total, order.status, order.total, order.status)
        )
        self._conn.commit()

    def remove(self, order_id: str) -> None:
        self._conn.cursor().execute("DELETE FROM orders WHERE id = %s", (order_id,))
        self._conn.commit()

# adapters/in_memory_orders.py  ← test adapter
class InMemoryOrders(Orders):
    def __init__(self): self._store: dict[str, Order] = {}
    def find(self, order_id: str) -> Order:  return self._store[order_id]
    def save(self, order: Order) -> None:    self._store[order.id] = order
    def remove(self, order_id: str) -> None: del self._store[order_id]
// domain/ports/Orders.ts  ← pure domain language
export interface Order { id: string; total: number; status: string; }

export interface Orders {                    // domain name: "Orders"
  find(orderId: string): Promise;    // business verb: "find"
  save(order: Order): Promise;        // business verb: "save"
  remove(orderId: string): Promise;   // business verb: "remove"
}

// adapters/PostgresOrders.ts  ← adapter translates domain to SQL
import { Pool } from "pg";
import { Orders, Order } from "../domain/ports/Orders";

export class PostgresOrders implements Orders {
  constructor(private pool: Pool) {}

  async find(orderId: string): Promise {
    const result = await this.pool.query(
      "SELECT id, total, status FROM orders WHERE id = $1", [orderId]
    );
    return result.rows[0] as Order;
  }

  async save(order: Order): Promise {
    await this.pool.query(
      "INSERT INTO orders (id, total, status) VALUES ($1, $2, $3) " +
      "ON CONFLICT (id) DO UPDATE SET total=$2, status=$3",
      [order.id, order.total, order.status]
    );
  }

  async remove(orderId: string): Promise {
    await this.pool.query("DELETE FROM orders WHERE id = $1", [orderId]);
  }
}

// adapters/InMemoryOrders.ts  ← test adapter
import { Orders, Order } from "../domain/ports/Orders";

export class InMemoryOrders implements Orders {
  private store = new Map();
  async find(orderId: string): Promise  { return this.store.get(orderId)!; }
  async save(order: Order):    Promise   { this.store.set(order.id, order); }
  async remove(orderId: string): Promise { this.store.delete(orderId); }
}

Key takeaway

Ports belong to the domain and speak its language. If a port method is named fetchRecord instead of findOrder, the infrastructure is leaking into your domain. The adapter's job is to translate; the port's job is to describe what the business needs in the business's own words.

Done with this lesson?

Mark it complete to earn XP and track your progress.