Keep a port private
How-to. Hide a module's internals — a pool, a raw client, a parsed config — from everything outside it, with the compiler enforcing the line. For why that is a withheld type rather than a runtime wall, see Modules and privacy.
Goal: a module with internals nothing outside can reach, and a boundary the compiler enforces rather than a naming convention.
Export the surface, withhold the rest
Privacy in di is not a keyword; it is the exports list. Everything a module provides but does not export is internal:
const Persistence = Module("Persistence")({
imports: [Config],
provides: [
Provider(Pool)([AppConfig], {
acquire: openPool,
release: (pool) => pool.close(),
}),
Provider(OrderRepository)([Pool], {
sync: (pool) => makeRepository(pool),
}),
],
exports: [OrderRepository], // Pool and AppConfig: not listed, not visible
});Any module importing Persistence sees exactly one port:
const App = Module("App")({
imports: [Persistence],
provides: [
Provider(GetOrder)([OrderRepository], { class: GetOrderInteractor }),
Provider(Audit)([Pool], { sync: makeAudit }), // Pool is not visible here
],
exports: [GetOrder],
});The second provider does not wire: Pool is not among what App can see — its own provides plus its imports' exports — so the dependency stays unmet, and surfaces as UNSATISFIED DEPENDENCIES at the entry point (or at start, which carries the same gate).
And on a built context:
ctx.get(GetOrder); // compiles
ctx.get(Pool); // does not compileWhat makes this work — and what it is not
The built container is a single flat map at runtime. Pool's service is genuinely in it — there is nowhere else to put it — so this is not runtime sandboxing. What exports withholds is the type: the built Context's channel contains only the exported ports, so ctx.get(Pool) has no overload that accepts it. The port class itself may be a plain TypeScript export (so tests can name it); what matters is the DI module's exports list.
Hexagonal order API pins exactly this with a @ts-expect-error in its index.test-d.ts — the guarantee is compile-time only, so the proof is a type-level test, not a runtime assertion.
Exports are checked, not declarative
The exports list cannot lie:
Module("Persistence")({
provides: [Provider(OrderRepository)([Pool], { sync: makeRepository })],
exports: [OrderRepository, Metrics], // Metrics: neither provided nor imported
});An export must be available — provided by this module, or exported by one of its imports. Exporting something never imported, or re-exporting a neighbour's internal, is a compile error at the declaration, not a silent no-op.
Re-export a whole module
Listing an imported module in exports re-exports its whole public surface — useful for a facade module that groups plugins without re-listing every port:
const AppModule = Module("App")({
imports: [DatabaseModule, CacheModule],
exports: [DatabaseModule, CacheModule],
});What stays private in DatabaseModule stays private here too: a whole-module re-export forwards the module's exports, not its internals. The starters' module sugar (HttpModule, TemporalModule, AmqpModule) keeps the same discipline: each adds its runtime port to whatever exports you wrote, and nothing of yours becomes visible that you did not list.
See also
- Modules —
imports/provides/exports, precisely. - Modules and privacy — the flat map, the withheld type, and why that is enough.
- Swap an adapter for tests — privacy is what makes the adapters interchangeable.