Modules
A module groups providers and draws a visibility boundary. Like a provider, it is a declaration — building happens only at an entry point.
Module(name)(options)
const Persistence = Module("Persistence")({
imports: [Config],
provides: [
Provider(Pool)([AppConfig], {
acquire: openPool,
release: (p) => p.close(),
}),
Provider(OrderRepository)([Pool], { sync: makeRepository }),
],
exports: [OrderRepository],
});All three lists are optional and default to empty.
imports
Modules whose exports become visible inside this one. A diamond — two imports that both import a third — is fine: providers are de-duplicated by reference at build time, so the shared module's services construct once.
provides
This module's own providers. What a provider here may depend on is anything available in this module: ports provided here, plus ports exported by the imports. Order within the list does not matter for correctness — dependency order is computed at build time — but it is what makes error selection deterministic when several providers fail at once.
exports
The ports outside code may see. Each entry must be either:
- an available port — provided here, or exported by an import. Exporting anything else (a port from nowhere, an import's internal) is a compile error at the declaration; or
- an imported module — a whole-module re-export, forwarding that module's own
exports(never its internals).
Everything provided but not exported is private to the module: present in the built flat map at runtime, unnameable through the built Context's type.
The channels
Module<Exports, E, Needs>:
Exports— the union of exported ports' instance types (whole-module re-exports contributing their ownExports). This becomes theContext<X>channel an entry point hands back.E— every way construction can fail: the union of all providers' error channels, here and in every import, transitively.Needs— everything still unmet: the union of all providers' needs and all imports' needs, minus what is available here. A dependency satisfied by a sibling provider or an import's export disappears fromNeeds; one nothing supplies propagates upward until some module satisfies it — or surfaces as the "UNSATISFIED DEPENDENCIES" compile error at the entry point.Scope, once introduced by a resourceful provider, propagates the same way and is discharged only byModule.scoped/Module.forkScope.
The variance rule, shared with Provider:
Capability channels (
Exports) are contravariant, so you may forget what you have. Obligation channels (E,Needs) are covariant, so you may not forget what you owe.
Concretely: annotating a module as exporting less than it does is fine (forgetting a capability); annotating away an error case or an unmet need does not compile (laundering an obligation). This is what makes the adapter-seam pattern safe:
const makeAppModule = <E, N>(persistence: Module<OrderRepository, E, N>) => /* ... */;Any module exporting OrderRepository fits, and whatever it may fail with or still need flows through E/N into the result — invisibly to the seam, inescapably at the entry point.
What a module is at runtime
A plain object: { name, imports, provides, exports }. Declaring one runs no factories, opens nothing, allocates nothing but the object itself — building a module fresh per call (makeAppModule(...), a per-request module for forkScope) is cheap and idiomatic. The channels are phantom: they exist only in the type, which is why every wiring property they express is settled before runtime.