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-10-03 · Language-independent; the Go expression re-checked against a production Go monorepo. 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

Dependencies point inward, always. core imports nothing from inbound or outbound. Adapters import core. A feature reaches another feature through a port it declares in its own core, in its own words; an outbound adapter implements that port by calling the other feature's public surface — in a Go service, the other feature's core; on the web, the other feature's door. Nothing else crosses.

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 whose neighbours reach it only through ports 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 checking-script approach.

Rules that keep it honest

  • No package named for a mechanism — service, 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. Where the language does not organise behaviour as one class per operation — Go — a feature's core is one flat package holding one service whose methods are its decisions, one method per decision; the direction of dependency and the ports are unchanged. See Go services.
  • 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/{core,server,components,hooks,form}), not a global components/ hooks/ services/ tree. Route files stay thin adapters and, like other features, use only the feature's door. Server-state clients (TanStack Query and friends) belong to the feature's core/, 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.
  • A door or a port that exposes everything → the boundary is gone. Expose 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