feat(di)!: a module declares what it expects from outside - #86
Merged
Conversation
Module(name)({ ... }) takes a fourth list, needs, and a port the module depends
on but neither provides nor imports must be named there. Anything it owes and
does not name is refused at that call, with the port in the message:
Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type
'{ provides: [...]; exports: [...]; }' but required in type
'{ readonly "UNDECLARED NEEDS — name it in `needs`": Logger; }'.
Before this a need nothing local satisfied simply travelled to whoever composed
the module, so a composition root could satisfy an imported module's dependency
without that module ever mentioning it — measured: a slice's provider received
the root's service while importing nothing at all, and slices/audit/ said
nothing about where its Logger came from.
needs is the explicit stand-in for NestJS's @global, which this container does
not have and now does not need: the port is named, the supplier is not, so a
slice still composes into any root that answers it.
Two shapes here are load-bearing and both were measured after they broke
something. The gate's failure branch is an object with one required property
rather than StartGate's bare string, because that is what makes the diagnostic
end on the port; and both that branch and the RETURN type inline their Exclude
rather than naming an alias — a named alias survives declaration emit unreduced
and names the imported modules' internal ports (TS2883/TS4023 on the first
consumer that exports a composition root).
NeedsGate and Unmet are exported for the starters' shaped modules, which
re-declare the gate over their own augmented tuples.
Closes #50.
The environment is the one that travels furthest: each starter binds its own configuration from it, so http(), temporal() and amqp() declare needs: [Env], observability() does too, and so does every module that imports one — up to the root start hands one to. That chain is what a @global would have hidden. The three sugars take needs and re-declare di's NeedsGate over their augmented tuples, exactly as they already re-declare Exportable. Without that a root written with HttpModule would skip the check entirely, since the gate cannot be computed inside the sugar where the tuples are still type parameters. They assert past it internally to a spelled-out type, not `as never` — that collapses the sugar's return to Module<never, never, never> (measured). start's Env wrapper and testing's tapped use `as never`, which is safe there because both already cast their result. Both carry the reason, and it is runMain's discharged-signature cast around StartGate one layer down. The three starter-port negatives moved from start to the module: a composition importing a starter without providing the router / handlers / activities now fails where the gap is. Declaring the port is not an escape either — each package exports its port's TYPE only, so an application has nothing to name.
AuditSlice is needs: [Logger], OrdersSlice is needs: [Env, Logger], and the application and persistence modules name theirs too — so slices/audit/ now says where its Logger comes from without naming who supplies it, which is the pair that keeps the slice recomposable. The needs-gate type tests move with it. Three negatives that used to be start's are now di's declaration gate, and order-application's gains one that separates them: DECLARING a port makes the module legal and leaves Module.scoped's arity gate as what is left, which is exactly the distinction the two gates draw.
The decision for #50 in packages/di/CLAUDE.md, the reader's version in modules-and-privacy and compile-time-wiring, the surface in reference/di/modules, the needs option on the three starter sugars, and 60-odd samples that would no longer compile. Two pages had a claim to retract rather than a sample to patch. keep-a-port-private described a provider reaching a private Pool as surfacing at the entry point; it is refused at the module now, and naming the port in needs is not a way round the boundary — it asks a root for a Pool, it does not make Persistence's visible. examples/order-api and examples/order-amqp-worker each described a starter-port omission as start's needs channel, ending on Type '"HttpRouter"' is not assignable to type '"@di/Scope"'; that measurement is gone, and the new one names the port in the marker's own property.
There was a problem hiding this comment.
Pull request overview
This PR introduces explicit module “needs” declarations to the @btravstack/di module API, adding a compile-time gate that refuses any module that owes ports it did not explicitly name in needs. This tightens module visibility/expectations across the monorepo and updates starter sugars, examples, and docs to reflect the new contract.
Changes:
- Add
needsas a fourthModule(name)({ ... })option tuple and implement theNeedsGate/Unmettype-level gate to reject undeclared unmet dependencies at the module declaration site. - Update starter sugars (
HttpModule,TemporalModule,AmqpModule) and starter primitives to re-declare/forward the needs gate so omissions fail at the sugar call site rather than later atstart. - Propagate the new
needsrequirement through tests, examples, and documentation (including changeset notes) so modules that rely onEnv,Logger, etc. declare them explicitly.
Reviewed changes
Copilot reviewed 97 out of 97 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates top-level README example to include needs in composition roots. |
| packages/testing/src/tapped.ts | Adjusts wrapper cast strategy to account for the new needs gate. |
| packages/temporal/src/workflow-activities.test-d.ts | Updates type tests to satisfy/illustrate needs for Temporal roots. |
| packages/temporal/src/test-fixtures.ts | Adds needs in Temporal test module compositions. |
| packages/temporal/src/temporal-runtime.ts | Makes Temporal starter explicitly declare it needs Env and the activities port. |
| packages/temporal/src/temporal-module.ts | Extends TemporalModule sugar to accept/forward needs and re-declare NeedsGate. |
| packages/temporal/README.md | Updates README worked example to include needs: [Env]. |
| packages/temporal/CLAUDE.md | Documents the new needs option and how the sugar re-declares the gate. |
| packages/observability/src/test-fixtures.ts | Updates fixture modules to declare needs (e.g., Env, Logger). |
| packages/observability/src/observability.ts | Declares needs: [Env] since the starter reads LOG_LEVEL. |
| packages/observability/README.md | Updates README worked example to include needs: [Env]. |
| packages/http/src/test-fixtures.ts | Adds needs: [Env] to HTTP test module compositions. |
| packages/http/src/http-runtime.ts | Declares needs: [Env] in the HTTP starter module. |
| packages/http/src/http-module.ts | Extends HttpModule sugar to accept/forward needs and re-declare NeedsGate. |
| packages/http/src/auth.test-d.ts | Updates auth marker type tests to reflect new failure location (module gate vs start). |
| packages/http/README.md | Updates README examples to include needs: [Env]. |
| packages/http/CLAUDE.md | Documents needs on HttpModule and the re-declared gate. |
| packages/di/src/scoped.test-d.ts | Updates type tests to declare needs explicitly where required. |
| packages/di/src/module.ts | Implements Unmet + NeedsGate and adds needs option tuple to ModuleDeclaration. |
| packages/di/src/module.test-d.ts | Adds/updates type tests for needs visibility, declaration gate behavior, and discharge rules. |
| packages/di/src/index.ts | Re-exports NeedsGate and Unmet for shaped-module consumers. |
| packages/di/src/fork.test-d.ts | Updates fork-related type tests to declare needs. |
| packages/di/src/fork.spec.ts | Declares fork seam dependency via needs: [Pool]. |
| packages/di/src/example.test-d.ts | Tightens seam typing to avoid generic “unnameable needs” propagation. |
| packages/di/src/example.spec.ts | Updates example seam to assert through NeedsGate with the new needs option. |
| packages/di/src/build.test-d.ts | Updates build gate type test to distinguish declared-vs-unsatisfied. |
| packages/di/src/build.spec.ts | Updates runtime defect test to reflect declared unmet needs behavior. |
| packages/di/README.md | Documents that modules declare needs explicitly. |
| packages/di/CLAUDE.md | Records the #50 decision and documents module visibility + the new needs declaration gate. |
| packages/core/src/test-fixtures.ts | Updates core fixtures to declare Env/fork seam needs explicitly. |
| packages/core/src/start.ts | Updates the Env wrapper cast to work with the new needs gate deferral. |
| packages/core/src/start.test-d.ts | Updates core start type tests for new module needs requirements. |
| packages/core/src/docs-examples.test-d.ts | Updates docs compilation gate examples to declare needs where required. |
| packages/config/README.md | Updates config README examples to declare needs: [Env]. |
| packages/amqp/src/test-fixtures.ts | Adds needs: [Env] to AMQP test module compositions. |
| packages/amqp/src/handler.test-d.ts | Updates AMQP handler type tests to satisfy the module needs gate. |
| packages/amqp/src/amqp-runtime.ts | Declares needs: [Env, handlersPort] in AMQP starter module. |
| packages/amqp/src/amqp-runtime.test-d.ts | Updates AMQP starter type tests to reflect new gate location/behavior. |
| packages/amqp/src/amqp-module.ts | Extends AmqpModule sugar to accept/forward needs and re-declare NeedsGate. |
| packages/amqp/README.md | Updates AMQP README worked example to include needs: [Env]. |
| packages/amqp/CLAUDE.md | Documents needs on AmqpModule and the re-declared gate. |
| examples/order-temporal-worker/src/test-fixtures.ts | Updates example worker fixtures to satisfy new needs rules. |
| examples/order-temporal-worker/src/slices/fulfillment/module.ts | Declares slice-level needs (Env, Logger) explicitly. |
| examples/order-temporal-worker/src/slices/billing/module.ts | Declares slice-level needs (Logger) explicitly. |
| examples/order-temporal-worker/src/needs-gate.test-d.ts | Updates example needs-gate type tests to the new declaration gate semantics. |
| examples/order-temporal-worker/src/module.ts | Declares root needs: [Env] on the Temporal worker composition. |
| examples/order-temporal-worker/src/fulfillment.ts | Declares module needs: [Logger] explicitly. |
| examples/order-temporal-worker/src/billing.ts | Declares module needs: [Logger] explicitly. |
| examples/order-temporal-worker/README.md | Updates example README snippet to include needs: [Env]. |
| examples/order-infrastructure/src/module.ts | Declares needs: [Env] on DB/persistence modules that read env-bound config. |
| examples/order-infrastructure/README.md | Updates infrastructure example docs to include needs: [Env]. |
| examples/order-application/src/needs-gate.test-d.ts | Updates layer gating type test to reflect “undeclared needs” failure earlier. |
| examples/order-application/src/module.ts | Declares application-layer needs (OrderRepository, Logger, etc.) explicitly. |
| examples/order-api/src/test-fixtures.ts | Updates HTTP example fixtures to satisfy new needs requirements. |
| examples/order-api/src/slices/orders/module.ts | Declares slice needs: [Env, Logger] explicitly. |
| examples/order-api/src/slices/customers/module.ts | Declares slice needs: [Env] explicitly. |
| examples/order-api/src/request-scope.ts | Declares request fork seam needs: [Logger] explicitly. |
| examples/order-api/src/needs-gate.test-d.ts | Updates transport-layer needs-gate type tests to new declaration gate semantics. |
| examples/order-api/src/module.ts | Declares root needs: [Env] on the HTTP API composition. |
| examples/order-api/src/docs-examples.test-d.ts | Updates compiled docs examples to include explicit needs. |
| examples/order-api/README.md | Updates example index/readme snippets to include needs: [Env]. |
| examples/order-amqp-worker/src/test-fixtures.ts | Updates AMQP example fixtures to satisfy new needs requirements. |
| examples/order-amqp-worker/src/slices/notifications/module.ts | Declares slice needs: [Logger] explicitly. |
| examples/order-amqp-worker/src/slices/audit/module.ts | Declares slice needs: [Logger] explicitly. |
| examples/order-amqp-worker/src/needs-gate.test-d.ts | Updates AMQP example type gates to new declaration gate semantics. |
| examples/order-amqp-worker/src/module.ts | Declares root needs: [Env] on the AMQP worker composition. |
| examples/hexagonal-order-api/src/index.ts | Updates hexagonal example seam to assert through NeedsGate under new rules. |
| examples/hexagonal-order-api/src/emit-guards.ts | Adds explicit needs to preserve declaration-emit guards under the new API. |
| docs/tutorial/second-runtime.md | Updates tutorial narrative examples to include needs: [Env]. |
| docs/tutorial/getting-started.md | Updates tutorial narrative examples to include needs: [Env]. |
| docs/reference/testing.md | Updates testing reference snippets to include needs: [Env]. |
| docs/reference/temporal.md | Updates Temporal reference exports table to include needs on TemporalModule. |
| docs/reference/observability.md | Updates observability reference snippet to include needs: [Env]. |
| docs/reference/http.md | Updates HTTP reference exports table to include needs on HttpModule. |
| docs/reference/di/modules.md | Updates DI modules reference to document needs and the declaration gate. |
| docs/reference/config.md | Updates config reference examples to include needs: [Env]. |
| docs/reference/amqp.md | Updates AMQP reference exports table to include needs on AmqpModule. |
| docs/index.md | Updates docs landing page example composition to include needs: [Env]. |
| docs/how-to/test-an-application.md | Updates how-to snippets to include needs: [Env] where required. |
| docs/how-to/split-a-worker-into-slices.md | Updates how-to to show slice/root needs explicitly. |
| docs/how-to/split-a-router-into-controllers.md | Updates how-to snippets to include needs: [Env] and slice needs. |
| docs/how-to/serve-orpc-over-http.md | Updates recipe text/snippets to include needs: [Env]. |
| docs/how-to/run-a-temporal-worker.md | Updates recipe text/snippets to include needs: [Env]. |
| docs/how-to/protect-a-procedure.md | Updates recipe text/snippets to include needs: [Env]. |
| docs/how-to/open-a-per-request-scope.md | Updates how-to examples to include needs and the fork seam declaration. |
| docs/how-to/log-and-correlate.md | Updates how-to snippets to include needs: [Env]. |
| docs/how-to/keep-a-port-private.md | Updates explanation to reflect the new declaration gate behavior. |
| docs/how-to/consume-amqp-messages.md | Updates recipe text to include needs: [Env]. |
| docs/how-to/configure-from-the-environment.md | Updates how-to to include Env import and needs: [Env]. |
| docs/explanation/starters.md | Updates starters explanation to include needs: [Env] in the sugar signature. |
| docs/explanation/modules-and-privacy.md | Adds/updates explanation of declared needs vs absorbed dependencies. |
| docs/explanation/compile-time-wiring.md | Updates wiring explanation to include the new declaration gate step. |
| docs/examples/order-temporal-worker.md | Updates example docs snippets to include needs: [Env]. |
| docs/examples/order-api.md | Updates example docs to include root/slice needs and new gate wording. |
| docs/examples/order-amqp-worker.md | Updates example docs to include needs: [Env] and declaration gate behavior. |
| CLAUDE.md | Updates repository-wide guidance to reflect the new “undeclared need” behavior. |
| .changeset/declared-module-needs.md | Adds a changeset describing the new needs option and gate behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The batch that added `needs: [Env]` to every sugar sample matched inside inline code spans too, so three reference tables gained a second row mid-signature and six prose paragraphs gained a stray line — and in the files where the prose was the only match, the actual sample never got the line at all. Every injection outside a fenced block is undone, the seven samples that were missed have it, and the prose signatures name `needs` the way they name every other option instead.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #50. Replaces #85, whose premise was wrong.
The decision
A module states what it expects from outside, and anything it owes and did
not state is a compile error at that module.
needsis the explicit stand-in for NestJS's@Global, which this containerdoes not have and now does not need: the port is named, the supplier is not, so
the slice still composes into any root that answers it.
What was actually wrong
#85 measured the sibling case and generalised. The ancestor case is what
the issue was about, and it behaved as reported — a root satisfied an imported
module's dependency without that module ever mentioning it:
Both halves are closed: the module names the port, and a root offering one
nobody asked for is offering it to nobody.
The diagnostic
The gate rides an intersection on the options parameter —
unknownwhensatisfied. Its failure branch is an object with one required property rather
than
StartGate's bare string, because that is what makes the message end onthe port:
Both that branch and the return type inline their
Excluderather thannaming an alias: a named alias survives declaration emit unreduced and names the
imported modules' internal ports — TS2883/TS4023 on the first consumer that
exports a composition root (
OrderApi"cannot be named without a reference to'OrderDatabase'"). Caught by the existing emit guard, which is what it is for.
Two rules worth knowing
Scopeis exempt, and it is forced rather than chosen. Nothing canprovide
Scope— a provider for it is aWiringDefect— so it is neversomething an ancestor supplies.
Envis not exempt, and that is the point. Every module that reads theenvironment says
needs: [Env], and so does every module that imports one, upto the root
starthands one to. That chain is what a@Globalwould hide.Generic wrappers
Unmet<I, P>over a generic tuple is a deferred conditional, so a wrapperaround
Module(name)cannot satisfy the gate at its own definition site. Thepattern —
runMain's discharged-signature cast, one layer down:as the sugars already re-declare
Exportable. Without this a root writtenwith
HttpModulewould skip the check entirely.as never— thatcollapses the sugar's return to
Module<never, never, never>(measured).startandtappedmay useas never, since both already cast their result.What moved
Three starter-port negatives moved from
startto the module: a compositionimporting a starter without providing the router / handlers / activities now
fails where the gap is. Declaring the port is not an escape — each package
exports its port's TYPE only, so an application has nothing to name.
order-application's gate test gains an arm that separates the two: declaring aport makes the module legal and leaves
Module.scoped's arity error as what isleft.
Scale
97 files.
di's type algebra, the three starter sugars,start'sEnvwrapper,
tapped, all ten examples, ~25 module declarations, fourneeds-gate.test-d.tsfiles, and 60-odd documentation samples. Two pages had aclaim to retract rather than a sample to patch —
keep-a-port-privateand thetwo example pages describing the starter-port refusal as
start's.Gate
All six green from the root:
format --check,lint,typecheck(31/31),knip,test(30/30),build(10/10). Changeset included — minor, all nine.