|$ curl https://forge-ai.dev/api/markdown?path=docs/architecture/hexagonal
$cat docs/hexagonal-/-ports-&-adapters.md
updated TodayΒ·22-30 min readΒ·published

Hexagonal / Ports & Adapters

β—†Architectureβ—†Hexagonalβ—†Clean Architectureβ—†Intermediate🎯Free Tools
Introduction

Hexagonal architecture (ports and adapters) places the domain and application use cases at the center. External concerns β€” HTTP, databases, message buses, third-party APIs β€” connect through ports (interfaces) and adapters (implementations). Clean Architecture applies the same dependency rule: source code dependencies point inward toward entities and use cases.

Why: protect business rules from framework churn; enable testing without DB/UI; swap adapters when requirements change (new payment provider, new UI). How: define ports in the inside, implement adapters on the outside, inject dependencies at composition root.

Core Concepts

Entities / domain model

Business invariants and rules. Pure of frameworks.

Use cases / application services

Orchestrate entities to fulfill a requirement; define required ports.

Ports

Interfaces for driving (inbound) and driven (outbound) interactions.

Adapters

HTTP controllers, CLI, ORM repositories, email senders implementing ports.

Composition root

Wires adapters to use cases at startup β€” main.ts / container.

Port kindExampleAdapter examples
Driving (inbound)PlaceOrderUseCase interfaceREST controller, GraphQL, CLI
Driven (outbound)PaymentPort, OrderRepositoryStripe adapter, Postgres adapter
Dependency Inversion

The domain defines what it needs (PaymentPort). Infrastructure implements it. Thus domain does not depend on Stripe SDK β€” Stripe depends (at runtime wiring) on the port contract. This is dependency inversion applied at architecture scale.

βœ“

best practice

Do not create a port for every trivial call. Port what varies or what you must test against. Over-porting is ceremony.
Node.js Project Sketch
node-hexagonal.txt
TEXT
1src/
2 domain/
3 order.ts
4 order-errors.ts
5 application/
6 place-order.ts # use case
7 ports/
8 order-repository.ts
9 payment-port.ts
10 clock.ts
11 adapters/
12 http/
13 place-order.controller.ts
14 persistence/
15 pg-order-repository.ts
16 payment/
17 stripe-payment-adapter.ts
18 main.ts # composition root
place-order.ts
TypeScript
1export type PaymentPort = {{
2 charge: (input: {{ customerId: string; amount: number; currency: string }}) => Promise<{{ chargeId: string }}>;
3}};
4
5export type OrderRepository = {{
6 save: (order: Order) => Promise<void>;
7}};
8
9export function placeOrder(deps: {{ payments: PaymentPort; orders: OrderRepository }}) {{
10 return async (cmd: {{ customerId: string; items: Item[] }}) => {{
11 const order = Order.create(cmd.items);
12 const {{ chargeId }} = await deps.payments.charge({{
13 customerId: cmd.customerId,
14 amount: order.total,
15 currency: "usd",
16 }});
17 order.markPaid(chargeId);
18 await deps.orders.save(order);
19 return order.id;
20 }};
21}}
Python Project Sketch
python-hexagonal.txt
TEXT
1src/
2 domain/
3 order.py
4 application/
5 place_order.py
6 ports.py
7 adapters/
8 http/
9 fastapi_routes.py
10 persistence/
11 sqlalchemy_orders.py
12 payment/
13 stripe_adapter.py
14 main.py
place_order.py
Python
1from dataclasses import dataclass
2from typing import Protocol
3
4class PaymentPort(Protocol):
5 def charge(self, customer_id: str, amount: int, currency: str) -> str: ...
6
7class OrderRepository(Protocol):
8 def save(self, order: "Order") -> None: ...
9
10@dataclass
11class PlaceOrder:
12 payments: PaymentPort
13 orders: OrderRepository
14
15 def __call__(self, customer_id: str, items: list) -> str:
16 order = Order.create(items)
17 charge_id = self.payments.charge(customer_id, order.total, "usd")
18 order.mark_paid(charge_id)
19 self.orders.save(order)
20 return order.id
When Requirements Justify Hexagonal
RequirementWhy hexagonal
Swap vendors / DBs without rewriteAdapters replaceable
High domain complexityKeeps rules testable and central
Long-lived system, changing frameworksUI/DB churn isolated
Need fast unit tests without DBFake ports in tests

When it may be overkill: tiny CRUD apps with stable stacks and low domain logic β€” a simple layered modular structure can suffice. You can still avoid framework leakage without full ports everywhere.

Common Mistakes
  • Ports that leak HTTP or ORM types into domain.
  • Anemic use cases + anemic entities β€” complexity moved nowhere useful.
  • Hundreds of one-method interfaces with one implementation forever.
  • Skipping the composition root and constructing adapters deep inside domain.
  • Calling it Clean/Hexagonal while domain imports Express/Django models.
Clean Architecture Mapping

Clean Architecture names concentric circles: entities, use cases, interface adapters, frameworks/drivers. The rule is the same as hexagonal: dependencies point inward. Controllers and gateways are adapters; presenters shape output without leaking into entities.

Clean circleHexagonal analog
EntitiesDomain model
Use casesApplication services
Interface adaptersControllers, presenters, gateways
Frameworks/driversWeb framework, DB driver, devices
Testing Strategy
  • Domain tests: pure, no mocks needed beyond simple values.
  • Use case tests: fake ports (in-memory repos, fake payments).
  • Adapter tests: contract tests against DB or HTTP sandbox.
  • Narrow E2E through driving adapters for critical journeys.
fake-payment.ts
TypeScript
1export function fakePayment(): PaymentPort {{
2 return {{
3 async charge({{ amount }}) {{
4 if (amount <= 0) throw new Error("invalid");
5 return {{ chargeId: "ch_test_" + amount }};
6 }},
7 }};
8}}
Migrating Toward Hexagonal

You rarely rewrite overnight. Strangle:

  • 1. Identify a critical use case (place order).
  • 2. Extract domain types used by that use case.
  • 3. Define outbound ports for IO it needs.
  • 4. Wrap existing ORM calls in an adapter implementing the port.
  • 5. Point the controller at the new use case.
  • 6. Repeat; delete dead layered pass-throughs.
πŸ“

note

During migration, temporary anti-corruption adapters around legacy modules are expected β€” document them as transitional in ADRs.
Hexagonal Checklist
hexagonal-checklist.txt
TEXT
1[ ] Domain has no framework/DB imports
2[ ] Use cases depend on ports, not adapters
3[ ] Composition root wires implementations
4[ ] Tests cover use cases with fakes
5[ ] Ports exist only where variation/test seams needed
6[ ] ADR explains why hexagonal was chosen
Requirements Mapping
RequirementHexagonal response
Annual payment vendor RFPPaymentPort + new adapter
Offline mode / alternate UINew driving adapter; same use cases
Deterministic domain tests in CIPure domain + fake ports
Framework migration (Express→Fastify)Replace HTTP adapter only

Community

Get help on Slack, Discord or VIP

Stuck on a guide? Join the community and ask.