Skip to content

feat(di)!: a module declares what it expects from outside - #86

Merged
btravers merged 5 commits into
mainfrom
feat/di-declared-needs
Aug 21, 2026
Merged

feat(di)!: a module declares what it expects from outside#86
btravers merged 5 commits into
mainfrom
feat/di-declared-needs

Conversation

@btravers

Copy link
Copy Markdown
Contributor

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.

export const AuditSlice = Module("AuditSlice")({
  needs: [Logger],
  provides: [orderAudit],
  exports: [orderAudit],
});

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
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:

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;   // compiled
✓ a slice's provider receives the ROOT's service, importing nothing

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 — unknown when
satisfied. Its failure branch is an object with one required property rather
than StartGate's bare string, because that is what makes the message end on
the port:

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; }'.

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 (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

  • Scope is exempt, and it is forced rather than chosen. Nothing can
    provide Scope — a provider for it is a WiringDefect — so it is never
    something an ancestor supplies.
  • Env is not exempt, and that is the point. Every module that reads the
    environment says needs: [Env], and so does every module that imports one, up
    to the root start hands one to. That chain is what a @Global would hide.

Generic wrappers

Unmet<I, P> over a generic tuple is a deferred conditional, so a wrapper
around Module(name) cannot satisfy the gate at its own definition site. The
pattern — runMain's discharged-signature cast, one layer down:

  1. Re-declare the gate on the wrapper's options, over its augmented tuples,
    as the sugars already re-declare Exportable. Without this a root written
    with HttpModule would skip the check entirely.
  2. 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 and tapped may use as never, since both already cast their result.

What moved

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 — 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 a
port makes the module legal and leaves Module.scoped's arity error as what is
left.

Scale

97 files. di's type algebra, the three starter sugars, start's Env
wrapper, tapped, all ten examples, ~25 module declarations, four
needs-gate.test-d.ts files, and 60-odd documentation samples. Two pages had a
claim to retract rather than a sample to patch — keep-a-port-private and the
two 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.

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

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 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 needs as a fourth Module(name)({ ... }) option tuple and implement the NeedsGate/Unmet type-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 at start.
  • Propagate the new needs requirement through tests, examples, and documentation (including changeset notes) so modules that rely on Env, 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.

Comment thread README.md
Comment thread docs/reference/amqp.md Outdated
Comment thread docs/reference/temporal.md Outdated
Comment thread docs/reference/http.md Outdated
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.
@btravers
btravers merged commit 62042a6 into main Aug 21, 2026
13 checks passed
@btravers
btravers deleted the feat/di-declared-needs branch August 21, 2026 18:37
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