The core story: an application layer that names its own ports and never mentions an adapter, a persistence layer with a private connection pool and a public repository, and a composition root generic enough to build the same application against a production adapter or an in-memory one.
pnpm --filter @btravstack/example-hexagonal-order-api test
pnpm --filter @btravstack/example-hexagonal-order-api typechecksrc/index.ts, top to bottom:
- Ports named by the domain, never by an adapter.
OrderRepositoryandGetOrderare declared once, by what the application needs.GetOrderInteractordepends only onServiceOf<OrderRepository>and never imports an adapter module — production or in-memory. - A private internal beside a public surface.
Persistence'sPool— a real connection, acquired withacquire/release— is never listed in that module'sexports;OrderRepositoryis the only port it makes visible. The built context is a single flat runtime map (there is nowhere else to put a service), soPoolreally is present in it —exportswithholds the type that would let a caller name it, not the entry itself.src/index.test-d.tspins exactly that with a@ts-expect-error. - One composition seam, two adapters.
makeAppModuleis generic in the persistence module's ownE/Needs, so one application module wires up unchanged againstmakePersistenceModule()(resourceful, needsScope) orInMemoryPersistenceModule(nothing to release,Needscollapses tonever).
Pool's acquire/release puts Scope in Persistence's Needs, which
propagates through makeAppModule to anything built from it. Building that
graph with Module.build is a compile error, not a runtime leak — the
call's arity gate (the "UNSATISFIED DEPENDENCIES" rest parameter every unmet
requirement produces) rejects it before anything runs. What it prints is the
arity line alone, Expected 3 arguments, but got 1; the label and Scope are
in the rest parameter's type, not in the message. Hand-spelling the phantom
arguments is what prints them — a value the tuple cannot accept names the label
first, then Argument of type 'number' is not assignable to parameter of type 'Scope'. src/index.test-d.ts pins that with a @ts-expect-error of its own,
right next to the privacy one. Module.scoped is the one entry point that
opens a scope and discharges Scope — used in src/index.spec.ts against
the production adapter, closing the pool on every path out.
InMemoryPersistenceModule has nothing resourceful, so makeAppModule
applied to it has Needs = never — Module.build accepts it directly, no
scope required.
src/index.spec.ts builds both graphs and calls GetOrder.execute through
each: the production graph resolves against the pool and releases it
cleanly (no teardown errors reported), a missing id comes back as a modeled
OrderNotFound — never an exception — and the in-memory graph resolves
without ever touching Module.scoped. src/index.test-d.ts is the
compile-time half; see its own header for why those two assertions live in
their own file rather than a fourth test() here — asserting Pool's
runtime absence would assert something false, since the flat context
genuinely holds it.