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

The four hexagonal references compared

# What each language buys and pays for the same architectural property — that a bounded context can be carved out as a wiring change — graded by what genuinely enforces each wall, with the findings that only a compiler revealed.

Conceptsaved 2026-08-14 #hexagonal-architecture#architecture#modulith#reference-implementation#go#rust#typescript#java#comparison

Overview

The four reference implementations (Java, Go, Rust, Frontend) all claim the same prize: a bounded context can be carved out into its own service as a wiring change, because modules meet only at a narrow peer seam. This note compares what each language actually pays for that prize and, more importantly, what genuinely holds each wall — compiler, resolver, linter, or review.

The gradings below were established by hand-building two-context skeletons in each language and compiling them (Go 1.24.7, rustc 1.94.1, Node 22.22 + TypeScript 5.9, Chromium via Playwright), including deliberate violation probes. Several widely-repeated claims did not survive: they are called out in §Findings that only compiling revealed. The skeletons were throwaway; the findings are the deliverable.

Key points

  • The prize decomposes into three walls, and no language holds all three the same way: (1) a peer cannot reach a provider's domain, (2) the consumer owns the port it calls the peer through, (3) the assembly is the only thing that knows the full module list.
  • Rust holds the most, Go holds the most per unit of ceremony, TypeScript holds the least by an order of magnitude. Java sits between Rust and Go: the build graph is real but lives in a build tool rather than a compiler.
  • Go's internal/ is the best enforcement-per-character in the four. A context is a directory with a facade file and an internal/ beneath it — five files for a whole hexagon — and the two walls that matter are compiler-held.
  • Go's facade must re-export nothing. If the facade aliases the types appearing in its ports' signatures, any package in the repository can implement those ports. Export only New, Deps and Module, and "only the consumer's own directory may implement the consumer's ports" becomes a compile error. Type inference lets the assembly hold the resulting unnameable values, so the tighter aperture costs nothing.
  • Rust's crate graph stops you naming a crate — not domain types flowing. A peer service crate that mentions a domain type in its public signature hands that type to every consumer, with no error and no warning. The fix (public = false + exported_private_dependencies) is nightly-only, so on stable this is discipline — decided 2026-08-14, and the one place a reference is weaker than its neighbours: the JVM holds this wall with build scope and Go with unnameability, Rust with a rule.
  • TypeScript's package graph enforces nothing. Undeclared workspace dependencies resolve fine (hoisting), relative paths bypass exports maps entirely, and project references do not restrict which projects a project may import. The exports map is the one real wall, and only against package-specifier imports.
  • Every stack offers both layouts, basic default — and the cost figures say when to turn the dial, not whether to have one. Manifest count measures the cost of building a layout, not of reading it: the modulith's facade, modules/<ctx>/ level and peer-seam-without-a-peer are indirection a single-context project can decline no matter how cheap its build files are. Go can adopt it for zero manifests and defer it for free; Rust jumps from one crate to eight and should stay flat longest.
  • Every language has a "hand-computed path" trap — the JVM's mavenArtifact / upToRoot bug class — and each has a different one. Naming them before writing templates is the cheapest lesson the JVM implementation taught.

Details

The three walls, and what holds each

An arrow of enforcement strength: compiler > resolver/build tool > linter > review. Only the first is unbypassable from inside the repository.

Wall Java Go Rust TypeScript (node & browser)
A peer cannot reach a provider's domain build tool — Gradle implementation scope keeps it off the compile classpath compiler — nested internal/ compiler — a missing Cargo.toml edge linter — dependency-cruiser only
A context's core is hidden from its own adapters build tool — separate domain-core module compiler — a second nested internal/ compiler — private module, or a separate crate resolver — the exports map, for package specifiers only
Only the gateway may implement a consumer's ports linter (ArchUnit) compiler, if the facade re-exports nothing; otherwise linter compiler — the orphan rule leaves the impl nowhere else to live linter
Only the gateway may import a peer linter (ArchUnit) linter (depguard; facades are module-wide visible) linter (a Cargo.toml edge is legal anywhere) linter
The assembly is the only full module list build tool compiler (cmd/ cannot see any context's internals) build tool linter
The domain never touches the platform (DOM, executor) convention convention convention (domain has no tokio edge) compiler"lib": ["ES2022"] with no "DOM"

Two asymmetries are worth naming. Go and Rust are the only references where the primary wall is compiler-held, which is why their extraction stories are believable without CI. And TypeScript is the only reference that gets a wall the others don't have at all: tsc can make "the domain never touches the browser" a type error, which no JVM or Go project can do.

What each pays

Counted from the compiled skeletons: one context, contract + core + peer service + one driven adapter, no HTTP face.

Java Go Rust ts-http (packages) ts-http (directories)
Build/manifest files per context 5–6 (build.gradle.kts each) 0 4–5 (Cargo.toml each) 4–5 (package.json each) 0
Source files for a whole hexagon ~8 5 ~6 ~5 ~5
Directories per context ~8 9 ~6 ~6 ~6
Cross-context imports rewritten on extraction 0 0 0 0 3 (relative → package)
Enforcement lost if CI is skipped the ArchUnit rules almost none almost none everything everything

Go's zero-manifest column is the headline. A Go context costs nothing structurally — no build file, no member list, no version — because the compiler reads directory names. That is why the modulith is nearly free there and why the dial ruling below falls the way it does.

Findings that only compiling revealed

Each of these contradicted a plausible reading of the reference note it belongs to.

Go — the assembly cannot construct adapters buried under a context's internal/. The obvious tree (modules/<ctx>/internal/infra/postgres/) makes cmd/api/main.go uncompilable: use of internal package … not allowed. The fix is to put driven adapters at modules/<ctx>/infra/<tech>/outside the context's internal/, still inside its directory, where they can still import <ctx>/internal/domain (the internal/ rule admits anything rooted at the parent of internal/). The price is that peers can now import a context's adapters; that residue is a linter rule, and it is the only one the Go layout genuinely needs.

Go — the facade's alias set is the aperture, and it should be empty. With type CustomerRef = domain.CustomerRef re-exported, an arbitrary package compiles an implementation of billing's port. Drop the aliases and the same probe fails with want ActivityFor(context.Context, domain.CustomerRef) (domain.Activity, error) — unnameable outside billing's directory. The assembly is unaffected: := infers the unnameable type happily. This turns the gateway convention into a compiler rule and is the single highest-value correction across all four references.

Rust — the orphan rule is doing more work than the crate graph. The assembly cannot skip the gateway crate and write impl OrderLookup for Arc<dyn OrderingService>: E0117, only traits defined in the current crate can be implemented for types defined outside of the crate. The gateway must exist as a real artifact owning a newtype. The reference claims this as convention; it is a compile error.

Rust — the crate graph does not stop domain types crossing the seam. A gateway crate that depends only on ordering-user-side-service compiled, held a value of ordering_domain_contract::Summary returned through the service's public API, and read its fields — with no dependency edge, no error, no warning. The crate graph stops you naming a crate, not values flowing. cargo -Z public-dependency with public = false and #![deny(exported_private_dependencies)] catches it exactly (it also flags constructor signatures taking the domain's own ports), but it is nightly-only as of 1.94 — so on stable the "the service crate's DTOs are its own" rule is discipline.

Settled 2026-08-14: keep the discipline, state it in the seam crate's own module doc at the point where it would be violated, and switch to public-dependency the day it stabilizes (two lines: public = false on the contract dependency, plus the deny attribute on the crate root). A custom lint is not a third option — checking a crate's public API surface needs rustdoc JSON, which is itself nightly, while cargo-deny bans dependency edges and cannot see type flow. Pinning a scaffolded project to nightly to enforce one rule on one crate, for every project including the single-context majority that has no seam, is the disproportionate trade. What stays open is the enforcement, not the stance.

Rust — the contract/core split does not need two crates. A private mod core_impl inside one ordering-domain crate hides entities from dependents (E0603: module core_impl is private) exactly as a separate crate would. The two-crate split survives on a different argument than the one usually given: measured rebuild blast radius. Touching the core rebuilt 2 crates when split and 3 when merged, because in the merged crate every dependent of the contract face also rebuilds. Splitting buys cheaper incremental builds on the most frequent edit, not a wall you couldn't otherwise have.

Rust — <ctx>-user-side-service must be its own crate. Whatever crate owns the peer API also hands consumers everything else it exports. Merging the service into the domain crate gives every gateway a legal edge to the peer's domain. This one is load-bearing, and it is the reason the cluster cannot collapse below four crates.

Rust — native async fn in traits is still not dyn-compatible on 1.94 (E0038 … because method call is async). So platform-kernel's BoxFuture alias is load-bearing infrastructure, not a stylistic choice: every port method returns one by hand (or via a macro) or the Arc<dyn Port> wiring rule collapses.

TypeScript — the package graph enforces nothing. @acme/billing-domain-core importing @acme/ordering-domain-contract without declaring the dependency typechecks and runs, because npm workspaces symlink every member into the root node_modules. Project references do not help: with composite, the same undeclared import builds clean under tsc -b --force.

TypeScript — relative paths walk straight through exports maps. The map is a real wall for @acme/pkg/src/internal/x.ts (TS2307 at build, ERR_PACKAGE_PATH_NOT_EXPORTED at runtime) and no wall at all for ../../modules/ordering/domain/core/src/internal/order.ts. Under project references the relative form is worse: tsc silently redirects it to the peer's emitted .d.ts and typechecks it clean, while the emitted JS keeps a specifier that does not resolve — the violation surfaces only when Node runs.

TypeScript — dependency-cruiser silently sees nothing across workspace packages unless configured. Pointed at source directories with default options, every cross-package import resolves to the bare specifier with dependencyTypes: ['unknown'], so every architecture rule passes green while the tree is in violation. It needs enhancedResolveOptions: { extensions: ['.ts', …], exportsFields: ['exports'], conditionNames: ['import', 'default', 'types'] } before it resolves @acme/* back to real source paths and the rules begin to fire. A lint that fails open is worse than no lint, and this is the single most important thing to get right in any TypeScript realization of the layout.

Browser — cross-bundle duplication of an element-defining package is a crash, not waste. Two independently built bundles each inlining a shared design system: the second customElements.define('acme-ds-button', …) throws NotSupportedError, and because the throw aborts that module's top-level execution, the rest of that micro-frontend never registers. Half the page silently disappears. An import map leaving the design system external fixes it completely — one instance, shared module state, both panels rendered, no errors. The tempting cheap fix — if (!customElements.get(tag)) define(...) — removes the crash and installs silent divergence in its place: whichever bundle loads first owns the class, the other keeps a dead second copy of the module state, and instanceof across the boundary is false.

Browser — Context protocol subtree scoping works exactly as claimed. Two bundles, each registering a context-request listener on its own root, each resolved its own widget's port with no coordination. A widget cross-mounted into the other bundle's subtree got no provider — confirming both the scoping prize and its corollary: shells compose their own modules' elements, and cross-bundle element composition is not a thing the protocol offers.

The dial: should each stack offer basic and modulith?

Every stack offers both, basic default (settled 2026-08-14). The measurements below vary enormously — a context costs zero manifest files in Go and four or five crates in Rust — and an earlier draft of this note used them to argue that the cheap stacks should ship the modulith as their only layout.

That was the wrong inference, and the correction is the most transferable thing in this section: manifest count measures the cost of building the layout, not the cost of reading it. The modulith also imposes indirection — a facade between the assembly and the domain, a modules/<ctx>/ level above everything, a peer seam with no peer yet — and a genuinely single-context project can reasonably decline all of it however cheap the build files are. So the per-language figures below are guidance on when to turn the dial, not a reason to remove the choice:

Stack Cost of a context When to turn the dial
JVM (12 stacks) 5–6 build.gradle.kts The most expensive to adopt and the most expensive to defer, since the modules must be created either way. Turn it when the second context is on the roadmap, not when it arrives.
Go zero manifest files Cheapest to adopt and cheapest to defer — moving later is one facade file and a directory move. No penalty for starting flat; turn it the moment a second context is real.
ts-http 1 package Cheap both ways. The modules/<ctx>/ level is the whole difference, so defer it until there is something to put beside <ctx>.
web-components 1 package The strongest architectural pull of the four — most browser apps are multi-context before their first release — so the default is right but the help text should lean toward modulith.
Rust 4–5 crates Heaviest by far, and today's flat skeleton is a single crate rather than a workspace, so the jump is 1 → 8. Stays on basic longest; turn it only with a concrete second context.

ts-http specifically: packages or directories?

The sharpest question, because the enforcement answer and the extraction answer point in opposite directions.

  • Enforcement is a wash. Packages add exactly one wall the directory layout lacks — the exports map against package-specifier deep imports — and that wall is bypassed by a relative path. Cross-context imports need dependency-cruiser under both designs.
  • The directory layout's lint is more trustworthy. Everything is a relative path, so dependency-cruiser resolves it natively with default options. The package layout needs the three-key resolver configuration above or it fails open. Fewer ways to be wrong.
  • Extraction favors packages, measurably. Moving ordering out cost 0 import rewrites under packages (every cross-context import is already a package specifier, and no relative import escapes a package) versus 3 under directories, plus authoring the extracted unit's package.json and tsconfig.json for the first time.

The answer is a third shape neither option offered: one package per context, not per (context × layer). @acme/<ctx> holds src/domain/{contract,core}, src/user-side/… and src/infra/… as ordinary directories, and its exports map publishes exactly two entry points — "." (the facade) and "./service" (the peer seam). Verified: both resolve, while @acme/<ctx>/src/domain/core/internal/… is rejected by tsc (TS2307) and by Node (ERR_PACKAGE_PATH_NOT_EXPORTED) alike. It is Go's facade rule expressed in TypeScript, and it dominates both original options:

package per (ctx × layer) package per ctx directories only
manifests per context 3.5 1 0
core hidden from outside exports map exports map lint only
intra-context layering lint (the package graph doesn't check it) lint lint
cross-context imports lint lint lint
imports rewritten on extraction 0 0 3

Splitting a context into four packages buys four manifests and zero enforcement, because the package graph checks nothing in the first place. Collapsing to one package per context keeps the only wall that was ever real. This is the one place the JVM's shape genuinely does not transfer: on the JVM the build module is the enforcement, so per-layer modules are worth their cost; in TypeScript they are cost without enforcement.

The same shape serves web-components, with one extra entry point ("./elements") for the module's element registrations — the context is also the right code-split unit, since route-level splitting is done by dynamic import rather than by package granularity.

The hand-computed path trap, per language

The JVM's recurring bug class was hand-computed depths and prefixes, which is why jvmLayout centralises mavenArtifact, gradleProject and upToRoot. Each language has its own version, and each is different in kind:

Language The trap Why it bites
Go the import-path prefix <modulePath>/internal/modules/<ctx>/internal/domain there is no relative import in Go; every file names the full path, so the module path, the layout depth and the context name are concatenated in every import line of every template
Go package-name collisions modules/ordering and modules/billing/gateway/ordering are both package ordering; every importer of both must alias one. A template that emits an unaliased import compiles only until a second context appears
Rust crate name vs directory path, which do not match modules/ordering/user-side/service is crate ordering-user-side-service and Rust identifier ordering_user_side_service. Three spellings of one thing, in Cargo.toml members, in [dependencies] keys, and in use statements
Rust relative path = depth in every [dependencies] entry ../../../platform/kernel from modules/<ctx>/user-side/service — the exact upToRoot bug, transplanted
TypeScript package name vs directory path @<scope>/<ctx>-domain-contract at modules/<ctx>/domain/contract; the scope, the context and the path depth vary independently and none is derivable from another. (Workspace member globs are not a trap — nested patterns like modules/*/domain/* work under npm 10 and pnpm 10 alike)
TypeScript the exports map is coupled to the build mode no build step means "." → "./src/index.ts" and .ts import specifiers; an emitting build means ./dist/index.js plus a types condition and .js specifiers. Mix them and it typechecks, then fails at runtime — adding project references to a working no-build workspace required rewriting every map and every specifier
TypeScript erasableSyntaxOnly Node's type-stripping bans parameter properties and enums; a domain-entity template using constructor(readonly id: string) fails with TS1294 in exactly the stack that runs sources directly
Browser the tag prefix <scope>-<context>-<element> it is a runtime string, not a path, so nothing checks it; and the shared design system's tags are not context-prefixed, which is precisely why they collide across bundles

The lesson transfers: whatever the per-language jvmLayout equivalent is, it must own name derivation (crate name, package name, import prefix, tag prefix) as well as path derivation. On the JVM only paths were dangerous because the package prefix was already centralised; in Rust and TypeScript the name is the more dangerous half.

Related

  • Hexagonal architecture — the house principles and the modulith layout all four references realize; the hub.
  • Modulith & microservices — the extraction property this note grades each language against.
  • Java reference — the one that has been through a compiler in anger, and the shape the others were measured against.
  • Go reference — cheapest realization; the empty-facade rule is recorded there.
  • Rust reference — strongest walls; the seam-leak caveat is recorded there.
  • Frontend reference — weakest walls, strongest domain-purity check; the cross-bundle ruling is recorded there.
  • Coupling & cohesion — the forces the walls exist to manage.