Skip to main content

Sign in to CleanKata

Track your progress, earn XP, and unlock every lesson.

By signing in you agree to our Terms of Use and Privacy Policy.

Clean Architecture80 XP8 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.

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.

🔧 Some exercises may still have errors. If something seems wrong, use the Feedback button (bottom-right of the page) to report it — it helps us fix it fast.

Hint: 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.

✗ Your version

Ports and Adapters (Hexagonal Architecture) — CleanKata — CleanKata