Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/explanation/modules-and-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +58 to +61
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
Expand Down
57 changes: 57 additions & 0 deletions packages/di/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<its providers' deps | its imports' unmet needs, Available>`, 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<AmqpRuntime, ConfigInvalid, Env | Logger>'
is not assignable to parameter of type 'Module<AmqpRuntime, ConfigInvalid, Env | Scope>'.
```

`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
Comment on lines +179 to +181
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
Expand Down
37 changes: 37 additions & 0 deletions packages/di/src/module.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +128 to +131
const Holder = Module("Holder")({ imports: [ConfigModule] });
const opaque = Module("Opaque")({
imports: [Holder],
provides: [DatabaseProvider],
exports: [Database],
});
type OpaqueChannels = ChannelsOf<typeof opaque>;
const stillNeedsAppConfig: Equal<OpaqueChannels[2], AppConfig> = 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<typeof wired>;
const needsNothing: Equal<WiredChannels[2], never> = true;
void needsNothing;
});

test("an unmet requirement cannot be laundered to no requirement", () => {
const orphan = Module("Orphan")({
provides: [OrderRepositoryProvider],
Expand Down
Loading