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
45 changes: 45 additions & 0 deletions .changeset/declared-module-needs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@btravstack/contract": minor
"@btravstack/di": minor
"@btravstack/config": minor
"@btravstack/core": minor
"@btravstack/testing": minor
"@btravstack/observability": minor
"@btravstack/http": minor
"@btravstack/temporal": minor
"@btravstack/amqp": minor
---

A module declares what it expects 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:

```
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, and 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. A slice
directory could not be read on its own.

`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.

`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
composition root written with a sugar is checked exactly like a bare
`Module(name)`.

`@btravstack/di` additionally exports `NeedsGate` and `Unmet`, which a package
offering its own shaped module needs in order to re-declare the gate.
34 changes: 23 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,17 +445,23 @@ 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 **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 —
measured: a `TS2345` ending on
`Type '"HttpRouter"' is not assignable to type '"@di/Scope"'`, which names the
port); the fourth, `order-application`'s, pins **di's**
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**
`UNSATISFIED DEPENDENCIES` gate on `Module.scoped`, which is a rest-tuple
**arity** error printing `Expected 5 arguments, but got 2` and nothing else.
**Three** different mechanisms, easy to conflate — and only the first prints
its name. Do not call the second "di's `UNSATISFIED DEPENDENCIES` gate": an
**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
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
`start.test-d.ts`, since every shipped runtime declares `needs: []`.
Expand Down Expand Up @@ -687,7 +693,13 @@ 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. `@btravstack/http`'s
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
`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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ from its state; a failed finaliser is logged and forgotten; a crash exits `0`.
`start` gets them right once, as defaults.

- 🧩 **Business code only.** A composition root is a
`HttpModule("OrdersApi")({ router, imports, exports })` and a `main.ts` is
`HttpModule("OrdersApi")({ router, imports, exports, needs })` and a `main.ts` is
`await runMain(OrdersApi)`. Configuration is bound from the environment
Comment thread
btravers marked this conversation as resolved.
inside the graph; there is no `process.env`, no `app.listen`, no signal
handler to write.
Expand Down Expand Up @@ -140,10 +140,12 @@ 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
26 changes: 16 additions & 10 deletions docs/examples/order-amqp-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ the runtime's `stop`). `drain` stays the consumer's alone — draining means

```ts
export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({
needs: [Env],
contract: orderContract,
handlers: orderHandlers,
imports: [
Expand Down Expand Up @@ -344,7 +345,9 @@ 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 @@ -353,21 +356,24 @@ 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 the `Needs` channel: the
handlers port is left outstanding 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.
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.

## Where to go next

Expand Down
32 changes: 22 additions & 10 deletions docs/examples/order-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ 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 @@ -368,6 +369,9 @@ 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],
imports: [OrderApplicationModule, OrderPersistenceModule],
provides: [ordersController],
exports: [ordersController],
Expand Down Expand Up @@ -433,6 +437,7 @@ export class RequestSpan extends Port("RequestSpan")<{
}> {}

export const RequestModule = Module("Request")({
needs: [Logger],
provides: [
Provider(RequestSpan)(
{ logger: Logger },
Expand Down Expand Up @@ -541,23 +546,28 @@ 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:

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

This one is the **`Needs` channel**, not the kernel's marker: `http()`'s runtime
provider depends on the starter's own router port through di, so a composition
that imports the starter without providing the router carries an unmet need, and
`start` — whose `module` parameter accepts only `Scope | Env` outstanding —
refuses it. What prints is that assignability failure, and it 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
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
`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
the shipped runtime declares no needs.
Expand All @@ -577,6 +587,7 @@ 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 @@ -598,6 +609,7 @@ 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: 2 additions & 0 deletions docs/examples/order-temporal-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const orderActivities = TemporalActivities(orderContract)([
]);

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

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

Nothing checks anything yet — declaration is free. The check happens at the
one place a graph becomes running services.
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:

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

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.

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

## The gate: an arity error

Expand Down
45 changes: 45 additions & 0 deletions docs/explanation/modules-and-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,51 @@ 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.

## A need is declared, never absorbed

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
imported.

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

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

`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 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.

`Scope` is the one exemption, and it is forced rather than chosen: nothing can
provide `Scope` — a provider for it is a
[wiring defect](/reference/di/wiring-defects) — so it is never something an
ancestor supplies. `Module.scoped` and `start` discharge it by opening one.

## What it does not defend against

A determined caller can cast — `ctx as any`, a hand-rolled object with the
Expand Down
3 changes: 2 additions & 1 deletion docs/explanation/starters.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ helper in `@btravstack/config` rather than a local copy in each starter.
## The module sugar

Each starter ships a composition-root sugar — `HttpModule(name)({ router,
imports, provides, exports, … })`, `TemporalModule(name)({ contract,
imports, provides, exports, needs, … })`, `TemporalModule(name)({ contract,
activities, workflows, … })`, `AmqpModule(name)({ contract, handlers, … })`.
It is di's own `Module(name)({...})` that also takes the starter's fields:
it appends the starter to `imports`, prepends the router / activities /
Expand All @@ -90,6 +90,7 @@ 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
4 changes: 3 additions & 1 deletion docs/how-to/configure-from-the-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ touching `process.env`. The recipe is one provider.
is a `ConfigInvalid` before anything serves.

```ts
import { Config } from "@btravstack/config";
import { Config, Env } from "@btravstack/config";
import { Module, Port, Provider, type ServiceOf } from "@btravstack/di";

class Database extends Port("Database")<{ readonly query: () => string }> {}
Expand All @@ -48,6 +48,8 @@ const databaseConfig = Config.provider("DatabaseConfig")(
);

export const Persistence = Module("Persistence")({
// Every reader of the environment says so: `start` is what provides `Env`.
needs: [Env],
provides: [
databaseConfig,
Provider(Database)(
Expand Down
Loading
Loading