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.