rgoussu@goussu: ~/library/java/hexagonal-reference-implementation
~/library/java/hexagonal-reference-implementation cat infrastructure-layer.md

Infrastructure layer — secondary adapters & the granularity debate

# A module's infra/ implements its driven ports — persistence, messaging, clients and peer gateways — with granularity decided ad-hoc per context; ubiquitous ports live in platform/ rather than in any module.

Conceptsaved 2026-08-09updated 2026-08-14 #hexagonal-architecture#architecture#java#secondary-adapters#infrastructure#modulith

Overview

modules/<context>/infra/ holds that module's secondary (driven) adapters: implementations of the driven ports declared in its domain/contract — persistence, messaging producers, external API clients, and the gateways that reach peer modules. Its shape is deliberately not fixed by the reference: granularity is decided ad-hoc, per context — a single module or several more granular ones — because assemblies need different subsets. This note records the forces on that decision and the shapes to choose between (settled 2026-08-09; re-homed onto the modulith 2026-08-14).

Key points

  • Infrastructure belongs to a module, not to the repository. There is no root infrastructure/ folder in the modulith: a Postgres adapter implements some context's repository port, and it lives with that context. Two contexts both using Postgres get two adapters, and that duplication is correct — they map different tables to different aggregates.
  • The forcing fact: assemblies need different subsets of the driven adapters. A consumer assembly that only writes to Postgres shouldn't ship the billing HTTP client; a read-only API assembly shouldn't ship the Kafka producer. Whatever shape is chosen must let each assembly's dependency list say exactly what it touches.
  • Granularity is the trade-off itself — decide it ad-hoc. Both shapes are legitimate: a single infra module where the context is small or its assemblies homogeneous; several granular modules — one per (port × technology), e.g. infra/persistence-postgres, infra/messaging-kafka, infra/client-billing — the day assemblies need different subsets. The reference refuses a fixed rule; the forcing question is always which assemblies need which adapters.
  • Ubiquitous ports live in platform/, not in a module. Clock, id generation and the like have no business owner and no operational footprint; platform/commons declares and implements them once for the whole repository. A module's domain/contract may still re-declare a narrow port over them when it wants its own vocabulary, but the implementation is shared.
  • Peer gateways are ordinary driven adapters. infra/ordering-gateway implements a port its own domain declared, exactly like a Postgres adapter does; it just talks to ordering/user-side/service instead of a database. It obeys every rule in this note, and it is the module's only legal reference to another module — see module composition.
  • Infra modules depend on their own domain/contract, their technology, and nothing else (peer service modules excepted). Never on their own domain/core, never on another module's domain, never on each other, never on an assembly. Shared plumbing between adapters (a common error mapper, shared Testcontainers setup) is a warning sign, not a convenience.
  • Each module ships its own configuration (Spring auto-configuration, CDI producers) so an assembly merely selects modules rather than knowing their internals.
  • No contract testing between infra and the domain contract. Each module is integration-tested against its real technology (Testcontainers) on its own terms; the domain is tested against its in-memory fakes. The known risk — a fake drifting from real adapter behavior — is accepted and handled when observed, not with a standing contract-test kit.

Details

The three candidate shapes

Shape Pros Cons Verdict
Single infra module per context trivial build, no ceremony every assembly hosting the context drags every driver: fat images, wide attack surface, muddy test scope legitimate while the context is small or its assemblies homogeneous
One module per adapter under infra/ assemblies cherry-pick; per-adapter tests & deps; tech swap is localized more POMs, more ceremony legitimate the day assemblies need different subsets
One infra module per assembly each assembly fully self-describing duplicates adapters shared by several assemblies — same Postgres mapping code twice rejected; the pick-list belongs in the assembly's POM, not in copied code

The first two are a context call, not a progression with a "right" end state — that ad-hoc judgement is the design decision this layer asks of you. The third is out regardless.

Granularity heuristics (when going granular)

  • Split per (driven port × technology) pair, not per port alone: OrderRepository on Postgres is infra/persistence-postgres; an alternative DynamoDB implementation is infra/persistence-dynamodb, not a second package in the same module — the point is that an assembly's POM names its technologies.
  • One module may implement several small ports of the same technology — the Postgres module can implement OrderRepository and OrderHistoryQuery; splitting those would be ceremony without a consumer that wants one and not the other. Split when a real assembly wants the subset.
  • The module name states the technology, the package inside states the port. Reading an assembly's dependency list should read like its ops runbook: postgres, kafka, billing API. A nested infra/<port>/<technology> directory pair says the same thing when the technology alone would be ambiguous — the scaffold emits infra/greeting-log/jdbc beside infra/unit-of-work/jta, and the Maven artifactId flattens either shape identically (greeting-infra-greeting-log-jdbc).
  • Never split along context lines you have not drawn. If two modules' adapters keep wanting to merge, the boundary between the contexts is the thing to re-examine — not the infrastructure.

Where the shared things live

Concern Home Why not a module's infra/
Clock, id generation, random platform/commons no business owner; every context needs them identically
The dispatch vocabulary platform/kernel it is the contract of the seam, not an adapter
Test fixtures for a technology the adapter module's test sources shared test plumbing across contexts couples their release cadence
A peer module's data never — use the service seam reading another context's tables is the failure this layout exists to prevent

The adapter is not its connection

An infra module owns the mapping; the assembly owns the connection. Splitting them that way is what keeps the adapter a library:

Thing Home Why
OrderRepository implemented over SQL modules/x/infra/persistence-postgres it is the context's mapping of its own aggregate
The DataSource / pool, its credentials, its profile config the assembly pool sizing and secrets are deployment facts; two assemblies hosting the same context may size differently
The transactional boundary's implementation modules/x/infra/unit-of-work-<tech> the port is the domain's (see domain layer); the framework's transaction machinery is an adapter
The producer/@Configuration binding pool → adapter the assembly the assembly is the only module allowed to see both a domain/core and an infra module
Schema migrations their own deployment unit (migrations/) run against the database before the service deploys, never from inside it, so a bad migration blocks the rollout instead of the fleet — and schema-altering credentials never ship in a service image

The adapter takes a DataSource in its constructor and knows nothing else. That is what lets the same Postgres adapter serve an API assembly and a batch assembly whose pools are configured differently — and it is why "each module ships its own configuration" means its own beans, not its own infrastructure credentials.

The last row is the one worth enforcing loudly: no shared schema between modules. Each context owns its tables, and a context that needs a peer's data asks for it through a port. That rule costs almost nothing while everything is one database and is the single thing that makes extraction a migration rather than a rewrite.

Settled

  • Ambient ports (Clock, id generation): a shared platform module rather than per-port micro-modules or assembly-supplied defaults (2026-08-09; moved from infrastructure/commons to platform/commons with the modulith, 2026-08-14).
  • Contract testing between infra and the domain contract: ruled out (2026-08-09). Infra modules are integration-tested against their real technology on their own terms; the domain owns its fakes.

Related