Guide

Hexagonal architecture explained

Harbor Commerce’s guest-checkout service was a single Express route file that imported Stripe SDK calls, a Prisma client, and Redis session helpers directly. When they needed the same “place order” flow from a mobile BFF, a nightly batch reconciler, and an integration test suite that ran without Docker, every new entry point duplicated validation logic or reached through HTTP mocks. Refactoring to hexagonal architecture — also called ports and adapters — put a small application core at the center with explicit boundaries: HTTP handlers became primary adapters calling inbound ports; Stripe and Postgres became secondary adapters behind outbound ports. Swapping Stripe for Adyen meant one new adapter class, not a rewrite of pricing rules. Hexagonal architecture, coined by Alistair Cockburn, treats your application as a hexagon (a metaphor for “many sides”) where the inside holds domain logic and the outside holds technology-specific glue. This guide explains inbound vs outbound ports, primary vs secondary adapters, how hexagonal relates to clean architecture and domain-driven design, a Harbor Commerce checkout worked example, an architecture decision table, common pitfalls, and a production checklist.

The hexagon metaphor

Traditional layered architecture stacks UI → service → repository → database vertically. That works until the “service” layer becomes a junk drawer importing everything. Hexagonal architecture flips the picture: the application core sits in the middle; the edges are ports (interfaces the core declares) and adapters (concrete implementations on the outside).

A port is a contract — often a Java interface, TypeScript type, or Python Protocol — that describes what the core needs or offers without naming technology. An adapter is the bridge: it translates between the port’s language and the outside world’s language (HTTP JSON to domain objects, domain events to Kafka records, domain money types to Stripe cents).

The hexagon shape is deliberate: unlike a triangle with one “top,” a hexagon has multiple equal sides, signaling that your app can be driven equally by a REST API, a CLI, a message consumer, or a test harness — each is just another primary adapter plugging into the same inbound ports.

Inbound ports vs outbound ports

Inbound ports (driving side)

Inbound ports define what the application does for the outside world. They are use-case APIs: PlaceOrder, CancelSubscription, GetInventoryLevel. Primary actors — humans via browsers, other services via RPC, cron schedulers — invoke these ports. The core implements the port; adapters call into it.

Outbound ports (driven side)

Outbound ports define what the application needs from the outside: PaymentGateway, OrderRepository, EmailNotifier. The core calls these interfaces; secondary adapters implement them with real infrastructure. Dependency inversion is the rule: the core depends on abstractions; adapters depend on the core.

Naming helps in code reviews: if a type lives in application/ports/in or is named PlaceOrderUseCase, it is inbound. If it is ports/out/PaymentGateway, it is outbound. Mixing the two in one “Service” god-class is the most common regression after a successful hexagonal refactor.

Primary adapters vs secondary adapters

Primary adapters (driving adapters) sit on the inbound edge. They receive external input, map it to domain types, invoke an inbound port, and map the response back. Examples: Express route handlers, GraphQL resolvers, Kafka consumers that trigger use cases, CLI commands, and test fixtures that call use cases directly.

Secondary adapters (driven adapters) sit on the outbound edge. They implement outbound ports using concrete technology: a PostgresOrderRepository, a StripePaymentAdapter, a SendGridEmailAdapter. The core never imports Stripe; only the adapter does.

Wiring happens in a composition root — often main.ts, a DI container module, or a framework module that constructs adapters and injects them into use-case constructors. Frameworks like NestJS make this explicit with providers; in Go you might use manual constructor injection in cmd/server. See dependency injection for how containers reduce boilerplate without hiding the hexagon boundaries.

Harbor Commerce guest checkout: a worked example

Harbor Commerce’s refactor started by drawing two boxes: Place Guest Order (inbound) and the outbound ports it required: InventoryService, PaymentGateway, OrderRepository, and FraudScorer. The use-case implementation lived in plain TypeScript with zero Express imports.

  1. Define the inbound portPlaceGuestOrderCommand with cart lines, shipping address, and payment token reference. Return OrderConfirmation or typed errors (InsufficientStock, PaymentDeclined).
  2. Implement the use case — validate cart totals, reserve inventory via InventoryService, authorize payment via PaymentGateway, persist via OrderRepository, emit confirmation. Business rules (free-shipping threshold, tax rounding) stay here.
  3. Primary adapter: REST — Express handler parses JSON, maps to PlaceGuestOrderCommand, calls the use case, maps domain errors to HTTP status codes. A separate BFF mobile adapter reuses the same inbound port with a thinner DTO.
  4. Secondary adaptersPrismaOrderRepository, StripePaymentAdapter, RedisInventoryAdapter (wrapping their existing cache layer), RulesEngineFraudAdapter.
  5. Tests without Docker — unit tests inject in-memory fakes implementing outbound ports; contract tests hit real Postgres and Stripe only in the adapter packages.

Six weeks later Harbor added a Kafka consumer that replays failed payments from a dead-letter queue. It was a new primary adapter calling RetryPaymentUseCase — no changes to payment rules inside the core. That is the payoff: technology churn stays at the edges.

How hexagonal relates to clean architecture and DDD

Hexagonal, clean, and onion architectures share the same goal: protect domain logic from infrastructure. Clean architecture draws concentric rings (entities, use cases, interface adapters, frameworks); hexagonal emphasizes the symmetric port vocabulary. Onion architecture is essentially hexagonal with explicit domain and application layers. You can combine all three: DDD tactical patterns (aggregates, value objects) live inside the hexagon; use cases are inbound port implementations; repositories are outbound ports.

Hexagonal does not require microservices. A modular monolith with well-defined ports per bounded context is often the right first step. Extract a service later by moving an adapter bundle behind a network boundary — the port interface becomes your RPC contract.

Architecture decision table

Approach Best when Trade-off
Hexagonal (ports/adapters) Multiple entry points (HTTP + queue + CLI), swappable infra, strong unit-test needs More interfaces and mapping code upfront
Classic layered (MVC) Simple CRUD, single framework, small team, fast prototype Business logic drifts into controllers or ORM models
Clean architecture rings Large enterprise apps with explicit policy layers and long maintenance horizons Risk of over-layering if applied dogmatically to trivial features
Transaction script One-off scripts, admin tools, < 500 LOC services No structure for growth; copy-paste across endpoints
Microservices per bounded context Independent deploy cadence, team autonomy, clear domain splits Network latency, distributed transactions, ops overhead

Folder layouts that stay honest

Two popular layouts both work if ports stay visible:

  • Package by layer inside a modulecheckout/domain, checkout/application, checkout/adapters/in/rest, checkout/adapters/out/persistence. Good for one bounded context per deployable.
  • Package by feature with ports subfolderorders/place-order.usecase.ts, orders/ports/payment-gateway.ts, orders/adapters/stripe-payment.ts. Good when teams own vertical slices.

Anti-pattern: services/OrderService.ts that imports prisma, stripe, and express in the same file. If you cannot draw inbound and outbound arrows on a whiteboard without crossing the core, the boundary has leaked.

Testing strategy

Hexagonal architecture earns its keep in tests. Unit tests target use cases with fake outbound ports (in-memory repositories, stub payment gateways) — milliseconds per test, no network. Adapter integration tests verify that StripePaymentAdapter correctly maps domain money to Stripe API calls using Stripe’s test mode. End-to-end tests drive primary adapters through HTTP but should stay a thin slice; most scenarios belong in the layers below.

Contract tests between services can reuse outbound port interfaces: if Team A exposes an inbound port over gRPC, Team B’s client adapter implements the same port shape their core already expects.

Common pitfalls

  • Anemic ports — interfaces that mirror ORM entities one-to-one add ceremony without isolating behavior. Ports should express domain operations, not table rows.
  • Leaky adapters — returning Prisma model types from outbound ports lets database schema infect the core. Map to domain types inside the adapter.
  • Hexagon for hexagon’s sake — a three-endpoint internal tool does not need twelve interfaces. Start with transaction scripts; extract ports when a second adapter appears.
  • Shared mutable state between adapters — singleton Prisma clients are fine; singleton domain caches shared across requests without clear ownership cause race bugs.
  • Ignoring cross-cutting concerns — logging, metrics, and auth belong in decorator adapters or middleware wrapping primary adapters, not sprinkled inside use-case conditionals.

Production checklist

  • Draw inbound ports (use cases) and outbound ports (dependencies) before writing adapters.
  • Keep the application core free of framework imports (HTTP, ORM, SDK clients).
  • Map DTOs at adapter boundaries; never pass request JSON deep into use cases.
  • Wire dependencies in one composition root per deployable.
  • Provide in-memory fake adapters for every outbound port used in unit tests.
  • Document which primary adapters exist (REST, GraphQL, queue, CLI) and which ports they call.
  • Version outbound port interfaces carefully when extracting microservices.
  • Monitor adapter latency separately from use-case logic to spot infra regressions.
  • Revisit port granularity when a use case imports more than five outbound ports.

Key takeaways

  • Ports are contracts the application core owns; adapters are replaceable technology on the outside.
  • Inbound ports express what the app does; outbound ports express what the app needs.
  • Primary adapters drive the hexagon; secondary adapters are driven by it.
  • Hexagonal pairs naturally with DDD and clean architecture without mandating microservices.
  • Extract ports when duplication hurts, not before you have a second real adapter.

Related reading