Skip to content
Merged
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
19 changes: 13 additions & 6 deletions .changeset/declared-module-needs.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
"@btravstack/amqp": minor
---

A module declares what it expects from outside
A module declares what its own providers expect from outside

`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:
`Module(name)({ … })` takes a fourth list, `needs`. A port **this module's own
providers** read, and that nothing here satisfies, must be named there; anything
they owe and it 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
Expand All @@ -32,9 +33,15 @@ directory could not be read on its own.
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.

**An import's own needs are not the importer's to re-declare.** They are already
published in the import's type, and the entry point still refuses a root that
has not discharged them — so the declaration lands on the feature that reads the
port, once, rather than on every module between it and the root. That is
`ConfigModule.forFeature`'s shape reached without a global: `DatabaseModule`
says `needs: [Env]` because it reads `DATABASE_URL`, and the persistence modules
and slices that import it say nothing.

`Scope` is exempt — nothing can provide it, and the entry point discharges it.
`Env` is not: 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.

The three starter sugars — `HttpModule`, `AmqpModule`, `TemporalModule` — take
`needs` too and re-declare the gate over their augmented tuples, so a
Expand Down
46 changes: 24 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,22 +445,21 @@ type checker already verifies.
**`start`'s** gate (`order-api`, `order-temporal-worker`,
`order-amqp-worker` — its `NO RUNTIME` arm, since no starter's runtime
declares a `needs` any more; `order-api`'s also pins the `unit` halves) and
the **undeclared need** on the starter's port (a composition importing
`http()` / `temporal({ contract, workflows })` / `amqp({ contract })` without
providing the router / activities / handlers owes the starter's port, and
since #50 that is refused at the **module** rather than at `start` — di's
`NeedsGate`, measured: `Property '"UNDECLARED NEEDS — name it in `needs`"' is
missing … but required in type
'{ readonly "UNDECLARED NEEDS — name it in `needs`": HttpRouterPort; }'`.
Declaring it is not the escape either: each starter exports its port's TYPE
only, so an application has nothing to name and providing the router /
handlers / activities is the only way past); the fourth,
`order-application`'s, pins **di's**
the **unmet need** on the starter's port (a composition importing `http()` /
`temporal({ contract, workflows })` / `amqp({ contract })` without providing
the router / activities / handlers carries the starter's port in `Needs`, and
`start`'s `module` parameter takes only `Scope | Env`, so it fails to assign —
the starter is an IMPORT, and an import's needs travel without the importer
re-declaring them, so di's declaration gate has nothing to say and this stays
the kernel's); the fourth, `order-application`'s, pins **di's**
`UNSATISFIED DEPENDENCIES` gate on `Module.scoped`, which is a rest-tuple
**arity** error printing `Expected 5 arguments, but got 2` and nothing else —
reached only once the module DECLARES what it owes, since an undeclared one
never gets that far.
**Four** different mechanisms now, easy to conflate — and only two print a
**arity** error printing `Expected 5 arguments, but got 2` and nothing else.
A **fourth** mechanism joined them in #50 and is pinned beside the third:
di's `NeedsGate`, which fires when a module's OWN provider reads a port
nothing local satisfies and `needs` does not name it —
`order-temporal-worker`'s `FulfillmentlessSlice`, printing
`'{ readonly "UNDECLARED NEEDS — name it in `needs`": StockService | ShippingService; }'`.
**Four** mechanisms, easy to conflate — and only two print a
name. Do not call the second "di's `UNSATISFIED DEPENDENCIES` gate": an
earlier revision of this file did, and it is wrong in both halves. `start`'s
`UNSATISFIED RUNTIME NEEDS` arm is pinned only by `packages/core`'s own
Expand Down Expand Up @@ -693,13 +692,16 @@ AuditSlice, observability()], … })`),
Temporal workflow and its activities, an AMQP consumer and its handler — and
(if it needs one) its own adapter, and ships as an ordinary di `Module` that
exports only that piece's port — everything else about the slice stays
private. It also **declares what it expects from the root**, in `needs`:
`AuditSlice` is `needs: [Logger]`, `OrdersSlice` is `needs: [Env, Logger]`,
and a slice that owed a port and named none does not compile (#50, di's
`NeedsGate` — the full rule is in `packages/di/CLAUDE.md`). That is what
makes a slice directory readable on its own: it says which ports come from
outside without naming who supplies them, so the slice still composes into
any root that answers them. `@btravstack/http`'s
private. It also **declares what its own providers expect from the
root**, in `needs`: `AuditSlice` is `needs: [Logger]` because its handler
reads one, `OrdersSlice` is `needs: [Logger]` because its controller does,
and a slice whose provider owed a port and named none does not compile (#50,
di's `NeedsGate` — the full rule is in `packages/di/CLAUDE.md`). An
**import's** needs are not restated: `OrdersSlice` says nothing about `Env`,
because the module that reads `DATABASE_URL` is `DatabaseModule` and it says
so there. That is what makes a slice directory readable on its own — which
ports come from outside, without naming who supplies them — and what keeps a
`needs` list one line per feature instead of one per hop. `@btravstack/http`'s
`HttpController(name, fragment)({ name: Dep }, { sync })` mints the controller's
port; the root composes every slice's controller into one router with the
keyed `HttpRouter(contract)(controllers)` form, exact against the contract
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,10 @@ export const ordersRouter = HttpRouter(ordersContract)(

```ts
// main.ts — the whole process.
import { Env } from "@btravstack/config";
import { runMain } from "@btravstack/core";
import { HttpModule } from "@btravstack/http";

const OrdersApi = HttpModule("OrdersApi")({
needs: [Env],
router: ordersRouter,
imports: [OrderApplicationModule, OrderPersistenceModule],
});
Expand Down
27 changes: 12 additions & 15 deletions docs/examples/order-amqp-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,7 @@ spelled with the `amqp()` primitive — the sugar cannot leave the handlers out,
which is what it is for:

```ts
// @ts-expect-error — UNDECLARED NEEDS: the starter's handlers port.
const HandlerlessAmqp = Module("HandlerlessAmqp")({
needs: [Env],
imports: [
OrderApplicationModule,
OrderPersistenceModule,
Expand All @@ -356,24 +354,23 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({
],
exports: [AmqpRuntime, PlaceOrder, Logger],
});

// @ts-expect-error — the module's needs channel carries the handlers port, which nothing provides.
const _missingHandlers = start(HandlerlessAmqp, options);
```

Two different diagnostics, worth telling apart. The first is `start`'s marker:
the module argument fails to match
`Module<…> & "NO RUNTIME — the module exports no port declared over RuntimePort"`,
and the sentence is the last line. The second is di's own
[declaration gate](/explanation/modules-and-privacy): the handlers port is owed
here and not named in `needs`, so the module never gets as far as `start`, and
what prints ends on

```
'{ readonly "UNDECLARED NEEDS — name it in `needs`": HandlersInstanceOf<…>; }'
```

Declaring it is not the escape: `@btravstack/amqp` exports its handlers port's
TYPE only, so an application has nothing to name — providing the handlers is
the only way past, which is what the gate is for. Neither diagnostic is di's
`UNSATISFIED DEPENDENCIES` arity gate.
and the sentence is the last line. The second is the `Needs` channel: the
handlers port is owed by `amqp()`, an **import** — so di's
[declaration gate](/explanation/modules-and-privacy) has nothing to say, an
import's needs travel without being restated, and `start`'s `module` parameter
takes only `Scope | Env`, so what prints is
`Type 'HandlersInstanceOf<…>' is not assignable to type 'Env | Scope'` — wide,
because the contract expands, but ending on
`Type '"AmqpHandlers"' is not assignable to type '"@di/Scope"'`, which names the
port. Neither is di's `UNSATISFIED DEPENDENCIES` arity gate.

## Where to go next

Expand Down
34 changes: 14 additions & 20 deletions docs/examples/order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,6 @@ composition root and one fewer import, not a rewrite.

```ts
export const OrderApi = HttpModule("OrderApi")({
needs: [Env],
router: orderRouter,
authenticator: bearerAuthenticator,
imports: [OrdersSlice, CustomersSlice, observability()],
Expand All @@ -369,9 +368,11 @@ happens to depend on:

```ts
export const OrdersSlice = Module("OrdersSlice")({
// The environment its persistence reads `DATABASE_URL` from, and the logger
// its interactors write to — both the root's to supply, both named here.
needs: [Env, Logger],
// The controller writes a line itself, so `Logger` is this slice's own
// provider's need. The environment its persistence reads `DATABASE_URL` from
// is not: that one is `DatabaseModule`'s, declared there and inherited
// through the imports below.
needs: [Logger],
Comment thread
btravers marked this conversation as resolved.
imports: [OrderApplicationModule, OrderPersistenceModule],
provides: [ordersController],
exports: [ordersController],
Expand Down Expand Up @@ -546,27 +547,22 @@ marks `orders`, so a graph carrying the router without an authenticator has an
unmet need too, and an arm that could fail either way pins neither gate.

```ts
// @ts-expect-error — UNDECLARED NEEDS: the starter's router port.
const RouterlessApi = Module("RouterlessApi")({
needs: [Env],
imports: [OrdersSlice, CustomersSlice, observability(), http()],
exports: [HttpRuntime, Logger],
});
```

This one is di's own **declaration gate**, not the kernel's marker: `http()`'s
runtime provider depends on the starter's own router port, so a composition
that imports the starter without providing the router owes it — and owing a
port it does not name in `needs` is refused at the module, before `start` is
reached at all. What prints names the port:

```
'{ readonly "UNDECLARED NEEDS — name it in `needs`": HttpRouterPort; }'
// @ts-expect-error — the composition needs the router port and nothing provides it.
const _missingRouter = start(RouterlessApi, options);
```

Naming it is not the escape: `@btravstack/http` exports that port's TYPE only,
so an application has nothing to write there — providing the router is the way
past, which is what the gate is for. It is **not** di's
This one is the **`Needs` channel**, not the kernel's marker and not di's
declaration gate either: the port is owed by `http()`, an **import**, and an
import's needs travel without the importer re-declaring them. `start` — whose
`module` parameter accepts only `Scope | Env` outstanding — is what refuses it,
and the diagnostic names the port:
`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`, down to
`Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`. It is **not** di's
`UNSATISFIED DEPENDENCIES` arity gate, which guards `Module.build` and
`Module.scoped`; conflating the two is easy and the distinction is the point of
having both pinned here. There is no `UNSATISFIED RUNTIME NEEDS` arm, because
Expand All @@ -587,7 +583,6 @@ The last two are the authenticator's, and they are different gates on purpose:

```ts
const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({
needs: [Env],
router: orderRouter,
imports: [OrdersSlice, CustomersSlice, observability()],
exports: [Logger],
Expand All @@ -609,7 +604,6 @@ const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({
});

const _mismatchedApi = HttpModule("MismatchedApi")({
needs: [Env],
router: orderRouter,
// @ts-expect-error — the authenticator resolves `{ sub }`, not the router's Identity.
authenticator: wrongAuthenticator,
Expand Down
2 changes: 0 additions & 2 deletions docs/examples/order-temporal-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ export const orderActivities = TemporalActivities(orderContract)([
]);

export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({
needs: [Env],
contract: orderContract,
activities: orderActivities,
workflows: {
Expand Down Expand Up @@ -262,7 +261,6 @@ billing is never swapped:

```ts
const worker = TemporalModule("StubTemporalWorker")({
needs: [Env],
contract,
activities: orderActivities,
workflows: { workflowBundle },
Expand Down
15 changes: 8 additions & 7 deletions docs/explanation/compile-time-wiring.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ Persistence (provides Pool, exports OrderRepository) Needs: Scope ← Pool ne
App (imports Persistence) Needs: Scope ← still unpaid
```

An unpaid balance may only travel if the module **signed for it**. A module
that owes a port it neither provides nor imports has to name it in `needs`, and
one that does not is refused where it is written:
An unpaid balance run up by a module's **own providers** may only travel if that
module **signed for it**. A provider reading a port nothing here satisfies has
to be answered by a `needs` entry, and a module that does not is refused where
it is written:

```
Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type
Expand All @@ -66,10 +67,10 @@ Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type
```

That is the first of the checks, and the only one that fires at a module rather
than at a call that builds one. `Scope` is exempt — nothing can provide it, so
it is never something an ancestor signs over. Everything else that survives the
subtraction has been declared on purpose, and travels to whoever composes the
module.
than at a call that builds one. It reads a module's own providers alone: a
balance inherited from an **import** travels without being signed for again,
because it is already published in that import's type. `Scope` is exempt —
nothing can provide it, so it is never something an ancestor signs over.

The remaining checks happen at the one place a graph becomes running services.

Expand Down
63 changes: 43 additions & 20 deletions docs/explanation/modules-and-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ needs `@Global`, a way for a cross-cutting module to be visible without being
imported.

`di` splits that differently. A module may be handed a port by whoever composes
it — but only one it **asked for by name**:
it — but only one **its own providers asked for by name**:

```ts
export const AuditSlice = Module("AuditSlice")({
Expand All @@ -72,26 +72,49 @@ export const AuditSlice = Module("AuditSlice")({
});
```

`needs` does not make `Logger` visible the way an import would, and it does not
manufacture an obligation for a root that owes nothing. It says: _this module
depends on a `Logger` it does not build, and something above it has to_. Leave
it out and the module does not compile at all — the diagnostic names the port —
so a slice can never quietly absorb whatever the composition root happens to be
holding.

That is the whole difference from `@Global`. A global module is invisible
plumbing: a slice benefits from it without mentioning it, and reading
`slices/audit/` still tells you nothing about where its logger comes from. A
declared need is the opposite — the slice states the port and stays silent
about the supplier, which is exactly the pair that lets it be recomposed. The
same `AuditSlice` drops into a different root, or
[lifts into a process of its own](/how-to/split-a-router-into-controllers),
with no edit: any root that answers `Logger` will do.
The provider may depend on that `Logger` and will be handed whatever an ancestor
supplies. What `needs` does **not** do is provide the port here: it stays
outside what the module can see, so it cannot be exported, and it manufactures
no obligation for a root that owes nothing. It says: _the provider in this
module depends on a `Logger` it does not build, and something above it has to_. Leave it out and the module does not compile at all — the diagnostic
names the port — so a slice can never quietly absorb whatever the composition
root happens to be holding.

**An import's needs travel on their own.** A module that merely imports
`AuditSlice` does not restate `Logger`: the obligation is already in
`AuditSlice`'s type, at the `imports` entry a reader is looking at, and the
[entry point](/reference/di/entry-points) still refuses a root that has not
discharged it. Restating it at every level would put one line on every module
between the reader of a port and the root that supplies it — for `Env`, that
was six declarations in the order API and only one of them a module that reads
an environment variable.

So the declaration lands where the feature is:

```ts
// reads DATABASE_URL — declares it
const DatabaseModule = Module("Database")({
needs: [Env],
provides: [databaseConfig, orderDatabaseProvider],
exports: [OrderDatabase],
});

The cost is one line per module, and it compounds — `Env` is declared by every
module that reads the environment, and again by every module that imports one
of those, up to the root that `start` hands one to. That chain is the thing a
`@Global` would have hidden, and seeing it is the point.
// only imports it — declares nothing
export const OrderPersistenceModule = Module("OrderPersistence")({
imports: [DatabaseModule],
provides: [orderRepositoryProvider, outboxProvider],
exports: [OrderRepository, Outbox],
});
```

That is NestJS's `ConfigModule.forFeature` shape without a global to reach it
through — and it is the whole difference from `@Global`. A global module is
invisible plumbing: a slice benefits from it without mentioning it, and reading
`slices/audit/` still tells you nothing. A declared need is the opposite — the
slice states the port and stays silent about the supplier, which is exactly the
pair that lets it be recomposed. The same `AuditSlice` drops into a different
root, or [lifts into a process of its own](/how-to/split-a-router-into-controllers),
with no edit: any root that answers `Logger` will do.

`Scope` is the one exemption, and it is forced rather than chosen: nothing can
provide `Scope` — a provider for it is a
Expand Down
1 change: 0 additions & 1 deletion docs/explanation/starters.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ spelled once. From [`examples/order-api`](/examples/order-api):

```ts
export const OrderApi = HttpModule("OrderApi")({
needs: [Env],
router: orderRouter,
imports: [OrderApplicationModule, OrderPersistenceModule, observability()],
exports: [Logger],
Expand Down
3 changes: 0 additions & 3 deletions docs/how-to/log-and-correlate.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,11 @@ happened. The recipe is one import.
`StartOptions.unit` module, a test.

```ts
import { Env } from "@btravstack/config";
import { Module, Provider } from "@btravstack/di";
import { HttpModule } from "@btravstack/http";
import { Logger, observability } from "@btravstack/observability";

export const OrderApi = HttpModule("OrderApi")({
needs: [Env],
router: orderRouter,
imports: [OrderApplicationModule, OrderPersistenceModule, observability()],
exports: [Logger],
Expand Down Expand Up @@ -251,7 +249,6 @@ values:
const lines: Line[] = [];

const RecordingApi = HttpModule("RecordingApi")({
needs: [Env],
router: orderRouter,
imports: [
OrderApplicationModule,
Expand Down
1 change: 0 additions & 1 deletion docs/how-to/open-a-per-request-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ the last line of the error:

```ts
const UnloggedApi = Module("UnloggedApi")({
needs: [Env],
imports: [
OrderApplicationModule,
OrderPersistenceModule,
Expand Down
Loading
Loading