feat!: a provider declares its dependencies by name - #79
Conversation
Positional deps are correct by position: reordering the array silently rebinds
the parameters, and TypeScript only catches it when the service shapes differ,
which they routinely do not — two config ports over { url: string }, two
repositories with the same methods. ServiceOf erases the port's brand, so the
nominal identity that makes the rest of di safe is exactly what is missing at
that call.
deps is a record now, and the factory receives one keyed the same way. The
Qualification arms are untouched: ArgsOf makes the services record a
ONE-ELEMENT tuple, so (...args: Args) => S becomes (services) => S while a
no-deps factory keeps taking no arguments at all.
Arity discriminates, not shape — a deps record and an options object are both
non-array objects, so two arguments means the first is deps and one means it
is not. Object.entries fixes the order once at the call, and construct puts
the positionally-resolved services back under the names the caller wrote.
Migrates the container's own specs, type tests, README sample and spec notes to the keyed deps record. Adds three type tests for the new surface: an undeclared key on the services record, a non-port deps value, and a services entry carrying the service shape.
…'s ports by index
Arity is what tells a provider that declares dependencies from one that
declares none, so `keys.length === 0` was the wrong test: a caller who
wrote `Provider(P)({}, { sync })` got no argument at all where the types
promised a record. `HttpRouter(contract)({}, { sync })` is the shape
that found it.
…deps HttpRouter's positional arm, HttpController and HttpAuthenticator now declare their dependencies by name, matching di's provider. Arity is what tells HttpRouter's deps form from its keyed-controllers form, since both arguments are non-array objects. The authenticator rides a namespaced key on the deps record instead of a last array slot, and the controllers form's services record is already keyed by contract key, so nothing is reassembled.
TemporalActivities' deps arm and TemporalWorkflowActivities both inherit di's record form. The composing arm now declares each piece under the contract key its port id carries, so the services record IS the activities record and construct hands it straight back.
AmqpHandlers' deps arm and AmqpHandler both inherit di's record form. The composing arm now declares each piece under the contract key its port id carries, so the services record IS the handlers record and construct hands it straight back.
…nce declare The three interactors take their collaborators as one destructured record, and each adapter provider names the client it binds `db`.
Both controllers, the request scope and the docs-examples gate take a deps record. The gate's "positional" router is renamed to depsOrdersRouter, since positional is what it no longer is.
Both slice handlers name their logger, and the outbox relay names its four: outbox, logger, broker and config.
Both saga pieces and the three stand-in services name what they call.
Every sample and prose reference that spelled a provider's dependencies as an array now spells the record. No behaviour change.
Every TypeScript sample on the site that declared a provider's dependencies as an array now declares the record, with the key names the code uses. Also fixes a pre-existing drift the recompile surfaced: the AMQP how-to's placingHandlers called PlaceOrder.execute without its tenant.
…r has
A controller or an authenticator that calls nothing is the common shape in
this package, not an edge case: `({}, { sync })` was the most repeated
token in its type tests, 25 times across five files. HttpController,
HttpAuthenticator and HttpRouter's deps arm now mirror `Provider(port)`'s
own two overloads, discriminated by arity, with the no-deps factory taking
no argument at all. AmqpHandler and TemporalWorkflowActivities already had
it — they return di's build directly.
HttpRouter is the one helper with three forms and two arguments' worth of
arity, so it is the one place arity cannot decide alone: its two
one-argument forms are told apart by whether `sync` holds a function, which
is total rather than a heuristic, since a contract key called `sync` would
hold a controller and a controller is never a function.
This also makes the empty-record defect unreachable from a call site rather
than merely guarded against; `provider.ts` says why the guard stays anyway.
…nator HttpRouter has three forms and two arguments' worth of arity, so one call has to be told apart by whether `sync` holds a function. The argument that this is total — a contract may name a key `sync`, but the value under it is a controller, an object carrying .port, never a function — was sound and untested. A contract whose top-level key is literally `sync`, composed through the keyed form. Mutation-checked against the simplification it guards: weakening the check to `"sync" in first` fails this test and only this test, where breaking the discriminator outright fails four.
There was a problem hiding this comment.
Pull request overview
This PR makes a breaking change to the @btravstack/di Provider API so dependencies are declared once, by name, using a record ({ name: Port }) instead of a positional tuple/parameter list pairing. It migrates the entire workspace (packages, examples, docs) to the new keyed-deps shape, updates wrapper helpers (HTTP/AMQP/Temporal), and adds/adjusts tests to pin the new discrimination and empty-deps behavior.
Changes:
- Replace positional provider deps with keyed dependency records across the DI container and all call sites.
- Update starter helpers (
HttpRouter,HttpController,HttpAuthenticator,AmqpHandler(s),TemporalWorkflowActivities/TemporalActivities) to accept/produce named-deps providers. - Refresh docs/READMEs and add tests for previously unpinned discriminator/edge cases (notably
HttpRouter’ssynckey and empty deps records).
Reviewed changes
Copilot reviewed 96 out of 96 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Update top-level example to deps record form |
| packages/testing/src/tapped.ts | Adapt tapped to new provider deps shape |
| packages/temporal/src/test-fixtures.ts | Migrate Temporal fixtures to deps records |
| packages/temporal/src/temporal-runtime.ts | Update Temporal runtime providers to deps records |
| packages/temporal/src/temporal-module.ts | Update TemporalActivities composition to keyed services |
| packages/temporal/README.md | Update README examples to deps records |
| packages/temporal/CLAUDE.md | Update Temporal package spec references |
| packages/observability/src/test-fixtures.ts | Update observability fixtures provider deps |
| packages/observability/src/observability.ts | Update Logger provider deps to named record |
| packages/observability/README.md | Update README provider examples |
| packages/http/src/test-fixtures.ts | Update HTTP fixtures; add sync-keyed contract/router |
| packages/http/src/orpc.ts | Rework HttpRouter builder discrimination + deps record plumbing |
| packages/http/src/http-runtime.ts | Update HttpRuntime provider deps to named record |
| packages/http/src/http-module.ts | Update docs comment for HttpAuthenticator signature |
| packages/http/src/controller.ts | Update HttpController factory to mirror new Provider arity |
| packages/http/src/controller.test-d.ts | Update type gates for new deps record form |
| packages/http/src/controller.spec.ts | Add runtime test for sync key discriminator |
| packages/http/src/auth.ts | Update HttpAuthenticator builder to mirror Provider arity |
| packages/http/src/auth.test-d.ts | Update auth type gates for deps record form |
| packages/http/src/auth.spec.ts | Update runtime spec expectations for deps list |
| packages/http/README.md | Update README examples + lifted-router recipe |
| packages/http/CLAUDE.md | Update HTTP package spec to new deps semantics |
| packages/di/src/scoped.test-d.ts | Update scoped type tests to deps records |
| packages/di/src/scoped.spec.ts | Update scoped runtime specs to deps records |
| packages/di/src/provider.ts | Implement deps-record Provider + arity-based discrimination |
| packages/di/src/provider.test-d.ts | Update Provider type tests for services record typing |
| packages/di/src/provider.spec.ts | Add runtime spec for empty deps record behavior |
| packages/di/src/module.test-d.ts | Update module type tests to deps records |
| packages/di/src/lifecycle.spec.ts | Update lifecycle specs to deps records |
| packages/di/src/fork.test-d.ts | Update fork type tests to deps records |
| packages/di/src/fork.spec.ts | Update fork runtime spec provider deps |
| packages/di/src/example.test-d.ts | Update DI example type test |
| packages/di/src/example.spec.ts | Update DI example runtime spec + constructors |
| packages/di/src/build.test-d.ts | Update build type tests to deps records |
| packages/di/src/build.spec.ts | Update build runtime specs to deps records |
| packages/di/README.md | Update DI README provider example |
| packages/di/CLAUDE.md | Document breaking Provider deps-record-only decision |
| packages/core/src/test-fixtures.ts | Update core fixtures provider deps |
| packages/core/src/start.test-d.ts | Update start type tests to deps records |
| packages/core/src/docs-examples.test-d.ts | Update core docs type examples to deps records |
| packages/config/src/config.ts | Update Config.provider internals to deps record |
| packages/config/README.md | Update config README provider example |
| packages/config/CLAUDE.md | Update config package spec reference |
| packages/amqp/src/test-fixtures.ts | Update AMQP fixtures to deps records |
| packages/amqp/src/amqp-runtime.ts | Update AMQP runtime provider deps + compose logic |
| packages/amqp/README.md | Update AMQP README examples to deps records |
| packages/amqp/CLAUDE.md | Update AMQP package spec references |
| examples/README.md | Update examples index/controller signature references |
| examples/order-temporal-worker/src/slices/fulfillment/activities.ts | Migrate workflow activities deps to named record |
| examples/order-temporal-worker/src/slices/billing/activities.ts | Migrate billing activities deps to named record |
| examples/order-temporal-worker/src/fulfillment.ts | Update providers (Stock/Shipping) to deps records |
| examples/order-temporal-worker/src/billing.ts | Update PaymentService provider to deps record |
| examples/order-temporal-worker/README.md | Update README examples to deps records |
| examples/order-infrastructure/src/prisma-outbox.ts | Update Outbox provider (wrap point-free) |
| examples/order-infrastructure/src/prisma-order-repository.ts | Update repository provider (wrap point-free) |
| examples/order-infrastructure/src/prisma-customer-repository.ts | Update customer repo provider (wrap point-free) |
| examples/order-infrastructure/src/database.ts | Update database resource provider deps |
| examples/order-application/src/use-cases.ts | Update interactors + providers for services record |
| examples/order-api/src/slices/orders/controller.ts | Update controller deps to named record |
| examples/order-api/src/slices/customers/controller.ts | Update controller deps to named record |
| examples/order-api/src/request-scope.ts | Update RequestModule provider deps |
| examples/order-api/src/needs-gate.test-d.ts | Update needs gate docs + authenticator call form |
| examples/order-api/src/docs-examples.test-d.ts | Update docs type examples to deps records |
| examples/order-api/src/authenticator.ts | Update authenticator provider call form |
| examples/order-api/README.md | Update example README signatures |
| examples/order-amqp-worker/src/slices/notifications/handler.ts | Update handler deps to named record |
| examples/order-amqp-worker/src/slices/audit/handler.ts | Update handler deps to named record |
| examples/order-amqp-worker/src/outbox-relay.ts | Update resource provider deps to named record |
| examples/order-amqp-worker/README.md | Update README handler call form |
| examples/hexagonal-order-api/src/index.ts | Update example module/provider deps + constructors |
| examples/hexagonal-order-api/src/emit-guards.ts | Update emit-guards provider deps |
| docs/tutorial/getting-started.md | Update tutorial code samples to deps records |
| docs/reference/http.md | Update HTTP reference signatures/examples |
| docs/reference/di/providers.md | Update Provider reference to deps records + arms |
| docs/reference/di/modules.md | Update modules reference samples |
| docs/reference/config.md | Update config reference sample |
| docs/reference/amqp.md | Update AMQP reference signatures/examples |
| docs/index.md | Update landing page sample to deps records |
| docs/how-to/swap-an-adapter.md | Update how-to samples to deps records |
| docs/how-to/split-a-worker-into-slices.md | Update slice how-to samples + compose explanation |
| docs/how-to/split-a-router-into-controllers.md | Update router/controller how-to samples |
| docs/how-to/read-the-ambient-unit.md | Update examples to deps records |
| docs/how-to/protect-a-procedure.md | Update auth/how-to samples to deps records |
| docs/how-to/open-a-per-request-scope.md | Update request-scope sample to deps records |
| docs/how-to/manage-a-resource.md | Update resource how-to samples to deps records |
| docs/how-to/keep-a-port-private.md | Update port-privacy samples to deps records |
| docs/how-to/consume-amqp-messages.md | Update AMQP how-to samples to deps records |
| docs/how-to/configure-from-the-environment.md | Update env-config how-to samples to deps records |
| docs/explanation/starters.md | Update starters explanation provider examples |
| docs/explanation/compile-time-wiring.md | Update wiring explanation snippet |
| docs/examples/order-temporal-worker.md | Update example page snippets |
| docs/examples/order-api.md | Update example page snippets |
| docs/examples/order-amqp-worker.md | Update example page snippets |
| docs/examples/hexagonal-order-api.md | Update example page snippets |
| CLAUDE.md | Update root repo guidance examples to deps records |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| const supplied = (options ?? armOnly) as { | ||
| readonly sync: (s: Record<string, unknown>) => unknown; | ||
| }; | ||
| const deps = armOnly === undefined ? (depsOrControllers as Record<string, AnyPort>) : {}; | ||
| const sync = (services: Record<string, unknown>): Router<Record<never, never>> => | ||
| routerFrom(supplied.sync(own(services)) as Record<string, unknown>, services); | ||
| return Provider(HttpRouterPort)(withAuthenticator(deps), { sync } as never); |
There was a problem hiding this comment.
Fixed in 85e7444 — correct, and worth more than the "runtime behaviour change" framing suggests.
The arm-only form's sync is declared () => Implementation<C, Identity>. Handing it a services record contradicts the arity guarantee this branch just introduced for a no-deps factory — Provider hands one nothing, and the helpers were added so a caller need not write {}. Passing a record anyway made the helper the one place that broke its own rule.
armOnly === undefined ? call(own(services)) : call(), with the reason stated at the branch.
Nothing caught it. I reverted the fix to check: all 41 tests stayed green. So there is a spec now — an arm-only router whose sync is a rest parameter recording its own arity — and it is mutation-checked: restoring call(own(services)) unconditionally fails it with expected 1 to be +0, and fails only it.
That is the part I would emphasise over the arity break itself. An arrow ignores the extra argument, so the bug was invisible to every existing test and would have stayed invisible until someone wrote a factory that looked.
…name one type Review found the arm-only form calling supplied.sync(own(services)) while its declared type is () => Implementation. JS ignores the extra argument, so nothing crashed — but it contradicts the arity guarantee this branch just gave a no-deps factory, and a rest parameter would see it. Nothing caught it either: reverting the fix left all 41 tests green, so there is a spec now, mutation- checked (always passing the record fails it and only it). Also from a complexity pass: HttpRouter's three overloads and HttpController's two spelled the same return type each time, differing only in the needs channel. One named alias per helper. Two findings from that pass I dropped rather than applied. Swapping the arity ternary for a spread saves a line and costs a cast — boring wins. And I priced the duplication at ~40 lines across three helpers; auth.ts's returns are one-liners, so it was two.
Closes #65.
The wart
Every provider declared its dependencies twice — once as a tuple, once as a
parameter list — and correctness was by position:
Reorder the array and the parameters silently rebind. TypeScript catches a swap
only when the two service shapes differ, and they routinely do not — two
config ports over
{ url: string }, two repositories with the same methods.ServiceOferases the port's brand, so the nominal identity that makes the restof di safe is exactly what is missing at that call.
One shape, not two
The issue proposed this as additive, keeping the positional form. It is not:
the positional form is gone. One shape was the decision, the library is
experimental, and two spellings for one thing is the wart in another costume.
How it costs so little
Qualification's five arms —value,sync,make,class,acquire— areuntouched. They are variadic (
(...args: Args) => S), soArgsOf<D>makesthe services record a one-element tuple:
(...args: [Services]) => Sis(services: Services) => S. A no-deps provider keepsreadonly [], and with ita factory of no arguments.
Three things the issue got wrong, found by spiking before writing:
NeedsOfis notInstanceType<D[keyof D]>. The false branch of theconditional does not narrow
D, so that does not compile. It needs a secondpositive check.
Array.isArraycannot discriminate. A deps record and an options objectare both non-array objects. Arity decides: two arguments means the first
is deps.
constructreceives a positional array and spreads it, because the buildpipeline resolves deps positionally. The descriptor now carries the key order
Object.entriesfixed at the call and rebuilds the record before calling thefactory.
Verified before the migration: destructuring is plain dot access
(
({ alpha, beta }) => …), notservices["alpha"].Scope
Provider, then every call site: 13 workspaces, and the helpers that passdeps through —
HttpController,HttpRouter's deps arm,HttpAuthenticator,AmqpHandler,TemporalWorkflowActivities.AmqpHandlers,TemporalActivitiesandHttpRouter's keyed-controllers form take pieces,not deps, and are unchanged. Documentation and every README moved with it.
Two bugs found on the way
An empty deps record handed the factory nothing.
descriptordiscriminatedon
keys.length === 0, soProvider(P)({}, { sync })passed no argument wherethe types promised
{}. Arity is the right test. Pinned by a spec.25 empty deps records, because a controller or an authenticator with no
dependencies is the common shape in
packages/http—{},on its own line hadbecome the most repeated token in its type tests. The three helpers that wrap
Providernow mirror its two overloads, so the no-deps call takes one argumentlike
Provider's does. That also makes the empty-record bug unreachable byconstruction rather than by the guard, which is noted where the guard lives so
nobody deletes one thinking the other covers it.
The discriminator, and the test it was missing
HttpRouterhas three forms and two arguments' worth of arity, so one callcannot be decided by arity: its arm-only form and its keyed-controllers form are
told apart by whether
syncholds a function.The argument that this is total — a contract may name a key
sync, but thevalue under it is then a controller, an object carrying
.port, never afunction — was sound and untested. No contract in the suite had a key named
sync.There is one now, and it earns its place: weakening the check to
"sync" in first— the obvious simplification — fails that test and only thattest, where breaking the discriminator outright fails four.
The cost, accepted and recorded
sync: prismaOrderRepositoryhad to becomesync: ({ db }) => prismaOrderRepository(db).Point-free is gone for the single-dependency case, and that is inherent to
naming dependencies rather than a formatting accident.
packages/di/CLAUDE.mdrecords it, ending "Do not reintroduce a positional arm to recover it."
Gate
format --check·lint·typecheck31/31 ·knip·test29/29 (Docker) ·build10/10 · docs build — all green.packages/http41 tests, 100%lines/functions.