Architect
Standards
ReferenceNormative

Hexagonal architecture

Organise by feature, then by direction of dependency — never by technical layer, and never a layered tree under new names.

Verified 2026-07-31 · Language-independent. Enforcement recipes per stack are on the stack pages.

The standing default, in every language and on both sides of the stack. Do not propose a layered controllers/ services/ models/ utils tree, and do not quietly reintroduce one under new names.

The shape

<feature>/
  core/           the decision-making centre — no framework imports, no I/O
    model/        entities and value objects that own their rules
    usecase/      one class per decision; a single entry point
    port/         interfaces the core needs, declared by the core, plus their vocabulary
  inbound/        adapters that drive the core: http, cli, queue consumer, mcp, cron, ui
  outbound/       adapters the core drives: db, http client, cache, broker
  api/            what other features may import — and nothing else

Dependencies point inward, always. core imports nothing from inbound or outbound. Adapters import core. Features import each other only through api/.

Why this and not layers

  • Layers group by mechanism; features group by reason to change. A feature change in a layered tree touches five directories and reviews as five unrelated diffs.
  • The core becomes testable without infrastructure. Rules live where a plain unit test can exercise them; nothing forces a database to assert a state transition. That is a payoff of the structure, not a separate discipline — see testing.
  • Boundaries make extraction cheap. A feature with a real api/ is a service-in-waiting; a layered app is not, at any size.
  • The dependency rule is mechanically checkable — ArchUnit, dependency-cruiser, eslint boundaries, import-linter — which is rule 3 of the standing default, and the reason it survives. Where the language has no such tool, build one: see Go's checked-file approach.

Rules that keep it honest

  • No package named for a mechanismservice, util, helper, manager, dto, mapper, repository (as a package). They attract everything and mean nothing.
  • A port is declared by the core, in the core's vocabulary, and implemented outside. The reverse — the core importing the driver's types — is the most common way this collapses.
  • The type lives with its owner. A use case owns its input and output; a port owns what it accepts and returns.
  • A use case exists when there is a decision. A pure lookup is a port method. Wrapping every read in a class is ceremony, not architecture.
  • State the operation's shape as a type, not a marker. An annotation is ignored by the compiler; an interface is not. Full rules: the operation.
  • The core declares meaning; the platform recognises mechanism. When infrastructure refuses a write, the core says what that constraint means — it never catches the driver's exception or matches its message.
  • Conversion belongs to the outer type, not to a mapper layer.
  • Shared code is a feature too, or it is platform — never a common/ dumping ground with no owner.

The frontend applies the same rule

Feature folders (features/checkout/{ui,model,api,lib}), not components/ hooks/ services/. Route files stay thin adapters over a feature's core. Server-state clients (TanStack Query and friends) belong to the feature's api/, not to a global service singleton.

Full layout: feature structure.

Traps

  • Hexagonal per project instead of per feature → one giant hexagon, and boundaries that do not help. Feature first, hexagon inside it.
  • Ports with one implementation, invented for symmetry → declare a port when the core genuinely needs to state a need, not to satisfy a diagram.
  • Anaemic core — records with getters and all the rules in the use case. If the model cannot refuse an illegal transition, the architecture is decorative.
  • An api/ that re-exports everything → the boundary is gone. Export the contract only.
  • An architecture rule that selects nothing. Rules written against a method shape stop matching the moment you change that shape, and a rule matching nothing reports green forever. Prefer a checker that fails on an empty selection — otherwise moving a parameter can silently retire a security rule and nothing goes red.

On this page