From 76f3be40c60b891db0abaec273cc6388e3223194 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 18:47:30 +0200 Subject: [PATCH 1/2] test(di): only an import's own exports discharge a need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The property #50 turns on, and nothing pinned it. "A need bubbles up" reads as "a provider sees whatever the tree happens to hold", and Needs subtracts Available — own provides plus imports' EXPORTS — so a module that imports the exporter and re-exports nothing leaves a sibling's need standing. Both arms: the opaque holder, and the one difference that discharges it, exports: [ConfigModule]. The flat runtime graph would have resolved it either way; the type channel is what refuses. --- packages/di/src/module.test-d.ts | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/di/src/module.test-d.ts b/packages/di/src/module.test-d.ts index a68f64f..87e4e83 100644 --- a/packages/di/src/module.test-d.ts +++ b/packages/di/src/module.test-d.ts @@ -118,6 +118,43 @@ describe("Module algebra", () => { void needsIsNotNever; }); + test("only an import's own exports discharge a need", () => { + // The rule the module algebra states about visibility, and the one the + // root `CLAUDE.md`'s slices rest on: `Needs` subtracts `Available` — + // what this module provides, plus what its imports EXPORT — and nothing + // else. `Holder` imports the module that exports `AppConfig` and + // re-exports nothing, so `AppConfig` is available inside `Holder` and + // nowhere else; a sibling in the same tree still owes it. Pinned because + // "a need bubbles up" reads as "a provider sees whatever the tree + // happens to hold", and that is not what this is: the flat runtime graph + // would resolve `AppConfig` here, and the type channel is what refuses + // to. + const Holder = Module("Holder")({ imports: [ConfigModule] }); + const opaque = Module("Opaque")({ + imports: [Holder], + provides: [DatabaseProvider], + exports: [Database], + }); + type OpaqueChannels = ChannelsOf; + const stillNeedsAppConfig: Equal = true; + void stillNeedsAppConfig; + + // The same tree with the one difference that matters: `Holder` passes + // the export on, and the need is discharged. + const Passthrough = Module("Passthrough")({ + imports: [ConfigModule], + exports: [ConfigModule], + }); + const wired = Module("Wired")({ + imports: [Passthrough], + provides: [DatabaseProvider], + exports: [Database], + }); + type WiredChannels = ChannelsOf; + const needsNothing: Equal = true; + void needsNothing; + }); + test("an unmet requirement cannot be laundered to no requirement", () => { const orphan = Module("Orphan")({ provides: [OrderRepositoryProvider], From 0d413184434fc2f4fe891a011f150623aac3641c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 18:47:31 +0200 Subject: [PATCH 2/2] docs(di): a need is an obligation, not a lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decides #50: keep the model. The issue's own framing is what made it look like a hole — di already has NestJS's visibility rule for SATISFACTION (measured: a sibling's private observability() does not discharge a slice's Logger), and the only divergence is what happens to a need nothing local satisfies. NestJS errors where it is written; di carries a typed obligation to whoever composes the module, gated at Module.build / Module.scoped / start. So there is no @Global to add: NestJS needs one because the error is local, and here the obligation already arrives at the composition root, which is the one place a cross-cutting module is imported. And substitution stays recomposition, which keeps tappedAmqp's seam open — import-visibility would have needed overrideProvider (#63) to stay testable, and #63 is now unblocked rather than forced. packages/di/CLAUDE.md carries the decision for a contributor; modules-and-privacy.md answers the reader's version of it, which is "where is @Global". --- docs/explanation/modules-and-privacy.md | 27 ++++++++++++ packages/di/CLAUDE.md | 57 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/docs/explanation/modules-and-privacy.md b/docs/explanation/modules-and-privacy.md index 5632ece..966089c 100644 --- a/docs/explanation/modules-and-privacy.md +++ b/docs/explanation/modules-and-privacy.md @@ -53,6 +53,33 @@ This is privacy in exactly the sense TypeScript itself uses everywhere else: `internal` API are all names withheld rather than bytes hidden. `di` extends the convention to wiring. +## An unmet need is an obligation, not an error + +The same computation answers a question NestJS answers differently. There, a +provider sees only what its own module declares or imports, and a need nothing +local satisfies is an error where it is written — which is why NestJS also needs +`@Global`, a way for a cross-cutting module to be visible without being +imported. + +`di` has the first half and not the second. What a module can see is still its +own provides plus its imports' exports — a sibling that imports +`observability()` and re-exports nothing does **not** satisfy your `Logger` — but +a need nothing local satisfies is not an error. It stays in the module's `Needs` +channel and travels to whoever composes the module, and the +[entry point](/reference/di/entry-points) is where it has to be gone. + +That is what lets a slice declare `Logger` in a provider's `deps` and import +nothing at all. The slice is not seeing the tree; it is stating a requirement +its composition root has to discharge, and until one does, nothing can run it. +It is also why there is no `@Global` to look for: the obligation already arrives +at the root, which is the one place a cross-cutting module is imported anyway. + +The cost is that a slice directory does not say where its `Logger` comes from — +the composition root does. What it buys is that the same slice composes into a +different root, or [lifts into a process of its +own](/how-to/split-a-router-into-controllers), without carrying along a supplier +it never needed to name. + ## What it does not defend against A determined caller can cast — `ctx as any`, a hand-rolled object with the diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index cae7935..ab8d02e 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -153,6 +153,63 @@ because `fork.spec.ts` already pins what the second asserted (and `order-api` forks a real per-request scope besides); the first went with `Port.many` itself. +## Module visibility: a need is an obligation, not a lookup + +**Decided in #50: keep the model, and stop calling it "needs bubble up."** That +phrase reads as _a provider sees whatever the tree happens to hold_, and the +measured behaviour is the opposite. + +A module's `Needs` is +`Exclude`, and +`Available` is **what the module provides plus what its imports EXPORT** — +nothing else (`module.ts`'s `ModuleDeclaration`). So the satisfaction rule is +already NestJS's: a sibling that imports `observability()` and re-exports +nothing does **not** discharge another module's `Logger`. Measured, against +`examples/order-amqp-worker`: + +``` +error TS2345: Argument of type 'Module' + is not assignable to parameter of type 'Module'. +``` + +`module.test-d.ts`'s _"only an import's own exports discharge a need"_ pins both +arms — the opaque holder that leaves the need standing, and the one difference +that discharges it, `exports: [ConfigModule]`. + +The one real divergence from NestJS is what happens to a need nothing local +satisfies. NestJS makes it an error where it is written; di makes it a **typed +obligation** carried in the third channel until an ancestor discharges it, gated +at `Module.build` / `Module.scoped` / `start`. `AuditSlice` declaring `[Logger]` +and importing nothing is that channel working, not a hole: nothing can run the +slice until a root imports something that exports `Logger`, which +`examples/order-amqp-worker/src/needs-gate.test-d.ts`'s `LoggerlessAmqp` pins. + +Three consequences, and they are why import-visibility was refused rather than +merely not adopted: + +- **There is no `@Global`, and none is needed.** NestJS needs one because an + unsatisfied need is an error at the module, so a cross-cutting provider has to + be visible everywhere. Here the obligation travels to the composition root, + which is the one place `observability()` is imported anyway. +- **Recomposability is what the model buys.** A slice that named its logger's + supplier would carry that supplier when lifted into a process of its own — the + do-not-break property in the root `CLAUDE.md`. Naming the port and not the + supplier is what makes a slice a piece rather than a program. +- **Substitution stays recomposition, so #63 is unblocked rather than forced.** + The alternative was tried and measured in #50: `imports: [observability()]` on + each slice is `[di] two providers registered for port "LoggerConfig"` — the + helper mints fresh providers per call and `flatten` dedupes by **reference** — + and hoisting one shared const instead closes `test-fixtures.ts`'s `tappedAmqp` + seam, whose whole job is layering `observability({ sink })` over a graph that + does not already provide `Logger`. Import-visibility needs `overrideProvider` + to stay testable; this model does not. + +**Visibility is a compile-time rule only.** `build.ts`'s `flatten` erases module +boundaries — one flat `Set` of providers, every dep resolved against all of it — +which is what makes a diamond one instance. The type channel is what refuses the +sibling above; the runtime graph would have resolved it. Do not describe the +runtime as scoped. + ## Binding design rules - **Comments in `src/` are regression guards, not decoration.** Many record