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 aninternal/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,DepsandModule, 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
exportsmaps entirely, and project references do not restrict which projects a project may import. Theexportsmap is the one real wall, and only against package-specifier imports. - Every stack offers both layouts,
basicdefault — 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/upToRootbug 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
exportsmap 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
orderingout 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'spackage.jsonandtsconfig.jsonfor 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.