Skip to content

feat!: a provider declares its dependencies by name - #79

Merged
btravers merged 19 commits into
mainfrom
feat/keyed-dependencies
Aug 20, 2026
Merged

feat!: a provider declares its dependencies by name#79
btravers merged 19 commits into
mainfrom
feat/keyed-dependencies

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Closes #65.

The wart

Every provider declared its dependencies twice — once as a tuple, once as a
parameter list — and correctness was by position:

TemporalWorkflowActivities(orderContract, "fulfillOrder")(
  [PlaceOrder, OrderRepository, StockService, ShippingService],
  { sync: (place, repository, stock, shipping) =>  },
)

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

One shape, not two

Provider(Target)({ alpha: Alpha, beta: Beta }, { sync: ({ alpha, beta }) =>  })
Provider(Target)({ sync: () =>  })                    // no deps, unchanged

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 — are
untouched. They are variadic ((...args: Args) => S), so ArgsOf<D> makes
the services record a one-element tuple: (...args: [Services]) => S is
(services: Services) => S. A no-deps provider keeps readonly [], and with it
a factory of no arguments.

Three things the issue got wrong, found by spiking before writing:

  • NeedsOf is not InstanceType<D[keyof D]>. The false branch of the
    conditional does not narrow D, so that does not compile. It needs a second
    positive check.
  • Array.isArray cannot discriminate. A deps record and an options object
    are both non-array objects. Arity decides: two arguments means the first
    is deps.
  • construct receives a positional array and spreads it, because the build
    pipeline resolves deps positionally. The descriptor now carries the key order
    Object.entries fixed at the call and rebuilds the record before calling the
    factory.

Verified before the migration: destructuring is plain dot access
(({ alpha, beta }) => …), not services["alpha"].

Scope

Provider, then every call site: 13 workspaces, and the helpers that pass
deps through — HttpController, HttpRouter's deps arm, HttpAuthenticator,
AmqpHandler, TemporalWorkflowActivities. AmqpHandlers,
TemporalActivities and HttpRouter'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. descriptor discriminated
on keys.length === 0, so Provider(P)({}, { sync }) passed no argument where
the 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 had
become the most repeated token in its type tests. The three helpers that wrap
Provider now mirror its two overloads, so the no-deps call takes one argument
like Provider's does. That also makes the empty-record bug unreachable by
construction 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

HttpRouter has three forms and two arguments' worth of arity, so one call
cannot be decided by arity: its arm-only form and its keyed-controllers form are
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 then a controller, an object carrying .port, never a
function — 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 that
test
, where breaking the discriminator outright fails four.

The cost, accepted and recorded

sync: prismaOrderRepository had to become sync: ({ 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.md
records it, ending "Do not reintroduce a positional arm to recover it."

Gate

format --check · lint · typecheck 31/31 · knip · test 29/29 (Docker) ·
build 10/10 · docs build — all green. packages/http 41 tests, 100%
lines/functions.

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.
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.
Copilot AI lite review requested due to automatic review settings August 20, 2026 22:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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’s sync key 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.

Comment thread packages/http/src/orpc.ts
Comment on lines +221 to +227
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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@btravers
btravers merged commit b774749 into main Aug 20, 2026
13 checks passed
@btravers
btravers deleted the feat/keyed-dependencies branch August 20, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keyed dependency records for Provider — positional deps are the highest-frequency DX wart

2 participants