Modules and privacy
A module's exports list promises that its internals — the pool behind the repository, the parsed config behind the client — cannot be reached from outside. This page is about what enforces that promise, because the honest answer is surprising: at runtime, nothing does.
The flat map
Build any module tree and the result is a single map from port id to service. Persistence's private Pool is in it, right next to the exported OrderRepository — there is nowhere else to put it; the repository's own construction had to read it. No nested containers, no per-module resolution scopes, no hierarchy to walk at get time.
Runtime enforcement would mean wrapping that map per module boundary — tracking, for every caller, which module's vantage point it holds. That is a real design (nested injectors exist in other containers), and it buys real costs: resolution walks a chain, module boundaries exist as objects with lifetimes of their own, and the failure mode is a runtime "not visible from here" — precisely the class of surprise this package exists to remove.
Privacy is a missing name
di enforces the boundary one level earlier. The built Context<X> is typed by the module's Exports channel, and ctx.get only accepts ports in X:
ctx.get(OrderRepository); // ✓ exported
ctx.get(Pool); // ✗ does not compile — Pool is not in XThe Pool service is present in the map; the type that would let you ask for it is not in scope. The same withholding governs wiring: a provider in App cannot list Pool in its deps, because what App can see — its own provides, its imports' exports — does not include it, and the dependency would surface as "UNSATISFIED DEPENDENCIES" at the entry point. Privacy and dependency-checking are one mechanism, not two.
This is privacy in exactly the sense TypeScript itself uses everywhere else: #private fields aside, an unexported type, a module-private symbol, an internal API are all names withheld rather than bytes hidden. di extends the convention to wiring.
What it does not defend against
A determined caller can cast — ctx as any, a hand-rolled object with the right portId — and reach anything in the map. The boundary is not a security perimeter, and does not try to be: the threat model is accident, not adversary. What it prevents is the quiet coupling where application code starts importing an adapter's internals because they happened to be reachable, and a year later the adapter cannot change without breaking its callers.
Because the guarantee lives entirely in the types, its regression tests do too: the hexagonal-order-api example pins "ctx.get(Pool) does not compile" with a @ts-expect-error in a .test-d.ts file. A runtime test could only prove the opposite — the flat map genuinely holds the pool — which is true, and not the point.
What the flat map buys in exchange
getis a map lookup. No chain to walk, no vantage-point bookkeeping, no allocation per boundary.- One instance per provider, ever. A diamond — two modules importing the same
Config— cannot yield two configs, because de-duplication happens on provider identity before construction and the result lands in one map. Nested-container designs have to work to get this right; here it falls out. - Whole-module re-export is free.
exports: [DatabaseModule]forwards a type; nothing is copied or proxied at runtime. - The type-level model stays honest.
Available(what a module can see) andExports(what it shows) are set computations over port types — checkable, testable, and identical in shape to what the runtime actually does with ids in a map.
The trade, stated once: di's module boundary is exactly as strong as the type system's reach, in exchange for a runtime with nothing in it to misbehave. Where the types end — casts, duplicate ids, widening — pre-construction defect checks stand behind them; what those catch is wiring bugs, not privacy violations, because past the types a privacy violation is indistinguishable from intent.