Skip to content

docs(di): a need is an obligation, not a lookup - #85

Closed
btravers wants to merge 2 commits into
mainfrom
docs/di-module-visibility
Closed

docs(di): a need is an obligation, not a lookup#85
btravers wants to merge 2 commits into
mainfrom
docs/di-module-visibility

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Closes #50.

The decision

Keep the model. The issue's framing — "needs bubble up until some ancestor
discharges it" — is what made it look like a hole, and the measurement says the
opposite.

A module's Needs is
Exclude<its providers' deps | its imports' unmet needs, Available>, and
Available is what the module provides plus what its imports export
nothing else. So the satisfaction rule is already NestJS's. Measured: a sibling
that imports observability() and re-exports nothing does not discharge a
slice's Logger.

error TS2345: Argument of type 'Module<AmqpRuntime, ConfigInvalid, Env | Logger>'
  is not assignable to parameter of type 'Module<AmqpRuntime, ConfigInvalid, Env | Scope>'.

The one real divergence 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 — nothing can run it until a root
supplies one, which needs-gate.test-d.ts's LoggerlessAmqp already pins.

The two open questions the issue attached

  • @Global: not needed, and that follows from the above. NestJS needs one
    because the error is local, so a cross-cutting provider must be visible
    everywhere. Here the obligation travels to the composition root, which is the
    one place observability() is imported anyway.
  • Reconsider overrideProvider for testing — it costs four hand-maintained parallel roots today #63 is unblocked, not forced. Substitution stays recomposition, so
    tappedAmqp's seam stays open. Import-visibility was the arm that needed
    overrideProvider to remain testable — the issue measured both halves of why
    ([di] two providers registered for port "LoggerConfig", then the closed
    recording seam).

What is in the diff

  • module.test-d.ts"only an import's own exports discharge a need", both
    arms: the opaque holder that leaves the need standing, and
    exports: [ConfigModule], the one difference that discharges it. The property
    the decision rests on had nothing pinning it.
  • packages/di/CLAUDE.md — the decision, for a contributor, with the
    recomposability argument the issue asked for.
  • docs/explanation/modules-and-privacy.md — the reader's version of the same
    question, which is "where is @Global". The page already described the
    mechanism correctly; what it did not have was the NestJS comparison.

No behaviour change, no changeset: *.test-d.ts is excluded from the build.

Gate

All six green from the root: format --check, lint, typecheck (31/31),
knip, test (30/30), build.

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.
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".
Copilot AI lite review requested due to automatic review settings August 21, 2026 16:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR documents and pins the decision from #50 that di treats unmet needs as typed obligations carried to the composition root (rather than local module errors), while still keeping visibility constrained to a module’s own provides plus its imports’ exports.

Changes:

  • Adds a new .test-d.ts case asserting that only an import’s exports discharge downstream needs (opaque vs passthrough holder).
  • Updates packages/di/CLAUDE.md with the clarified visibility model and the rationale (incl. NestJS comparison and why @Global isn’t needed).
  • Updates the docs explanation page to reflect the same decision in reader-facing terms.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/di/src/module.test-d.ts Adds a type-level regression test pinning the “imports’ exports only” need-discharge rule.
packages/di/CLAUDE.md Records the #50 decision and clarifies the model as “obligation, not lookup,” with consequences and rationale.
docs/explanation/modules-and-privacy.md Adds a NestJS comparison section explaining why unmet needs travel to the root and why there’s no @Global.
Suppressed comments (1)

docs/explanation/modules-and-privacy.md:68

  • Same grammatical issue here: "a need nothing local satisfies" reads as missing "that".
`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

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +128 to +131
// "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.
Comment thread packages/di/CLAUDE.md
Comment on lines +179 to +181
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
Comment on lines +58 to +61
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
@btravers
btravers marked this pull request as draft August 21, 2026 16:57
@btravers

Copy link
Copy Markdown
Contributor Author

Correction — the central claim in this PR is wrong, and the issue is right.

I measured the sibling case (a module that imports observability() and
re-exports nothing does not discharge another module's Logger) and generalised
it to "di already has NestJS's visibility rule". It does not. The ancestor
case is the one #50 is about, and it behaves exactly as the issue says.

Type level — the root's own provides discharges an imported slice's need,
with the slice importing nothing:

const Slice = Module("Slice")({ provides: [RepositoryProvider], exports: [OrderRepository] });
const Root = Module("Root")({ imports: [Slice], provides: [DatabaseProvider], exports: [OrderRepository] });
const rootNeedsNothing: Equal<Channels<typeof Root>[2], never> = true;  // compiles

Runtime — the slice's provider receives the root's instance:

✓ a slice's provider receives the ROOT's service, importing nothing

So a slice genuinely does see providers from the root. Draft until #50 is
re-decided; the module.test-d.ts pin is still correct and still worth keeping
whichever way it goes, since the sibling rule is real.

@btravers

Copy link
Copy Markdown
Contributor Author

Superseded by the corrected decision — see the PR that replaces it. The premise in this PR is wrong: the ancestor case is what #50 is about, and a root does supply an imported module's need without that module mentioning it.

@btravers btravers closed this Aug 21, 2026
@btravers
btravers deleted the docs/di-module-visibility branch August 23, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide di's module visibility: needs bubble up today, NestJS scopes to imports

2 participants