From 0cfde8c967c2e41d5d02325fa9d2f194faf7906b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 21:56:57 +0200 Subject: [PATCH 1/4] feat(di)!: an import's needs travel without being re-declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NeedsGate now reads a module's OWN providers alone. A port one of them depends on and nothing here satisfies still has to be named in needs — so a provider can never silently receive a service from whoever composed the module, which is what #50 was about. What is dropped is the re-declaration at every hop: an import's own unmet needs are already published in its type, at the imports entry a reader is looking at, and start still refuses a root that has not discharged them. Measured on this repo: 12 of 22 declarations were pure propagation. Dropping them leaves exactly the modules that read the port — DatabaseModule says needs: [Env] because it reads DATABASE_URL, and the persistence modules and slices that import it say nothing. That is ConfigModule.forFeature's shape, reached without a global. The Needs CHANNEL is unchanged and stays wider than what the gate asks a module to declare: everything outstanding, however it got there. --- packages/di/src/module.test-d.ts | 23 ++++++++++++++++++ packages/di/src/module.ts | 41 +++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/di/src/module.test-d.ts b/packages/di/src/module.test-d.ts index 9eb225e0..e19dd097 100644 --- a/packages/di/src/module.test-d.ts +++ b/packages/di/src/module.test-d.ts @@ -194,6 +194,29 @@ describe("Module algebra", () => { }); }); + test("an import's unmet needs travel without being re-declared", () => { + // The other half of the gate, and the reason a `needs` list stays one line + // per FEATURE rather than one per hop. `Orphan` reads `Database` through + // its own provider and declares it; `Importer` only imports `Orphan` and + // declares nothing — the obligation still reaches its channel, and `start` + // (or `Module.build`) is still what refuses a root that has not discharged + // it. Nothing is hidden: `Orphan`'s type says `Database` at the `imports` + // entry a reader is looking at. + const orphan = Module("Orphan")({ + needs: [Database], + provides: [OrderRepositoryProvider], + exports: [OrderRepository], + }); + const importer = Module("Importer")({ + imports: [orphan], + exports: [OrderRepository], + }); + + type Channels = ChannelsOf; + const stillOwesDatabase: Equal = true; + void stillOwesDatabase; + }); + test("declaring a need nothing owes is inert", () => { // `needs` says what this module expects from outside; it does not // manufacture an obligation. `ConfigModule` provides everything it uses, diff --git a/packages/di/src/module.ts b/packages/di/src/module.ts index 1a4b863c..1a59e869 100644 --- a/packages/di/src/module.ts +++ b/packages/di/src/module.ts @@ -162,10 +162,12 @@ type ResolvedExports = /** * What the module still owes: its providers' dependencies and its imports' - * declared needs, minus everything visible to it. Exported because a shaped - * module — a starter's `HttpModule(name)({...})` — re-declares this package's - * gates over its own augmented tuples, the way it already re-declares - * `Exportable`. + * own needs, minus everything visible to it. This is the `Needs` CHANNEL — + * everything outstanding, however it got there — and is deliberately wider + * than what `NeedsGate` makes a module declare, which is its own providers' + * half alone. Exported because a shaped module — a starter's + * `HttpModule(name)({...})` — re-declares this package's gates over its own + * augmented tuples, the way it already re-declares `Exportable`. */ export type Unmet = Exclude< NeedOf | NeedsOfModule, @@ -173,11 +175,22 @@ export type Unmet; /** - * The declaration gate. A port this module owes and did not name in `needs` is - * an error **here**, at the module that owes it, rather than an obligation - * that travels silently to whoever composes it. `needs` is how a module says - * "my composition root supplies this" out loud — the explicit stand-in for - * NestJS's `@Global`, which this package does not have and now does not need. + * The declaration gate. A port **this module's own providers** read, and that + * nothing here satisfies, is an error unless it is named in `needs` — so a + * provider can never silently receive a service from whoever composed the + * module. `needs` is how a module says "my composition root supplies this" + * out loud, the explicit stand-in for NestJS's `@Global`, which this package + * does not have and does not need. + * + * **An import's own unmet needs are NOT this module's to re-declare**, and + * that is the deliberate half. They are already published in the import's + * type — `Module` says so at the `imports` entry a reader is + * looking at — and `start` still refuses a root that has not discharged them, + * so nothing is hidden by leaving them out. What re-declaring them bought was + * one line per module per hop: measured on this repo, 12 of 22 declarations + * were pure propagation, and dropping them leaves exactly the modules that + * actually read the port. `Env` is the one that showed it — six declarations + * in `order-api`, one of them the feature that reads `DATABASE_URL`. * * `Scope` is the one exemption, and it is forced rather than chosen: nothing * can provide `Scope` — a provider for it is a `WiringDefect` — so it is never @@ -203,14 +216,14 @@ export type NeedsGate< I extends readonly AnyModule[], P extends readonly AnyProvider[], N extends readonly AnyPort[], -> = [Unmet] extends [InstanceType | Scope] +> = [Exclude, Available>] extends [InstanceType | Scope] ? unknown : { - // Inline, not a named `Undeclared` alias: an alias prints as - // itself, unreduced, and the reader is left reading their own tuples - // back. Written out, the message ends on the port (measured, both ways). + // Inline, not a named alias: an alias prints as itself, unreduced, and + // the reader is left reading their own tuples back. Written out, the + // message ends on the port (measured, both ways). readonly "UNDECLARED NEEDS — name it in `needs`": Exclude< - Unmet, + Exclude, Available>, InstanceType | Scope >; }; From f4b9697e8a869716ea9691f35e70f3021f8fdee5 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 21:57:01 +0200 Subject: [PATCH 2/4] refactor!: the declaration lands on the feature that reads the port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve declarations go: the three roots, both persistence modules, and the four slices that only inherited what they named. What is left is what actually reads something — DatabaseModule and observability() and each starter for Env, AuditSlice and NotificationsSlice and OrdersSlice and the two stand-in services for Logger, the application modules for their repositories. order-amqp-worker's root keeps needs: [Env], and the reason is the rule working: it provides relayConfig itself, so Env is its own provider's need rather than one inherited from the slices below. Three needs-gate negatives move back to start — the starter's port is owed by an import — and order-application's gains one that separates the two gates: a module whose OWN providers read the repositories, declaring nothing. --- examples/order-amqp-worker/src/module.ts | 6 +-- .../src/needs-gate.test-d.ts | 24 +++++------ .../order-amqp-worker/src/test-fixtures.ts | 1 + examples/order-api/README.md | 3 +- examples/order-api/src/module.ts | 5 --- examples/order-api/src/needs-gate.test-d.ts | 12 +++--- .../order-api/src/slices/customers/module.ts | 4 -- .../order-api/src/slices/orders/module.ts | 10 ++--- examples/order-api/src/test-fixtures.ts | 4 +- .../src/needs-gate.test-d.ts | 42 ++++++++++--------- examples/order-infrastructure/README.md | 3 -- examples/order-infrastructure/src/module.ts | 9 ++-- examples/order-temporal-worker/README.md | 1 - examples/order-temporal-worker/src/module.ts | 5 --- .../src/needs-gate.test-d.ts | 17 ++++---- .../src/slices/billing/module.ts | 5 --- .../src/slices/fulfillment/module.ts | 6 --- .../src/test-fixtures.ts | 4 +- packages/amqp/CLAUDE.md | 15 ++++--- packages/amqp/src/amqp-runtime.test-d.ts | 6 +-- packages/amqp/src/test-fixtures.ts | 4 +- packages/config/README.md | 2 +- packages/core/src/test-fixtures.ts | 2 + packages/http/CLAUDE.md | 15 ++++--- packages/http/README.md | 4 -- packages/http/src/test-fixtures.ts | 9 +--- packages/observability/README.md | 2 - packages/observability/src/test-fixtures.ts | 3 -- packages/temporal/CLAUDE.md | 15 ++++--- packages/temporal/README.md | 2 - packages/temporal/src/test-fixtures.ts | 4 +- 31 files changed, 95 insertions(+), 149 deletions(-) diff --git a/examples/order-amqp-worker/src/module.ts b/examples/order-amqp-worker/src/module.ts index d74d5a06..c45dfdf1 100644 --- a/examples/order-amqp-worker/src/module.ts +++ b/examples/order-amqp-worker/src/module.ts @@ -64,9 +64,9 @@ export const orderHandlers = AmqpHandlers(orderContract)([orderNotifications, or * own vhost. */ export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({ - // The composition root owes the environment and nothing else: `start` is - // what provides `Env`, and every other need in this graph has been - // discharged by a module in the list below. + // This root provides `relayConfig` itself — `OUTBOX_POLL_MS` / + // `OUTBOX_TENANTS` off the environment — so `Env` is its own provider's + // need, not one inherited from the slices below. needs: [Env], contract: orderContract, handlers: orderHandlers, diff --git a/examples/order-amqp-worker/src/needs-gate.test-d.ts b/examples/order-amqp-worker/src/needs-gate.test-d.ts index 99decfec..f837443a 100644 --- a/examples/order-amqp-worker/src/needs-gate.test-d.ts +++ b/examples/order-amqp-worker/src/needs-gate.test-d.ts @@ -60,14 +60,11 @@ const _noRuntime = start(RuntimelessAmqp, options); // primitive rather than `AmqpModule`, since the sugar cannot leave the // handlers out — that is what it is for. // -// Negative, and since di's `needs` gate this one no longer waits for `start`: -// the handlers port is owed here and undeclared, and declaring it is not an -// escape either — `AmqpHandlersPort` is the starter's own and the package -// exports only its TYPE, so an application has nothing to name. Providing the -// handlers is the only way out, which is the point. -// @ts-expect-error — UNDECLARED NEEDS: the starter's handlers port. +// The port is owed by the STARTER, which is an import — so di's declaration +// gate has nothing to say here, and the refusal is the kernel's, on the needs +// channel. That is the division the two gates draw: a module declares what its +// OWN providers read, and an import's needs travel published in its type. const HandlerlessAmqp = Module("HandlerlessAmqp")({ - needs: [Env], imports: [ OrderApplicationModule, OrderPersistenceModule, @@ -77,7 +74,8 @@ const HandlerlessAmqp = Module("HandlerlessAmqp")({ exports: [AmqpRuntime, PlaceOrder, Logger], }); -void HandlerlessAmqp; +// @ts-expect-error — the module's needs channel carries the handlers port, which nothing provides. +const _missingHandlers = start(HandlerlessAmqp, options); // The two real slices, composed into a root that forgets `observability()`. // Neither slice imports it — a subscriber owns no vertical, so `Logger` is the @@ -86,17 +84,15 @@ void HandlerlessAmqp; // including it would leave the negative unable to say which of the two leaked. // Negative, and the one this file exists to add: a slice does NOT shield the // ports its own pieces declare. Composition shields a piece's deps from the -// root; being inside a slice shields nothing — and each slice now says +// root; being inside a slice shields nothing — and each slice says // `needs: [Logger]` out loud, so what reaches this root is a DECLARED -// obligation rather than an inferred one. Either way the root has to answer -// it, and this one does not. -// @ts-expect-error — UNDECLARED NEEDS: Logger, carried in by both slices. +// obligation. It is still the root's to answer, and this one does not — the +// refusal is the kernel's, since `Logger` arrives through an import. const LoggerlessAmqp = AmqpModule("LoggerlessAmqp")({ - needs: [Env], contract: orderContract, handlers: orderHandlers, imports: [NotificationsSlice, AuditSlice], }); -// @ts-expect-error — and the kernel's gate refuses it too: `Logger` is not assignable to `Env | Scope`. +// @ts-expect-error — UNMET NEED: `Logger` is not assignable to `Env | Scope`. const _missingLogger = start(LoggerlessAmqp, options); diff --git a/examples/order-amqp-worker/src/test-fixtures.ts b/examples/order-amqp-worker/src/test-fixtures.ts index 282ec6ef..7a06c931 100644 --- a/examples/order-amqp-worker/src/test-fixtures.ts +++ b/examples/order-amqp-worker/src/test-fixtures.ts @@ -63,6 +63,7 @@ type Serve = ( const tappedAmqp = () => { const lines: Line[] = []; const recording = AmqpModule("RecordingAmqpWorker")({ + // The recording root provides `relayConfig` itself, so `Env` is its own. needs: [Env], contract: orderContract, handlers: orderHandlers, diff --git a/examples/order-api/README.md b/examples/order-api/README.md index b8dbf167..2d95ab39 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -100,7 +100,6 @@ root that is a `Module(...)` which also knows about it: ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], @@ -155,7 +154,7 @@ The root is a list of **slices**. Each one imports the vertical it needs — 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], + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index d7609d92..c2bcad8c 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { contract } from "@btravstack/example-order-api-contract"; import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; @@ -51,10 +50,6 @@ export const orderRouter = HttpRouter(contract)({ * `start` does, once, for the whole process. */ export const OrderApi = HttpModule("OrderApi")({ - // The composition root owes the environment and nothing else: `start` is - // what provides `Env`, and every other need in this graph has been - // discharged by a module in the list below. - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index ae85446e..23133be2 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -57,18 +57,16 @@ const _missingRuntime = start(RuntimelessApi, options); // provider depends on the starter's own router port (the one // `HttpRouter(contract)({ name: Dep }, arm)` provides), so the composition owes it. // -// Since di's `needs` gate that is refused HERE rather than at `start`, and -// declaring it is not an escape: `HttpRouterPort` is the starter's own and -// `@btravstack/http` does not export the value, so an application has nothing -// to name. Providing the router is the only way out, which is the point. -// @ts-expect-error — UNDECLARED NEEDS: the starter's router port. +// It is the KERNEL's gate rather than di's declaration one, and the division +// is the point: the port is owed by `http()`, an IMPORT, and an import's needs +// travel published in its type rather than being re-declared here. const RouterlessApi = Module("RouterlessApi")({ - needs: [Env], imports: [OrdersSlice, CustomersSlice, observability(), http()], exports: [HttpRuntime, Logger], }); -void RouterlessApi; +// @ts-expect-error — the composition needs the router port and nothing provides it. +const _missingRouter = start(RouterlessApi, options); // Positive: a `unit` module rides the same gate — `RequestModule` needs // `Logger`, which the composition root exports, so the fork the kernel opens diff --git a/examples/order-api/src/slices/customers/module.ts b/examples/order-api/src/slices/customers/module.ts index ed397987..dc33cf8b 100644 --- a/examples/order-api/src/slices/customers/module.ts +++ b/examples/order-api/src/slices/customers/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { CustomerApplicationModule } from "@btravstack/example-order-application"; import { CustomerPersistenceModule } from "@btravstack/example-order-infrastructure"; @@ -16,9 +15,6 @@ import { customersController } from "./controller.js"; * modules: one provider reference, so one database. */ export const CustomersSlice = Module("CustomersSlice")({ - // Shorter than the orders slice's by one: `FindCustomer` writes no line, so - // this slice owes the environment and not the logger. - needs: [Env], imports: [CustomerApplicationModule, CustomerPersistenceModule], provides: [customersController], exports: [customersController], diff --git a/examples/order-api/src/slices/orders/module.ts b/examples/order-api/src/slices/orders/module.ts index 7cbfb8e6..df89e8c0 100644 --- a/examples/order-api/src/slices/orders/module.ts +++ b/examples/order-api/src/slices/orders/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; @@ -26,11 +25,10 @@ import { ordersController } from "./controller.js"; * `HttpController` mints the port for you, so there is no class to name. */ export const OrdersSlice = Module("OrdersSlice")({ - // What this slice expects from the root: the environment its persistence - // reads `DATABASE_URL` from, and the logger its interactors write to. - // Neither is the slice's to provide, and both are now stated here rather - // than absorbed from whatever the root happens to hold. - needs: [Env, Logger], + // The controller writes a line itself, so `Logger` is this slice's own + // provider's need. The environment its persistence reads is not: that is + // `DatabaseModule`'s, declared there and inherited here. + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], diff --git a/examples/order-api/src/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index 9987493a..fb82cc2e 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { Env } from "@btravstack/config"; +import type { Env } from "@btravstack/config"; import type { RunningApp, StartOptions } from "@btravstack/core"; import { Module, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import { @@ -80,7 +80,6 @@ const recorderOf = () => { */ const apiWith = (repository: ServiceOf, sink: Sink = () => {}) => HttpModule("StubApi")({ - needs: [Env], router: orderRouter, // The same authenticator as the real root: the contract marks `orders`, so // every composition serving that router owes one. Swapping it out is how a @@ -114,7 +113,6 @@ const recordingApi = () => { const recorder = recorderOf(); return { api: HttpModule("RecordingApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, // `level` pinned rather than bound: `boot`'s `LOG_LEVEL` silences the diff --git a/examples/order-application/src/needs-gate.test-d.ts b/examples/order-application/src/needs-gate.test-d.ts index 26e40610..b54174d9 100644 --- a/examples/order-application/src/needs-gate.test-d.ts +++ b/examples/order-application/src/needs-gate.test-d.ts @@ -29,6 +29,7 @@ import { OrderRepository, PlaceOrder, } from "./index.js"; +import { findOrderProvider, placeOrderProvider } from "./use-cases.js"; const orderRepository = Provider(OrderRepository)({ value: { @@ -65,44 +66,45 @@ const _unwiredCustomers = Module.scoped(CustomerApplicationModule, (ctx) => // Negative, per vertical: the orders repository closes the orders module, and // says nothing about the customers one — a graph that wires the wrong -// vertical's adapter is still rejected. Since di's `needs` gate that is -// refused HERE, at the module with the gap, rather than at `Module.scoped`: -// this root neither provides `CustomerRepository` nor declares it. -// @ts-expect-error — UNDECLARED NEEDS: CustomerRepository. +// vertical's adapter is still rejected. It is `Module.scoped`'s arity gate, +// not di's declaration one: `CustomerRepository` is owed by an IMPORT, and an +// import's needs travel published in its type rather than being re-declared +// by whoever composes it. const MiswiredCustomers = Module("MiswiredCustomers")({ imports: [CustomerApplicationModule], provides: [orderRepository, logger], exports: [FindCustomer], }); -void MiswiredCustomers; +// @ts-expect-error — UNSATISFIED DEPENDENCIES: no CustomerRepository is provided. +const _miswired = Module.scoped(MiswiredCustomers, (ctx) => + ctx.get(FindCustomer).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-0000000000c1"), +); // Negative, the other port of the orders pair: the repository alone does not -// close the module, because `PlaceOrder` writes a line — and again the module -// is where that is said, not the entry point. -// @ts-expect-error — UNDECLARED NEEDS: Logger. +// close the module, because `PlaceOrder` writes a line. const LoglessOrders = Module("LoglessOrders")({ imports: [OrderApplicationModule], provides: [orderRepository], exports: [PlaceOrder, FindOrder], }); -void LoglessOrders; +// @ts-expect-error — UNSATISFIED DEPENDENCIES: no Logger is provided. +const _logless = Module.scoped(LoglessOrders, (ctx) => + ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), +); -// Negative, and the distinction the two gates now draw: DECLARING the logger -// makes the module itself legal — what is left is that nothing supplies it, -// which is `Module.scoped`'s arity gate and not di's declaration one. -const DeclaredLogless = Module("DeclaredLogless")({ - needs: [Logger], - imports: [OrderApplicationModule], - provides: [orderRepository], +// Negative, and the OTHER gate — the distinction the two draw. Here the +// interactors are this module's OWN providers rather than an import's, so +// `OrderRepository` and `Logger` are its to name, and leaving `needs` out is +// refused at the declaration instead of at `Module.scoped`. +// @ts-expect-error — UNDECLARED NEEDS: Logger | OrderRepository. +const UndeclaredOrders = Module("UndeclaredOrders")({ + provides: [placeOrderProvider, findOrderProvider], exports: [PlaceOrder, FindOrder], }); -// @ts-expect-error — UNSATISFIED DEPENDENCIES: no Logger is provided. -const _logless = Module.scoped(DeclaredLogless, (ctx) => - ctx.get(FindOrder).execute(TenantId("acme"), "0199a1e0-0000-7000-8000-000000000001"), -); +void UndeclaredOrders; const WiredOrders = Module("WiredOrders")({ imports: [OrderApplicationModule], diff --git a/examples/order-infrastructure/README.md b/examples/order-infrastructure/README.md index be883aff..ad70fb58 100644 --- a/examples/order-infrastructure/README.md +++ b/examples/order-infrastructure/README.md @@ -168,14 +168,12 @@ const DatabaseModule = Module("Database")({ }); export const OrderPersistenceModule = Module("OrderPersistence")({ - needs: [Env], imports: [DatabaseModule], provides: [orderRepositoryProvider, outboxProvider], exports: [OrderRepository, Outbox], }); export const CustomerPersistenceModule = Module("CustomerPersistence")({ - needs: [Env], imports: [DatabaseModule], provides: [customerRepositoryProvider], exports: [CustomerRepository], @@ -196,7 +194,6 @@ vertical it serves and the graph is closed: ```ts const AppModule = Module("App")({ - needs: [Env], imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [PlaceOrder, FindOrder], }); diff --git a/examples/order-infrastructure/src/module.ts b/examples/order-infrastructure/src/module.ts index 5a334d8c..42ce17cd 100644 --- a/examples/order-infrastructure/src/module.ts +++ b/examples/order-infrastructure/src/module.ts @@ -22,6 +22,9 @@ import { outboxProvider } from "./prisma-outbox.js"; * declared for the same reason every reader of the environment declares it. */ const DatabaseModule = Module("Database")({ + // The feature that reads the environment is the one that declares it — and + // the only one: importers of this module inherit the obligation without + // restating it. needs: [Env], provides: [databaseConfig, orderDatabaseProvider], exports: [OrderDatabase], @@ -39,9 +42,6 @@ const DatabaseModule = Module("Database")({ * the connection with the customers vertical did not spend it. */ export const OrderPersistenceModule = Module("OrderPersistence")({ - // `Env` again: a need travels only as far as the module that declares it, - // and importing `DatabaseModule` makes its unmet one this module's to state. - needs: [Env], imports: [DatabaseModule], provides: [orderRepositoryProvider, outboxProvider], exports: [OrderRepository, Outbox], @@ -54,9 +54,6 @@ export const OrderPersistenceModule = Module("OrderPersistence")({ * database, not two — the diamond that makes splitting the layer free. */ export const CustomerPersistenceModule = Module("CustomerPersistence")({ - // `Env` again: a need travels only as far as the module that declares it, - // and importing `DatabaseModule` makes its unmet one this module's to state. - needs: [Env], imports: [DatabaseModule], provides: [customerRepositoryProvider], exports: [CustomerRepository], diff --git a/examples/order-temporal-worker/README.md b/examples/order-temporal-worker/README.md index be165001..c59b81f9 100644 --- a/examples/order-temporal-worker/README.md +++ b/examples/order-temporal-worker/README.md @@ -82,7 +82,6 @@ export const orderActivities = TemporalActivities(orderContract)([ ]); export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { diff --git a/examples/order-temporal-worker/src/module.ts b/examples/order-temporal-worker/src/module.ts index 7f292435..f9a2c3ca 100644 --- a/examples/order-temporal-worker/src/module.ts +++ b/examples/order-temporal-worker/src/module.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { orderContract } from "@btravstack/example-order-temporal-contract"; import { observability } from "@btravstack/observability"; import { TemporalActivities, TemporalModule } from "@btravstack/temporal"; @@ -52,10 +51,6 @@ export const orderActivities = TemporalActivities(orderContract)([fulfillOrder, * has two arms. */ export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - // The composition root owes the environment and nothing else: `start` is - // what provides `Env`, and every other need in this graph has been - // discharged by a module in the list below. - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js") }, diff --git a/examples/order-temporal-worker/src/needs-gate.test-d.ts b/examples/order-temporal-worker/src/needs-gate.test-d.ts index 95625320..dea68822 100644 --- a/examples/order-temporal-worker/src/needs-gate.test-d.ts +++ b/examples/order-temporal-worker/src/needs-gate.test-d.ts @@ -72,14 +72,10 @@ const _noRuntime = start(RuntimelessTemporal, options); // `temporal()` primitive rather than `TemporalModule`, since the sugar cannot // leave the activities out — that is what it is for. // -// Negative, and since di's `needs` gate this one no longer waits for `start`: -// the composed activities port is owed here and undeclared, and declaring it -// is not an escape either — the port is the starter's own and only its TYPE is -// exported, so an application has nothing to name. Providing the activities is -// the only way out, which is the point. -// @ts-expect-error — UNDECLARED NEEDS: the starter's activities port. +// The KERNEL's gate rather than di's declaration one: the port is owed by +// `temporal()`, an IMPORT, and an import's needs travel published in its type +// rather than being re-declared here. const ActivitylessTemporal = Module("ActivitylessTemporal")({ - needs: [Env], imports: [ temporal({ contract: orderContract, @@ -89,16 +85,19 @@ const ActivitylessTemporal = Module("ActivitylessTemporal")({ exports: [TemporalRuntime], }); -void ActivitylessTemporal; +// @ts-expect-error — UNMET NEED: the module's needs channel carries the activities port. +const _missingActivities = start(ActivitylessTemporal, options); // The real `fulfillOrder` piece, composed into a slice that forgets // `FulfillmentModule`: the piece's own `deps` (`PlaceOrder`, // `OrderRepository`, `StockService`, `ShippingService`) are real ports, and // only the first two are met here. +// The slice's OWN provider is what reads them, so this one IS di's declaration +// gate — the distinction the two negatives above draw. // @ts-expect-error — UNDECLARED NEEDS: StockService | ShippingService, which // `FulfillmentModule` would have provided. const FulfillmentlessSlice = Module("FulfillmentlessSlice")({ - needs: [Env, Logger], + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [fulfillOrder], exports: [fulfillOrder], diff --git a/examples/order-temporal-worker/src/slices/billing/module.ts b/examples/order-temporal-worker/src/slices/billing/module.ts index dcf9896c..6d63c9ee 100644 --- a/examples/order-temporal-worker/src/slices/billing/module.ts +++ b/examples/order-temporal-worker/src/slices/billing/module.ts @@ -1,5 +1,4 @@ import { Module } from "@btravstack/di"; -import { Logger } from "@btravstack/observability"; import { BillingModule } from "../../billing.js"; import { chargeOrder } from "./activities.js"; @@ -14,10 +13,6 @@ import { chargeOrder } from "./activities.js"; * `TemporalWorkflowActivities` mints the port from the contract key. */ export const BillingSlice = Module("BillingSlice")({ - // What this slice expects from the root: the logger its stand-in payment - // service writes to. `BillingModule` is imported and owes it too, and a - // need travels only as far as the module that declares it. - needs: [Logger], imports: [BillingModule], provides: [chargeOrder], exports: [chargeOrder], diff --git a/examples/order-temporal-worker/src/slices/fulfillment/module.ts b/examples/order-temporal-worker/src/slices/fulfillment/module.ts index 72aebc43..f5b3b884 100644 --- a/examples/order-temporal-worker/src/slices/fulfillment/module.ts +++ b/examples/order-temporal-worker/src/slices/fulfillment/module.ts @@ -1,8 +1,6 @@ -import { Env } from "@btravstack/config"; import { Module } from "@btravstack/di"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; -import { Logger } from "@btravstack/observability"; import { FulfillmentModule } from "../../fulfillment.js"; import { fulfillOrder } from "./activities.js"; @@ -17,10 +15,6 @@ import { fulfillOrder } from "./activities.js"; * `TemporalWorkflowActivities` mints the port from the contract key. */ export const FulfillmentSlice = Module("FulfillmentSlice")({ - // The environment its persistence reads `DATABASE_URL` from, and the logger - // the interactors and the stand-in services write to. Both come from the - // root; neither is this slice's to provide. - needs: [Env, Logger], imports: [OrderApplicationModule, OrderPersistenceModule, FulfillmentModule], provides: [fulfillOrder], exports: [fulfillOrder], diff --git a/examples/order-temporal-worker/src/test-fixtures.ts b/examples/order-temporal-worker/src/test-fixtures.ts index c3865590..57941552 100644 --- a/examples/order-temporal-worker/src/test-fixtures.ts +++ b/examples/order-temporal-worker/src/test-fixtures.ts @@ -1,4 +1,4 @@ -import { Env, type ConfigInvalid } from "@btravstack/config"; +import type { ConfigInvalid, Env } from "@btravstack/config"; import type { RunningApp } from "@btravstack/core"; import { Module, Provider, type Scope, type ServiceOf } from "@btravstack/di"; import { @@ -87,7 +87,6 @@ type Serve = ( */ const rootWith = (fulfillment: typeof FulfillmentModule, sink: Sink) => Module("StubTemporal")({ - needs: [Env], imports: [OrderApplicationModule, OrderPersistenceModule, fulfillment, observability({ sink })], exports: [PlaceOrder, OrderRepository, StockService, ShippingService, Logger], }); @@ -239,7 +238,6 @@ export const it = test.extend({ // `orderActivities`'s `deps` are the two pieces' PORTS, and nothing // discharges them unless something in this tree does. const worker = TemporalModule("StubTemporalWorker")({ - needs: [Env], contract, activities: orderActivities, workflows: { workflowBundle }, diff --git a/packages/amqp/CLAUDE.md b/packages/amqp/CLAUDE.md index c552380f..6afeb501 100644 --- a/packages/amqp/CLAUDE.md +++ b/packages/amqp/CLAUDE.md @@ -83,12 +83,15 @@ key)`, both of which cast it to the typed alias), so there is nothing a hand-declared port of another id leaves the starter's need unmet, so `start` refuses the module. - It also takes **`needs`**, forwarded to di's own — what this root expects - from outside, which is `[Env]` for every real deployment since the starter - binds its configuration from the environment and `start` is what provides - it. The sugar **re-declares di's `NeedsGate`** over its augmented tuples, so - a root that forgets one is refused at THIS call rather than slipping past - into `start`; see `packages/di/CLAUDE.md`'s **Module visibility**. + It also takes **`needs`**, forwarded to di's own — what this root's OWN + providers expect from outside. The starter's `Env` is not among them: the + starter is an import, and an import's needs travel without being restated. A + root that provides a config provider of its own does declare it — + `examples/order-amqp-worker` says `needs: [Env]` for `relayConfig`. The sugar + **re-declares di's `NeedsGate`** over its augmented tuples, so a root whose + own provider owes a port it does not name is refused at THIS call rather than + slipping past into `start`; see `packages/di/CLAUDE.md`'s **Module + visibility**. A third call composes **pieces** instead of a record: `AmqpHandlers(contract)([piece, piece, ...])`, one piece per diff --git a/packages/amqp/src/amqp-runtime.test-d.ts b/packages/amqp/src/amqp-runtime.test-d.ts index 5d9c545f..c3f9665f 100644 --- a/packages/amqp/src/amqp-runtime.test-d.ts +++ b/packages/amqp/src/amqp-runtime.test-d.ts @@ -81,10 +81,10 @@ AmqpModule("Other")({ // Negative: a hand-declared port of another id is not the starter's — the // starter needs ITS port, so a root providing a different one still owes it. // Since the `needs` gate that is refused HERE, at the module that owes it, -// rather than at `start`: the port is undeclared, and declaring it is not the -// escape either — the module would then have to be handed one. +// and `start` refuses the module. It is the KERNEL's gate rather than di's +// declaration one: the port is owed by the STARTER, an import, and an +// import's needs travel without the importer re-declaring them. class NoHandlers extends Port("NoHandlers")> {} -// @ts-expect-error -- UNDECLARED NEEDS: the starter's handlers port is still owed const Unmet = Module("Unmet")({ imports: [amqp({ contract: pinContract })], provides: [Provider(NoHandlers)({ value: {} })], diff --git a/packages/amqp/src/test-fixtures.ts b/packages/amqp/src/test-fixtures.ts index 56b0b55b..6853d039 100644 --- a/packages/amqp/src/test-fixtures.ts +++ b/packages/amqp/src/test-fixtures.ts @@ -8,7 +8,7 @@ import { } from "@amqp-contract/contract"; import { it as amqpIt } from "@amqp-contract/testing"; import type { AmqpTestFixtures } from "@amqp-contract/testing/extension"; -import { Env, type ConfigInvalid } from "@btravstack/config"; +import type { ConfigInvalid } from "@btravstack/config"; import { currentUnit, type RunningApp, type UnitRecord } from "@btravstack/core"; import { Module, Port, Provider } from "@btravstack/di"; import { bootFixture, type Boot } from "@btravstack/testing"; @@ -63,7 +63,6 @@ type EchoProvider = Provider, never, Greeting>; */ const consuming = (url: string, handlers: EchoProvider, connectTimeoutMs?: number) => AmqpModule("Consuming")({ - needs: [Env], contract: echoContract, handlers, url, @@ -316,7 +315,6 @@ export const it: TestAPI = amqpIt.extend { const app = boot( AmqpModule("Sliced")({ - needs: [Env], contract: slicedContract, handlers: slices.handlers, url: amqpConnectionUrl, diff --git a/packages/config/README.md b/packages/config/README.md index 70ace514..206f6bbc 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -22,7 +22,7 @@ Not yet published: this repository has not cut a release yet. ## A slice of the environment, as a port ```ts -import { Config } from "@btravstack/config"; +import { Config, Env } from "@btravstack/config"; import { Module, Provider } from "@btravstack/di"; const databaseConfig = Config.provider("DatabaseConfig")( diff --git a/packages/core/src/test-fixtures.ts b/packages/core/src/test-fixtures.ts index 8612473f..60cf7a1d 100644 --- a/packages/core/src/test-fixtures.ts +++ b/packages/core/src/test-fixtures.ts @@ -63,6 +63,8 @@ export type ConfiguredApp = { const settingsApp = () => Module("ConfigFixtureApp")({ + // This module provides the config provider itself, so `Env` is its own + // provider's need rather than one inherited from an import. needs: [Env], imports: [testRuntime().module], provides: [Config.provider(Settings)(settingsSchema)], diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 9fe673a3..07d07527 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -307,12 +307,15 @@ InstanceType> & { readonly port: PortClassOf every caller, so the leaf answers `401` instead of serving unprotected. Unreachable while the two halves agree, which is exactly why it is there. - It also takes **`needs`**, forwarded to di's own — what this root expects - from outside, which is `[Env]` for every real deployment since the starter - binds its configuration from the environment and `start` is what provides - it. The sugar **re-declares di's `NeedsGate`** over its augmented tuples, so - a root that forgets one is refused at THIS call rather than slipping past - into `start`; see `packages/di/CLAUDE.md`'s **Module visibility**. + It also takes **`needs`**, forwarded to di's own — what this root's OWN + providers expect from outside. The starter's `Env` is not among them: the + starter is an import, and an import's needs travel without being restated. A + root that provides a config provider of its own does declare it — + `examples/order-amqp-worker` says `needs: [Env]` for `relayConfig`. The sugar + **re-declares di's `NeedsGate`** over its augmented tuples, so a root whose + own provider owes a port it does not name is refused at THIS call rather than + slipping past into `start`; see `packages/di/CLAUDE.md`'s **Module + visibility**. When `hasMarked(contract)` answers true, `AuthenticatorPort` joins the provider's deps record under the **namespaced** diff --git a/packages/http/README.md b/packages/http/README.md index a41cfc13..744e58e3 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -22,7 +22,6 @@ has not cut a release yet. ## A worked example ```ts -import { Env } from "@btravstack/config"; import { runMain } from "@btravstack/core"; import { HttpModule, HttpRouter } from "@btravstack/http"; import { P } from "unthrown"; @@ -83,7 +82,6 @@ const ordersRouter = HttpRouter(ordersContract)( // the router on the starter's own port (a process serves one router, so // there is nothing to name), exports the runtime port — nothing else to spell. const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, imports: [Application, Persistence], }); @@ -204,7 +202,6 @@ a type carrying the marker's phantom `unique symbol`, which a consumer's `.d.ts` cannot name. ```ts -import { Env } from "@btravstack/config"; import { authenticated } from "@btravstack/contract"; import { HttpModule, Unauthenticated } from "@btravstack/http"; import { oc, type } from "@orpc/contract"; @@ -265,7 +262,6 @@ const ordersRouter = HttpRouter({ orders: ordersContract })( ); const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [Application, Persistence], diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 484d0886..d1759a67 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -25,7 +25,7 @@ import { once } from "node:events"; import { createServer } from "node:http"; import { connect, type Socket } from "node:net"; -import { Env, type ConfigInvalid, type Environment } from "@btravstack/config"; +import type { ConfigInvalid, Environment } from "@btravstack/config"; import { authenticated } from "@btravstack/contract"; import { currentUnit, type RunningApp } from "@btravstack/core"; import { Module, Port, Provider, type ServiceOf } from "@btravstack/di"; @@ -61,7 +61,6 @@ type Handler = ServiceOf; */ const appOf = (handler: Handler, port = 0, securityHeaders?: HttpOptions["securityHeaders"]) => Module("App")({ - needs: [Env], imports: [ httpModule( { @@ -160,7 +159,6 @@ const slicedRouter = HttpRouter(slicedContract)({ /** `HttpModule` over the composed router, mirroring `rpcAppOf`. */ const rpcSlicedAppOf = () => HttpModule("RpcSlicedApp")({ - needs: [Env], router: slicedRouter, port: 0, hostname: "127.0.0.1", @@ -251,7 +249,6 @@ const authedPositionalRouter = AuthedRouter(authedContract)( /** `HttpModule` over the protected router, with the authenticator the router now needs. */ const rpcAuthedAppOf = () => HttpModule("RpcAuthedApp")({ - needs: [Env], router: authedRouter, port: 0, hostname: "127.0.0.1", @@ -281,7 +278,6 @@ const rootMarkedRouter = AuthedRouter(rootMarkedContract)({ const rpcRootMarkedAppOf = () => HttpModule("RpcRootMarkedApp")({ - needs: [Env], router: rootMarkedRouter, port: 0, hostname: "127.0.0.1", @@ -324,7 +320,6 @@ const strayRouter = HttpRouter(greetingContract)( /** The starter as an application uses it: `HttpModule` sugar over a router provider. */ const rpcAppOf = (prefix?: `/${string}`, stray = false) => HttpModule("RpcApp")({ - needs: [Env], router: stray ? strayRouter : greetingRouter, port: 0, hostname: "127.0.0.1", @@ -348,7 +343,6 @@ const corsRouter = HttpRouter(corsContract)({ /** The same starter shape as `rpcAppOf`, with oRPC's CORS plugin configured. */ const rpcWithCorsAppOf = () => HttpModule("RpcWithCorsApp")({ - needs: [Env], router: corsRouter, port: 0, hostname: "127.0.0.1", @@ -363,7 +357,6 @@ const configuredAppOf = (options: { readonly port?: number; readonly hostname?: let bound: ServiceOf | undefined; return { module: Module("ConfiguredApp")({ - needs: [Env], imports: [httpModule(options, Provider(HttpHandler)({ value: noop }))], provides: [ Provider(BoundConfig)( diff --git a/packages/observability/README.md b/packages/observability/README.md index 18174541..524b55a5 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -21,7 +21,6 @@ repository has not cut a release yet. ## A worked example ```ts -import { Env } from "@btravstack/config"; import { runMain } from "@btravstack/core"; import { Logger, @@ -49,7 +48,6 @@ const placeOrder = Provider(PlaceOrder)( // once — `verbose` is a startup failure naming the variable, not a silent // fallback to `info`. const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], diff --git a/packages/observability/src/test-fixtures.ts b/packages/observability/src/test-fixtures.ts index 5beb66c6..c2cf9a1b 100644 --- a/packages/observability/src/test-fixtures.ts +++ b/packages/observability/src/test-fixtures.ts @@ -1,4 +1,3 @@ -import { Env } from "@btravstack/config"; import { RuntimePort, type Runtime } from "@btravstack/core"; import { Module, Port, Provider } from "@btravstack/di"; import { bootFixture, testRuntime, TestRuntimePort, type Boot } from "@btravstack/testing"; @@ -145,7 +144,6 @@ export const it = test.extend({ await use( (tenantId, options = {}) => Module("TenantApp")({ - needs: [Env], imports: [tenantRuntimeModule(tenantId), observability(options)], exports: [Logger, TenantRuntime], }) as unknown as Module, @@ -159,7 +157,6 @@ export const it = test.extend({ return { runtime, module: Module("ObservabilityApp")({ - needs: [Env], imports: [runtime.module, observability(options)], provides: [Provider(Greeting)({ value: { text: "hello" } })], exports: [Logger, LoggerConfig, Greeting, TestRuntimePort], diff --git a/packages/temporal/CLAUDE.md b/packages/temporal/CLAUDE.md index 3fe062fa..7f53075b 100644 --- a/packages/temporal/CLAUDE.md +++ b/packages/temporal/CLAUDE.md @@ -69,12 +69,15 @@ ActivitiesPortOf`, so the next call is di's `(deps, arm)` unchanged and `examples/order-temporal-worker/src/slices/billing/activities.ts` are the worked examples (no port class, no name, anywhere). - It also takes **`needs`**, forwarded to di's own — what this root expects - from outside, which is `[Env]` for every real deployment since the starter - binds its configuration from the environment and `start` is what provides - it. The sugar **re-declares di's `NeedsGate`** over its augmented tuples, so - a root that forgets one is refused at THIS call rather than slipping past - into `start`; see `packages/di/CLAUDE.md`'s **Module visibility**. + It also takes **`needs`**, forwarded to di's own — what this root's OWN + providers expect from outside. The starter's `Env` is not among them: the + starter is an import, and an import's needs travel without being restated. A + root that provides a config provider of its own does declare it — + `examples/order-amqp-worker` says `needs: [Env]` for `relayConfig`. The sugar + **re-declares di's `NeedsGate`** over its augmented tuples, so a root whose + own provider owes a port it does not name is refused at THIS call rather than + slipping past into `start`; see `packages/di/CLAUDE.md`'s **Module + visibility**. A third call composes **pieces** instead of a record: `TemporalActivities(contract)([piece, piece, ...])`, one piece per diff --git a/packages/temporal/README.md b/packages/temporal/README.md index 351dbea3..32195fbc 100644 --- a/packages/temporal/README.md +++ b/packages/temporal/README.md @@ -21,7 +21,6 @@ this repository has not cut a release yet. ## A worked example ```ts -import { Env } from "@btravstack/config"; import { runMain } from "@btravstack/core"; import { TemporalActivities, TemporalModule } from "@btravstack/temporal"; import { P } from "unthrown"; @@ -51,7 +50,6 @@ const orderActivities = TemporalActivities(contract)( // The composition root: a di module, plus the contract, the activities // provider and the workflow source — and nothing else to know. const OrderWorker = TemporalModule("OrderWorker")({ - needs: [Env], contract, activities: orderActivities, workflows: { diff --git a/packages/temporal/src/test-fixtures.ts b/packages/temporal/src/test-fixtures.ts index becf8f51..da80d9c0 100644 --- a/packages/temporal/src/test-fixtures.ts +++ b/packages/temporal/src/test-fixtures.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; -import { Env, type ConfigInvalid, type Environment } from "@btravstack/config"; +import type { ConfigInvalid, Environment } from "@btravstack/config"; import { currentUnit, type RunningApp, type UnitRecord } from "@btravstack/core"; import { Port, Provider, type ServiceOf } from "@btravstack/di"; import { createNamespace } from "@btravstack/internal-test-infra/namespace"; @@ -364,7 +364,6 @@ export type TemporalFixtures = { const compose = (server: Server, boot: Boot, options: BootOptions) => { const taskQueue = nextTaskQueue(); const worker = TemporalModule("Worker")({ - needs: [Env], contract: { ...echoContract, taskQueue }, activities: options.activities ?? echoing, workflows: options.workflows ?? echoWorkflows, @@ -454,7 +453,6 @@ export const it = test.extend({ const taskQueue = nextTaskQueue(); const app: App = boot( TemporalModule("Sliced")({ - needs: [Env], contract: withTaskQueue(slicedContract, taskQueue), activities: slices.activities, workflows: echoWorkflows, From 149410a5a4d90311cb5745b6ab74ac9c7a3dbd2a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 21:57:06 +0200 Subject: [PATCH 3/4] docs: a need travels from the feature that reads it The rule in packages/di/CLAUDE.md, modules-and-privacy, compile-time-wiring and reference/di/modules, the needs note on the three starter sugars, the slices bullet and the gate inventory in the root CLAUDE.md, and 38 samples that were declaring what they only inherited. The two example pages describing a starter-port omission as di's declaration gate go back to describing it as start's needs channel, which is what it is again: the port is owed by an import. --- .changeset/declared-module-needs.md | 19 ++++-- CLAUDE.md | 46 +++++++------- README.md | 2 - docs/examples/order-amqp-worker.md | 27 ++++----- docs/examples/order-api.md | 28 ++++----- docs/examples/order-temporal-worker.md | 2 - docs/explanation/compile-time-wiring.md | 15 ++--- docs/explanation/modules-and-privacy.md | 60 +++++++++++++------ docs/explanation/starters.md | 1 - docs/how-to/log-and-correlate.md | 3 - docs/how-to/open-a-per-request-scope.md | 1 - docs/how-to/protect-a-procedure.md | 1 - docs/how-to/run-a-temporal-worker.md | 3 - docs/how-to/serve-orpc-over-http.md | 2 - .../how-to/split-a-router-into-controllers.md | 4 +- docs/how-to/split-a-worker-into-slices.md | 2 - docs/how-to/test-an-application.md | 1 - docs/index.md | 2 - docs/reference/config.md | 2 +- docs/reference/di/modules.md | 28 +++++---- docs/reference/http.md | 1 - docs/reference/observability.md | 1 - docs/reference/temporal.md | 1 - docs/tutorial/getting-started.md | 2 - docs/tutorial/second-runtime.md | 2 - packages/di/CLAUDE.md | 49 ++++++++++----- 26 files changed, 160 insertions(+), 145 deletions(-) diff --git a/.changeset/declared-module-needs.md b/.changeset/declared-module-needs.md index 014daaa1..12509ef2 100644 --- a/.changeset/declared-module-needs.md +++ b/.changeset/declared-module-needs.md @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 44e1d7f1..f141b86b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 78959624..c5f48f67 100644 --- a/README.md +++ b/README.md @@ -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], }); diff --git a/docs/examples/order-amqp-worker.md b/docs/examples/order-amqp-worker.md index 4cddb7dc..1ec6ff91 100644 --- a/docs/examples/order-amqp-worker.md +++ b/docs/examples/order-amqp-worker.md @@ -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, @@ -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 diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index b2075fdc..9129fe76 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -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()], @@ -371,7 +370,7 @@ happens to depend on: 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], + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], @@ -546,27 +545,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 @@ -587,7 +581,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], @@ -609,7 +602,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, diff --git a/docs/examples/order-temporal-worker.md b/docs/examples/order-temporal-worker.md index dcf9ca22..271ae8ed 100644 --- a/docs/examples/order-temporal-worker.md +++ b/docs/examples/order-temporal-worker.md @@ -87,7 +87,6 @@ export const orderActivities = TemporalActivities(orderContract)([ ]); export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { @@ -262,7 +261,6 @@ billing is never swapped: ```ts const worker = TemporalModule("StubTemporalWorker")({ - needs: [Env], contract, activities: orderActivities, workflows: { workflowBundle }, diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index a4a59d18..76f9dd9e 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -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 @@ -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. diff --git a/docs/explanation/modules-and-privacy.md b/docs/explanation/modules-and-privacy.md index 1787faf7..477c1eb8 100644 --- a/docs/explanation/modules-and-privacy.md +++ b/docs/explanation/modules-and-privacy.md @@ -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")({ @@ -73,25 +73,47 @@ 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. +manufacture an 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 diff --git a/docs/explanation/starters.md b/docs/explanation/starters.md index 5246008f..5f2d84eb 100644 --- a/docs/explanation/starters.md +++ b/docs/explanation/starters.md @@ -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], diff --git a/docs/how-to/log-and-correlate.md b/docs/how-to/log-and-correlate.md index d7a77878..c7063d73 100644 --- a/docs/how-to/log-and-correlate.md +++ b/docs/how-to/log-and-correlate.md @@ -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], @@ -251,7 +249,6 @@ values: const lines: Line[] = []; const RecordingApi = HttpModule("RecordingApi")({ - needs: [Env], router: orderRouter, imports: [ OrderApplicationModule, diff --git a/docs/how-to/open-a-per-request-scope.md b/docs/how-to/open-a-per-request-scope.md index 7cb7038d..7090b8b7 100644 --- a/docs/how-to/open-a-per-request-scope.md +++ b/docs/how-to/open-a-per-request-scope.md @@ -131,7 +131,6 @@ the last line of the error: ```ts const UnloggedApi = Module("UnloggedApi")({ - needs: [Env], imports: [ OrderApplicationModule, OrderPersistenceModule, diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index bfd5dde2..fa2dc937 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -251,7 +251,6 @@ answer per process. ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], diff --git a/docs/how-to/run-a-temporal-worker.md b/docs/how-to/run-a-temporal-worker.md index e7d72aa1..c7a7c38f 100644 --- a/docs/how-to/run-a-temporal-worker.md +++ b/docs/how-to/run-a-temporal-worker.md @@ -139,7 +139,6 @@ may re-run has to answer the same both times. ## Step 2 — the composition root ```ts -import { Env } from "@btravstack/config"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; import { orderContract } from "@btravstack/example-order-temporal-contract"; @@ -152,7 +151,6 @@ import { BillingModule } from "./billing.js"; import { FulfillmentModule } from "./fulfillment.js"; export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { @@ -217,7 +215,6 @@ environment beats default, per field: ```ts export const Pinned = TemporalModule("OrderTemporalWorkerLocal")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index 1cfe5dc1..f1c7224a 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -171,7 +171,6 @@ authenticator below. ## Step 3 — the composition root ```ts -import { Env } from "@btravstack/config"; import { OrderApplicationModule } from "@btravstack/example-order-application"; import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure"; import { HttpModule } from "@btravstack/http"; @@ -181,7 +180,6 @@ import { bearerAuthenticator } from "./authenticator.js"; import { ordersRouter } from "./router.js"; export const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index f60a81ea..630ea0a2 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -172,7 +172,7 @@ same privacy di already gives any provider: 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], + needs: [Logger], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], exports: [ordersController], @@ -209,7 +209,6 @@ owns: ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], @@ -247,7 +246,6 @@ export const ordersRouter = HttpRouter(contract.orders)( ); export const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, observability()], diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index 72d24408..b8383033 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -119,8 +119,6 @@ export const NotificationsSlice = Module("NotificationsSlice")({ }); export const BillingSlice = Module("BillingSlice")({ - // What the slice expects from the root, named rather than absorbed. - needs: [Logger], imports: [BillingModule], provides: [chargeOrder], exports: [chargeOrder], diff --git a/docs/how-to/test-an-application.md b/docs/how-to/test-an-application.md index 58f543bb..06255728 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -138,7 +138,6 @@ string. Compose the root's own shape with a recording sink, and boot that: const lines: Line[] = []; const recordingApi = HttpModule("RecordingApi")({ - needs: [Env], router: orderRouter, // The same authenticator as the real root: the contract marks `orders`, so // every composition serving that router owes one. diff --git a/docs/index.md b/docs/index.md index 9efed2ab..5a654d47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,7 +38,6 @@ composition root, and one call. The example itself composes that slice and a [controllers](/how-to/split-a-router-into-controllers). ```ts -import { Env } from "@btravstack/config"; import { authenticated } from "@btravstack/contract"; import { runMain } from "@btravstack/core"; import { HttpModule } from "@btravstack/http"; @@ -111,7 +110,6 @@ const ordersRouter = HttpRouter(ordersContract)( // The composition root. The runtime is a service of this module. const OrdersApi = HttpModule("OrdersApi")({ - needs: [Env], router: ordersRouter, authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule], diff --git a/docs/reference/config.md b/docs/reference/config.md index b86600d5..5f9f94e5 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -139,7 +139,7 @@ The port is built with the rest of the graph, so a bad environment is a modeled startup `Err` in the module's own error channel, still typed. ```ts -import { Config } from "@btravstack/config"; +import { Config, Env } from "@btravstack/config"; import { Module, Port, Provider } from "@btravstack/di"; class Database extends Port("Database")<{ readonly url: string }> {} diff --git a/docs/reference/di/modules.md b/docs/reference/di/modules.md index 40e1a13c..8568c1b5 100644 --- a/docs/reference/di/modules.md +++ b/docs/reference/di/modules.md @@ -34,12 +34,12 @@ const Persistence = Module("Persistence")({ All four lists are optional and default to empty. -| List | Contents | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `imports` | Modules whose **exports** become visible inside this one. A diamond — two imports that both import a third — is fine: providers are de-duplicated by reference at build time, so the shared module's services construct once. | -| `provides` | This module's own providers. A provider here may depend on anything **available** in this module: ports provided here, plus ports exported by the imports. Order within the list does not matter for correctness — dependency order is computed at build time — but it is what makes error selection deterministic when several fail at once. | -| `needs` | The ports this module expects a composition root to supply — what it depends on and does not satisfy itself. Declaring one does not make it available inside the module and does not manufacture an obligation: it is permission for the need to travel outward. Anything the module owes and does not name here is a compile error **at this call** (below). `Scope` is exempt — nothing can provide it. | -| `exports` | The ports outside code may see. Each entry must be an **available port** — provided here, or exported by an import — a **provider for one**, which is normalised to `provider.port`, or an **imported module**, a whole-module re-export forwarding that module's own `exports` (never its internals). Anything else is a compile error at the declaration. | +| List | Contents | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `imports` | Modules whose **exports** become visible inside this one. A diamond — two imports that both import a third — is fine: providers are de-duplicated by reference at build time, so the shared module's services construct once. | +| `provides` | This module's own providers. A provider here may depend on anything **available** in this module: ports provided here, plus ports exported by the imports. Order within the list does not matter for correctness — dependency order is computed at build time — but it is what makes error selection deterministic when several fail at once. | +| `needs` | The ports **this module's own providers** expect a composition root to supply. Declaring one does not make it available inside the module and does not manufacture an obligation: it is permission for the need to travel outward. Anything this module's providers owe and it does not name here is a compile error **at this call** (below). An **import's** own needs travel without being restated — they are already in that import's type. `Scope` is exempt: nothing can provide it. | +| `exports` | The ports outside code may see. Each entry must be an **available port** — provided here, or exported by an import — a **provider for one**, which is normalised to `provider.port`, or an **imported module**, a whole-module re-export forwarding that module's own `exports` (never its internals). Anything else is a compile error at the declaration. | Exporting a provider means exactly what exporting its port class means — same `Exports` channel, same gates — and it is the only spelling available when the @@ -58,11 +58,11 @@ flat map at runtime, unnameable through the built `Context`'s type. `Module`: -| Channel | Computed as | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | -| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | -| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies travels outward — **provided the module named it in `needs`** — until some module satisfies it, or is refused at the entry point by the [`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). `Scope`, once introduced by a resourceful provider, travels the same way without being declared, and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | +| Channel | Computed as | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Exports` | The union of exported ports' instance types, whole-module re-exports contributing their own `Exports`. This becomes the `Context` channel an entry point hands back. | +| `E` | Every way construction can fail: the union of all providers' error channels, here and in every import, transitively. | +| `Needs` | Everything still unmet: the union of all providers' needs and all imports' needs, **minus** what is available here. A dependency satisfied by a sibling provider or an import's export disappears from `Needs`; one nothing supplies travels outward — declared by the module whose own providers read it, then inherited by importers without being restated — until some module satisfies it, or is refused at the entry point by the [`UNSATISFIED DEPENDENCIES` gate](/reference/di/entry-points#the-gate). `Scope`, once introduced by a resourceful provider, travels the same way without being declared, and is discharged only by `Module.scoped`, `Module.forkScope` or `start`. | ## The declaration gate @@ -84,6 +84,12 @@ Property '"UNDECLARED NEEDS — name it in `needs`"' is missing in type '{ readonly "UNDECLARED NEEDS — name it in `needs`": Logger; }'. ``` +The gate reads **this module's own providers only**. A module that merely +imports `Slice` restates nothing — the obligation is in `Slice`'s type, and the +entry point is still what refuses a root that has not discharged it. So the +declaration lands on the feature that reads the port, once, rather than on every +module between it and the root. + This is why a slice directory can be read on its own: it names the ports that come from outside without naming who supplies them, so the same slice still composes into any root that answers them. See diff --git a/docs/reference/http.md b/docs/reference/http.md index aa403945..470e7228 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -74,7 +74,6 @@ The worked composition root, from `examples/order-api/src/module.ts`: ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], diff --git a/docs/reference/observability.md b/docs/reference/observability.md index 39015fdb..8f6c348e 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -251,7 +251,6 @@ application and export `Logger` if anything outside the root reads it — ```ts export const OrderApi = HttpModule("OrderApi")({ - needs: [Env], router: orderRouter, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index b8805cbf..b789a9fd 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -83,7 +83,6 @@ The worked composition root, from ```ts export const OrderTemporalWorker = TemporalModule("OrderTemporalWorker")({ - needs: [Env], contract: orderContract, activities: orderActivities, workflows: { diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 96fadc1d..16e102f8 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -130,14 +130,12 @@ exports `HttpRuntime` — the one port the kernel resolves and drives: ```ts // app.ts -import { Env } from "@btravstack/config"; import { HttpModule } from "@btravstack/http"; import { GreetingModule } from "./greeter.js"; import { greetingRouter } from "./router.js"; export const App = HttpModule("App")({ - needs: [Env], router: greetingRouter, imports: [GreetingModule], }); diff --git a/docs/tutorial/second-runtime.md b/docs/tutorial/second-runtime.md index b91bb2ba..28830f7b 100644 --- a/docs/tutorial/second-runtime.md +++ b/docs/tutorial/second-runtime.md @@ -148,7 +148,6 @@ code lives, imports the Temporal starter, and exports `TemporalRuntime`: ```ts // worker.ts -import { Env } from "@btravstack/config"; import { TemporalModule } from "@btravstack/temporal"; import { workflowsPathFromURL } from "@temporal-contract/worker/worker"; @@ -157,7 +156,6 @@ import { GreetingModule } from "./greeter.js"; import { greetingContract } from "./temporal-contract.js"; export const Worker = TemporalModule("Worker")({ - needs: [Env], contract: greetingContract, activities: greetingActivities, workflows: { diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index 720d9f62..f54f2e77 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -160,11 +160,22 @@ itself. ## Module visibility: a need is DECLARED, never absorbed -**Decided in #50: a module states what it expects from outside, and anything -it owes and did not state is a compile error at that module.** The rule in one -line: `needs` is the explicit stand-in for NestJS's `@Global` — a composition -root may supply a port to a module it imports, but only one the module asked -for by name. +**Decided in #50: a module states what its OWN providers expect from outside, +and anything they owe and it did not state is a compile error at that module.** +`needs` is the explicit stand-in for NestJS's `@Global` — a composition root +may supply a port to a module it imports, but only one that module asked for by +name. + +**An import's own unmet needs are not the importer's to re-declare**, and that +half is deliberate. They are already published in the import's type — the +`imports` entry a reader is looking at says `Module` — and `start` +still refuses a root that has not discharged them, so leaving them out hides +nothing. Re-declaring them bought one line per module per hop: measured on this +repo, **12 of 22 declarations were pure propagation**, and dropping them leaves +exactly the modules that read the port. `Env` is the case that showed it — six +declarations in `order-api`, one of them the feature that reads +`DATABASE_URL`. This is the per-feature shape NestJS's +`ConfigModule.forFeature` has, reached without a global. ```ts export const AuditSlice = Module("AuditSlice")({ @@ -243,10 +254,12 @@ Nothing can provide `Scope` — a provider for it is a `WiringDefect` — so it never something an ancestor supplies; `Module.scoped` and `start` discharge it by opening one. A resourceful module therefore declares nothing. -`Env` is **not** exempt, and that is the point rather than an oversight: every -module that reads the environment says `needs: [Env]`, and the port travels, -declared at each step, up to the root that `start` hands one to. That chain is -what a `@Global` would have hidden. +`Env` is **not** exempt, and exempting it was refused: the module that reads +the environment says `needs: [Env]` — `DatabaseModule`, `observability()`, each +starter — and from there it travels through importers without being restated, +up to the root `start` hands one to. Naming it at the feature is what a +`@Global` would have hidden; naming it at every hop was what made the first cut +of this gate noisy. ### The gate cannot be computed generically — and that is why the casts exist @@ -266,13 +279,17 @@ around `StartGate` one layer down: sugar's return type to `Module` (measured). `start` and `tapped` may use `as never`, because both already cast their result. -### What a module cannot declare its way out of - -A starter's own port — `HttpRouterPort`, the AMQP handlers port, the Temporal -activities port — is exported as a TYPE only, so an application has nothing to -name in `needs`. Providing the router / handlers / activities is the only way -past the gate, which is what those gates are for. Three negatives pin it, one -per starter, and each moved from `start` to the module in this change. +### Which gate catches what + +A starter's port — `HttpRouterPort`, the AMQP handlers port, the Temporal +activities port — is owed by the **starter**, which an application _imports_. +So those three are the KERNEL's gate, on the needs channel at `start`, not +di's declaration one, and the three `needs-gate.test-d.ts` negatives say so. +The declaration gate catches the other half: a module whose OWN provider reads +a port nothing here satisfies — +`examples/order-temporal-worker`'s `FulfillmentlessSlice`, whose `fulfillOrder` +piece names `StockService` and `ShippingService`. Both are pinned, side by side, +because conflating them is easy. ## Binding design rules From b922822fbe82b186e32678ee2456b479b6492a92 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 21 Aug 2026 23:18:21 +0200 Subject: [PATCH 4/4] fix(docs): what a declared need does, and three stale comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `needs` was described as not making the port available inside the module, which reads as "a provider cannot use it" — the opposite of what it is for. A provider may depend on a declared need and is handed whatever an ancestor supplies; what declaring does not do is provide the port locally, so it stays outside `Available` and is still not exportable. The three `OrdersSlice` samples still said the environment and the logger were "both named here" while the code beside them named only `Logger`: the sweep rewrote the value and left the comment. They now say which is which and why. --- docs/examples/order-api.md | 6 ++++-- docs/explanation/modules-and-privacy.md | 9 +++++---- docs/how-to/split-a-router-into-controllers.md | 6 ++++-- docs/reference/di/modules.md | 12 ++++++------ examples/order-api/README.md | 6 ++++-- packages/di/src/module.ts | 6 +++++- 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index 9129fe76..e83f0e29 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -368,8 +368,10 @@ 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. + // 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], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], diff --git a/docs/explanation/modules-and-privacy.md b/docs/explanation/modules-and-privacy.md index 477c1eb8..2dd1a956 100644 --- a/docs/explanation/modules-and-privacy.md +++ b/docs/explanation/modules-and-privacy.md @@ -72,10 +72,11 @@ 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: _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 +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. diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index 630ea0a2..01ef5af2 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -170,8 +170,10 @@ same privacy di already gives any provider: ```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. + // 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], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], diff --git a/docs/reference/di/modules.md b/docs/reference/di/modules.md index 8568c1b5..71dd9f3c 100644 --- a/docs/reference/di/modules.md +++ b/docs/reference/di/modules.md @@ -34,12 +34,12 @@ const Persistence = Module("Persistence")({ All four lists are optional and default to empty. -| List | Contents | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `imports` | Modules whose **exports** become visible inside this one. A diamond — two imports that both import a third — is fine: providers are de-duplicated by reference at build time, so the shared module's services construct once. | -| `provides` | This module's own providers. A provider here may depend on anything **available** in this module: ports provided here, plus ports exported by the imports. Order within the list does not matter for correctness — dependency order is computed at build time — but it is what makes error selection deterministic when several fail at once. | -| `needs` | The ports **this module's own providers** expect a composition root to supply. Declaring one does not make it available inside the module and does not manufacture an obligation: it is permission for the need to travel outward. Anything this module's providers owe and it does not name here is a compile error **at this call** (below). An **import's** own needs travel without being restated — they are already in that import's type. `Scope` is exempt: nothing can provide it. | -| `exports` | The ports outside code may see. Each entry must be an **available port** — provided here, or exported by an import — a **provider for one**, which is normalised to `provider.port`, or an **imported module**, a whole-module re-export forwarding that module's own `exports` (never its internals). Anything else is a compile error at the declaration. | +| List | Contents | +| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `imports` | Modules whose **exports** become visible inside this one. A diamond — two imports that both import a third — is fine: providers are de-duplicated by reference at build time, so the shared module's services construct once. | +| `provides` | This module's own providers. A provider here may depend on anything **available** in this module: ports provided here, plus ports exported by the imports. Order within the list does not matter for correctness — dependency order is computed at build time — but it is what makes error selection deterministic when several fail at once. | +| `needs` | The ports **this module's own providers** expect a composition root to supply. A provider here may depend on a declared need and have it satisfied by whatever an ancestor supplies; what declaring does **not** do is provide the port locally, so it is not `Available` and cannot be exported. Nor does it manufacture an obligation — it is permission for a real one to travel outward. Anything this module's providers owe and it does not name here is a compile error **at this call** (below). An **import's** own needs travel without being restated — they are already in that import's type. `Scope` is exempt: nothing can provide it. | +| `exports` | The ports outside code may see. Each entry must be an **available port** — provided here, or exported by an import — a **provider for one**, which is normalised to `provider.port`, or an **imported module**, a whole-module re-export forwarding that module's own `exports` (never its internals). Anything else is a compile error at the declaration. | Exporting a provider means exactly what exporting its port class means — same `Exports` channel, same gates — and it is the only spelling available when the diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 2d95ab39..f5501010 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -152,8 +152,10 @@ The root is a list of **slices**. Each one imports the vertical it needs — ```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. + // 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], imports: [OrderApplicationModule, OrderPersistenceModule], provides: [ordersController], diff --git a/packages/di/src/module.ts b/packages/di/src/module.ts index 1a59e869..b4a15da0 100644 --- a/packages/di/src/module.ts +++ b/packages/di/src/module.ts @@ -178,7 +178,11 @@ export type Unmet