Engineering Atlas › System Design Fundamentals

Ports and adapters: hexagonal architecture

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Inside and outside

Alistair Cockburn described the pattern in 2005, and he is explicit that the hexagon is not a hexagon because six matters: the shape just leaves room to draw ports, and most applications have two to four (Cockburn). Inside sit domain entities and application services (use cases). Outside sit the things that change for technical reasons: web frameworks, message brokers, database drivers, cloud SDKs. The rule is the dependency direction: outside code depends on inside code, never the reverse.

Driving and driven ports

A driving (primary) port is how the world asks the application to do something, for example PlaceOrder.execute(command). A REST controller, a CLI, a Kafka consumer, and a test are all driving adapters that translate their input into the same call. That symmetry was the original goal: an application that can be driven equally by users, other programs, automated tests or batch scripts (Cockburn). A driven (secondary) port is what the application needs from the world, for example OrderRepository.save(order) or Payments.charge(amount, token). A Postgres repository and an in-memory fake are both driven adapters for the same port.

Wiring

Something must construct the adapters and inject them into the core. That is the composition root, usually main or a dependency-injection container. It is the only place that knows which concrete adapters are in use. Swapping Stripe for Adyen, or Postgres for DynamoDB, changes the composition root and one adapter, not the use cases.

Relationship to other styles

Onion Architecture (Palermo, 2008) and Clean Architecture (Martin, 2012) express the same idea with more prescribed rings, and Martin's "Dependency Rule" (source code dependencies point only inward) is the shared core. All three are dependency inversion at the architecture level. Hexagonal is the lightest: it insists on the inside/outside boundary and explicit ports, and says nothing about how the inside is organised. A layered domain inside the hexagon is allowed, not required.

When a driven port faces a legacy system or a partner with a messy model, the adapter is also where translation lives, so the outside model never leaks in. Microsoft calls that adapter an anti-corruption layer (Azure Architecture Center).

Testing benefits

Because the core depends only on port interfaces, use-case tests run with in-memory adapters and no containers. A suite of 2,000 such tests can finish in a few seconds. Adapter tests then run separately against real infrastructure, checking only translation and integration, which keeps slow tests few and focused.

Down the stack

Layer 1The contract

A port is a contract the core states in its own terms: method signatures, domain types in and out, the errors it can raise, and semantic guarantees that adapters must honour. For a repository port that includes "save is atomic for one aggregate" and "load after save returns the saved state". For a payments port it includes "charge with the same idempotency key at most once". If a guarantee is not written into the port, the core cannot rely on it, and a fake adapter will not enforce it.

Layer 2The mechanism

The mechanism is dependency inversion enforced by the build. In Java, the core is a module with no dependency on Spring, JPA, or SDK artifacts; adapters live in modules that depend on the core and implement its interfaces, and ArchUnit tests fail the build if a class in ..domain.. imports org.springframework... ArchUnit ships a ready-made onionArchitecture() rule (its guide notes it covers hexagonal too) that takes the domain, application and adapter packages and checks every dependency between them (ArchUnit user guide). In Go, the core package declares small interfaces, adapters satisfy them implicitly, and the import graph (checked with go list -deps or a linter) must never point from core to adapter. The composition root, usually main, constructs a PostgresOrderRepository with a *sql.DB and passes it to NewPlaceOrder(repo, payments, clock). A unit-of-work port wraps a database transaction so a use case can save two aggregates atomically without importing the driver.

Layer 3The machine

The runtime cost of the indirection is small. An interface call is an indirect jump through a vtable or Go itab, about 1 to 2 ns, and often devirtualised: the JVM's JIT does it when one implementation dominates, and since Go 1.21 profile-guided builds add a direct call to the most common callee so it can be inlined (Go 1.21 release notes). Mapping between domain objects and persistence rows allocates: copying a 1 KB object per request at 10,000 requests per second is 10 MB per second of extra garbage, noticeable in GC profiles but rarely in latency. The real physics is in testing. A call to an in-memory adapter takes around 100 ns; a round trip to Postgres takes 0.3 to 1 ms; starting a database container for a test run takes 5 to 10 seconds. A suite of 2,000 use-case tests is milliseconds of CPU against fakes and minutes against real I/O.

Why it's built this way

  1. Business rules and technologies change at different rates and for different reasons, so they are separated.
  2. Tests need fast, deterministic feedback, and I/O is slow and nondeterministic, so I/O is pushed behind ports.
  3. Swapping a dependency should not ripple through rules, so the core owns the interfaces and adapters depend on it.
  4. An indirect call costs nanoseconds while I/O costs milliseconds, so the extra layer is affordable.

Diagram

flowchart LR
    REST["REST controller"] --> IN["Driving port: PlaceOrder"]
    CLI["CLI command"] --> IN
    MQ["Queue consumer"] --> IN
    IN --> CORE["Domain + use cases"]
    CORE --> R["Driven port: OrderRepository"]
    CORE --> P["Driven port: Payments"]
    R --> PG["Postgres adapter"]
    R --> MEM["In-memory adapter (tests)"]
    P --> STRIPE["Payment provider adapter"]
    P --> FAKE["Fake payments (tests)"]

Worked example

A subscription billing service has grown for six years. Business rules (proration, trials, dunning) are tangled with ORM calls and payment SDK objects. The test suite needs a database container and takes 25 minutes, so engineers skip it locally.

The team introduces ports incrementally. First, a BillingAccounts driven port with load(id) and save(account), plus a PaymentProvider port with charge(account_id, cents, idempotency_key). The proration logic moves into a pure Subscription entity that takes dates and prices and returns invoice lines. The existing ORM code becomes the Postgres adapter.

class ChangePlan:
    def __init__(self, accounts, payments, clock):
        self.accounts, self.payments, self.clock = accounts, payments, clock

    def execute(self, account_id, new_plan):
        acct = self.accounts.load(account_id)
        lines = acct.subscription.change_plan(new_plan, self.clock.today())
        due = sum(l.cents for l in lines)
        if due > 0:
            self.payments.charge(account_id, due, key="chg-%s-%s" % (account_id, new_plan.id))
        self.accounts.save(acct)
        return lines

After three months, 1,800 use-case tests run in 6 seconds with in-memory adapters, and 120 adapter tests run against real Postgres and the provider's sandbox in 4 minutes. When the company adds a second payment provider for one region, only a new adapter and a routing rule in the composition root change.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Layered (controller, service, DAO)Familiar, little ceremonyDomain often leaks into DAOs and frameworksSimple apps with modest logic
HexagonalFramework-free core, fast tests, swappable infrastructureMore interfaces and mapping codeLong-lived services with real business rules
Clean or OnionStrong, explicit rings and rulesHeavier structure, risk of ceremonyLarge teams that need enforced conventions
Transaction script or CRUDVery fast to buildLogic scatters as rules growThin data services, prototypes

Pitfalls in production

Leadership lens

Check yourself

  1. What is the difference between a driving and a driven port?

    Show answer

    A driving port is an entry point the outside world calls to use the application; a driven port is an interface the application calls to reach infrastructure or other systems.

  2. Why must ports use domain types rather than ORM or SDK types?

    Show answer

    Otherwise the core imports and depends on that technology, which defeats the isolation and makes the core untestable without it.

  3. Where do concrete adapters get chosen and wired in?

    Show answer

    In the composition root, typically main or a DI container, which is the only code that knows both the core and the concrete adapters.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →