Detail chart · Backend

Hexagonal Architecture

Pattern ◆◆◆◆◇

Isolate application core logic from external dependencies using ports and adapters.

Summary

Hexagonal Architecture (also called Ports and Adapters) structures an application so that the core domain logic is entirely independent of external systems — databases, APIs, UIs, and messaging infrastructure. External interactions flow through defined ports (interfaces) and are wired up via adapters at runtime.

Problem

Application logic becomes tightly coupled to infrastructure concerns: database queries embedded in business logic, HTTP transport leaking into domain models, testing requiring live external services.

Solution

Define the application core as a set of ports — interfaces that describe what the application needs (driven/secondary ports) or what it exposes to callers (driving/primary ports). Adapters implement those interfaces and translate between the external world and the application.

         [ UI Adapter ]   [ CLI Adapter ]
                  \           /
              [ Primary Port (inbound) ]
                       |
              [ Application Core ]
                       |
              [ Secondary Port (outbound) ]
                  /           \
         [ DB Adapter ]   [ HTTP Adapter ]

The core contains use cases and domain logic. It never imports adapters. Adapters import the core and implement or call its ports.

// Secondary port — defined by the core, implemented by an adapter
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

// Application core — depends only on the port interface
class PlaceOrderUseCase {
  constructor(private readonly orders: OrderRepository) {}

  async execute(cmd: PlaceOrderCommand): Promise<void> {
    const order = Order.create(cmd.items, cmd.customerId);
    await this.orders.save(order);
  }
}

// Adapter — implements the port, owns the infrastructure detail
class PostgresOrderRepository implements OrderRepository {
  constructor(private readonly db: Pool) {}

  async save(order: Order): Promise<void> {
    await this.db.query(
      `INSERT INTO orders (id, customer_id, items) VALUES ($1, $2, $3)`,
      [order.id, order.customerId, JSON.stringify(order.items)]
    );
  }

  async findById(id: string): Promise<Order | null> {
    const row = await this.db.query(`SELECT * FROM orders WHERE id = $1`, [id]);
    return row ? Order.fromRow(row) : null;
  }
}

// Wiring — swapping to an in-memory adapter for tests requires no core changes
const useCase = new PlaceOrderUseCase(new InMemoryOrderRepository());

PlaceOrderUseCase never references Postgres. A test suite can inject InMemoryOrderRepository and exercise the same domain logic with zero infrastructure.

The same shape holds in Python, using Protocol for the port and structural typing for the adapters:

from typing import Protocol, Optional

# Secondary port — defined by the core, implemented by an adapter
class OrderRepository(Protocol):
    async def save(self, order: "Order") -> None: ...
    async def find_by_id(self, order_id: str) -> Optional["Order"]: ...


# Application core — depends only on the port protocol
class PlaceOrderUseCase:
    def __init__(self, orders: OrderRepository) -> None:
        self._orders = orders

    async def execute(self, cmd: "PlaceOrderCommand") -> None:
        order = Order.create(cmd.items, cmd.customer_id)
        await self._orders.save(order)


# Adapter — implements the port, owns the infrastructure detail
class PostgresOrderRepository:
    def __init__(self, pool: "Pool") -> None:
        self._pool = pool

    async def save(self, order: "Order") -> None:
        await self._pool.execute(
            "INSERT INTO orders (id, customer_id, items) VALUES ($1, $2, $3)",
            order.id, order.customer_id, order.items,
        )

    async def find_by_id(self, order_id: str) -> Optional["Order"]:
        row = await self._pool.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
        return Order.from_row(row) if row else None


# Wiring — swapping to an in-memory adapter for tests requires no core changes
use_case = PlaceOrderUseCase(InMemoryOrderRepository())

Because OrderRepository is a Protocol, PostgresOrderRepository and InMemoryOrderRepository never need to declare the interface explicitly — the core depends on shape, not inheritance, keeping adapters free to evolve independently.

When to Use

Avoid when:

Trade-offs

BenefitCost
Core logic is fully unit-testable without infrastructureMore boilerplate: interfaces, adapters, wiring
Infrastructure is swappable without touching domain codeIndirection can make control flow harder to trace
Clear dependency direction enforced by structureRequires discipline to avoid leaking adapters into core
Supports parallel development (core vs. adapters)Initial setup cost is higher than layered architecture

Live Playground

Experiment with the pattern below. before.ts couples the use case directly to Postgres; after.ts extracts an OrderRepository port and swaps in an in-memory adapter with zero changes to the core. Edit either file and the preview updates in real time.