rgoussu@goussu: ~/library/applicative-architecture
~/library/applicative-architecture cat hexagonal-architecture.md

Hexagonal architecture

# Ports & adapters — isolating the domain from infrastructure so the application is testable and technology-agnostic at its edges.

Conceptsaved 2026-08-08updated 2026-08-14 #hexagonal-architecture#architecture#ddd#design#modulith

Overview

Hexagonal architecture (Alistair Cockburn, a.k.a. ports & adapters) structures an application so the domain logic sits at the center, defining ports (interfaces) for everything it needs from or offers to the outside world; adapters (HTTP controllers, database gateways, message consumers…) plug into those ports. Dependencies always point inward, so the domain knows nothing about frameworks, databases, or transport — making it testable in isolation and resilient to technology churn.

Key points

  • Driving vs. driven sides: primary (driving) adapters call the application (REST controller, CLI, scheduler); secondary (driven) adapters are called by it through ports (persistence, messaging, external APIs).
  • The dependency rule: domain depends on nothing; adapters depend on the domain via its ports — dependency inversion applied at architecture scale.
  • Test strategy falls out: domain tested with plain unit tests, ports contract-tested, adapters integration-tested; end-to-end kept thin.
  • One hexagon is not an architecture — a system has several bounded contexts, and the house answer is the modulith: one hexagon per context under modules/, composed into runnable assemblies under application/. The full layout and its dependency rules are in the Details.
  • Siblings: onion architecture and Clean Architecture (Martin) are the same idea with different layering vocabulary; don't get lost in the taxonomy.
  • Cost: indirection and mapping (DTOs ↔ domain objects) — overkill for CRUD-only services, pays off when the domain has real logic.
  • To explore: where transaction boundaries live, mapping strategies at the ports, hexagonal + DDD tactical patterns as the interior.

The shape, with every dependency arrow pointing inward:

flowchart LR
    subgraph primary["Driving side (primary adapters)"]
        REST["REST controller"]
        CLI["CLI"]
        SCHED["Scheduler"]
    end
    subgraph hexagon["The hexagon"]
        DRIVING["Driving ports"]
        DOMAIN["Domain logic"]
        DRIVEN["Driven ports"]
    end
    subgraph secondary["Driven side (secondary adapters)"]
        PERSIST["Persistence"]
        MSG["Messaging"]
        EXT["External APIs"]
    end
    REST -->|"calls"| DRIVING
    CLI -->|"calls"| DRIVING
    SCHED -->|"calls"| DRIVING
    DRIVING --- DOMAIN
    DOMAIN --- DRIVEN
    PERSIST -->|"implements"| DRIVEN
    MSG -->|"implements"| DRIVEN
    EXT -->|"implements"| DRIVEN

Details

The house reference principles

Four reference implementations — Java, Go, Rust, Frontend (web components) — realize one shared set of principles, each with its own enforcement mechanism (Maven module graph, nested internal/ + linter, Cargo crate graph, npm workspace + TS project references). Each implementation stands on its own; the principles live here:

  • One hexagon per bounded context, several hexagons per repository. The favored layout is a modulith: modules/<context>/ holds a self-contained hexagon (user-side/, domain/, infra/), platform/ holds what every context shares, and application/ holds the assemblies that compose modules into runnable artifacts. The flat trisection (application/ + domain/ + infrastructure/ at the root) is the degenerate one-context case of the same rules — see the favored layout below.
  • One deployment unit per delivery typology, not per adapter: an application/ assembly (api, consumer, cron, …) is the runnable, containerised artifact, and it hosts the matching user-side adapters of every module it assembles. Its containerisation definition (Dockerfile and the likes) lives beside it, never centralised.
  • A module's driving adapters are not runnable. user-side/ carries HTTP resources, message consumers and the in-process service, each as a library an assembly mounts. Deciding what runs together is the assembly's job alone.
  • Modules compose through the in-process service adapter, never through each other's domains. A module that needs a peer declares a driven port in its own vocabulary; its infra/ implements that port by delegating to the peer's user-side/service. This is the seam that makes carve-out cheap — see modulith & microservices.
  • The domain has a contract face and an implementation face — ports in both directions, command DTOs and events on the contract side; entities and services on the implementation side. Everything depends on the contract; almost nothing on the implementation.
  • Business logic enters the hexagon as command/query data through one explicit dispatch seam — driving adapters construct commands and never import concrete handlers. The seam is per module, not per repository. The mechanism behind it is per-language, settled 2026-08-09: Java routes through a registry Mediator (handlers self-declare via supports()); Rust keeps per-use-case driving-port traits and, where a unified seam is justified, makes the compiler the registry (commands as an enum, one exhaustive match); Go and the frontend skip the mediator object — per-use-case driving ports, explicitly wired in main (Go) or delivered by typed context keys (frontend) — with cross-cutting concerns as decorators around the seam. Each reference records its stance and the rejected alternatives.
  • An adapter's API artifact exists only where the contract is real and consumable (REST DTOs/OpenAPI, protos, message schemas) — the contract/adapters pair under user-side/api/ is earned, never imposed for symmetry; a cron adapter is a single module.
  • Infrastructure granularity is decided ad-hoc per context — a module's infra/ is a single module or one per (port × technology); the forcing question is which assemblies need which driven adapters. Ubiquitous ports (clock, id generation) are not a module's business at all: they sit in platform/. An assembly's dependency list reads like its ops runbook.
  • One assembly point per deployment unit wires domains and infrastructure — the application/<typology> module in Java, each binary's main in Go and Rust. Dependency injection is a pattern (explicit construction), never a reflection container.
  • No contract testing between infrastructure and the domain contract: adapters are integration-tested against their real technology on their own terms; the domain owns its in-memory fakes.
  • Duplication between deployment units is accepted over cross-unit coupling — by design there is scarcely any overlap between assemblies.
  • Every directory/module carries a README.md and an AGENTS.md stating its purpose and local conventions.

The favored layout — a modulith of hexagons

The trisection is what a single hexagon looks like. Real systems hold several bounded contexts, and the layout that scales is one that repeats the trisection per context instead of merging every context into one domain module:

acme/
├── application/                  # assemblies: one per deployment typology
│   ├── api/                      #   HTTP app — mounts every module's api adapters
│   ├── consumer/                 #   messaging app
│   └── cron/                     #   scheduler app
├── platform/                     # shared, domain-agnostic modules
│   ├── kernel/                   #   Command/Query, Handler, the dispatch seam
│   └── …                         #   ubiquitous ports (clock, ids), test kit
├── modules/                      # one directory per bounded context
│   ├── ordering/
│   │   ├── user-side/            #   driving adapters — libraries, never runnable
│   │   │   ├── api/
│   │   │   │   ├── contract/     #     published interfaces + DTOs
│   │   │   │   └── adapters/     #     their implementation
│   │   │   ├── consumers/        #     message consumers
│   │   │   └── service/          #     in-process adapter for peer modules
│   │   ├── domain/
│   │   │   ├── contract/         #   driving + driven ports, public DTOs
│   │   │   └── core/             #   the business logic
│   │   └── infra/                #   driven-port adapters
│   └── billing/                  # same shape, independently
└── deployment/                   # IaC — OpenTofu stacks per environment

The dependency rules are the trisection's, restated per module (an entry not listed is forbidden):

Module May depend on
platform/kernel nothing
modules/x/domain/contract platform/kernel
modules/x/domain/core its own domain/contract
modules/x/infra its own domain/contract, peer modules' user-side/service, its technology
modules/x/user-side/api/contract nothing — transport types only
modules/x/user-side/api/adapters its own user-side/api/contract + domain/contract
modules/x/user-side/service its own domain/contract
application/<typology> anything it assembles — the only place allowed to see a domain/core

Two consequences carry the layout's value:

  • No module ever depends on another module's domain. The single legal inter-module edge is infra → peer user-side/service, and it runs through a port the consumer declared in its own language. Contexts stay independently understandable, and a bad dependency is a build failure rather than a review comment.
  • The composition is decided in application/, at assembly time. Which modules run in one process, which get their own, and whether a peer call is a method call or a network hop are all assembly concerns. That is precisely what makes the modulith → microservices move mechanical rather than architectural.

The whole hexagon shape is preserved inside each module, so nothing above replaces the trisection — it repeats it:

flowchart LR
    subgraph asm["application/api (the deployment unit)"]
        ASSEMBLY["assembly + wiring"]
    end
    subgraph ordering["modules/ordering"]
        OUS["user-side/api/adapters"]
        OSVC["user-side/service"]
        ODC["domain/contract (ports)"]
        OCORE["domain/core"]
        OINFRA["infra"]
    end
    subgraph billing["modules/billing"]
        BDC["domain/contract<br/>(declares an OrderLookup driven port)"]
        BINFRA["infra/ordering-gateway"]
    end
    ASSEMBLY -->|"mounts"| OUS
    ASSEMBLY -->|"wires"| OCORE
    OUS --> ODC
    OSVC --> ODC
    OCORE --> ODC
    OINFRA --> ODC
    BINFRA -->|"implements"| BDC
    BINFRA -->|"calls in-process"| OSVC

When to stay flat. A service with one genuine bounded context gains nothing from modules/<the-only-one>/ but a level of nesting. Start flat, and adopt the modulith the moment a second context appears — the flat trisection maps onto modules/<context>/ one-to-one, so the promotion is a directory move plus a build file, not a redesign.

Practice

  • Trip Service Kata (source) — warm-up on seams: get code with hard-wired dependencies under test without touching the untouchable parts.
  • Racing Car katas (source) — dependency-breaking drills; each kata is a small dependency-inversion refactor.
  • Birthday Greetings — hexagonal extraction (exercise) — extract a pure domain from a coupled script; ports and adapters earned by refactoring, then proven by swapping adapters.
  • Build Your Own Redis, hexagonally (source) — greenfield practice: keep the wire protocol and storage behind ports and watch the core stay framework-free.
  • RealWorld full-stack build (source) — the architecture at product scale: a whole API as a domain core with HTTP and Postgres at the ports, judged by an external test suite.

Related

  • Reference implementations — the house principles above realized per language, each standing on its own: Java (a concept group with per-layer guideline notes), Go, Rust, and Frontend — web components.
  • The four hexagonal references compared — the four above read side by side: what holds each wall (compiler, resolver, linter or review), what each realization costs, and which stacks should offer both layouts.
  • Modulith & microservices — the layout above read as a position on the decomposition spectrum, and the mechanics of moving along it.
  • DDD — supplies the domain model the hexagon protects, and the bounded contexts modules/ is carved along.
  • Clean code — dependency inversion is SOLID's "D".
  • TDD — ports make outside-in test-first design practical.
  • Microservice architecture — a common internal structure for each service.
  • Coupling & cohesion — the forces ports & adapters manage: narrow, inward-directed coupling around a cohesive domain.
  • Observability & SRE practice — where telemetry attaches to the hexagon: filters/middleware in the driving adapters, wiring at the assembly point, at most a logging facade in the domain.