Skip to content

Reconsider overrideProvider for testing — it costs four hand-maintained parallel roots today #63

Description

@btravers

The root CLAUDE.md currently states the refusal flatly:

There is no Defect construction, no overrideProvider, no accumulation of runtimes, and no recoverFailure-style channel-moving helper. Swapping an adapter is composing a different module, which di already documents and the type checker already verifies.

Reopening it, because the cost is now measurable rather than theoretical.

What the refusal actually costs today: four hand-maintained copies of composition roots, all of them in examples/, which is the teaching surface.

File What it restates
examples/order-amqp-worker/src/test-fixtures.ts RecordingAmqpWorker — the whole AmqpModule root
examples/order-api/src/test-fixtures.ts two parallel roots
examples/order-temporal-worker/src/test-fixtures.ts one

Each says why, in its own words:

"a parallel root rather than OrderAmqpWorker itself because nothing can be layered over a graph that already provides Logger"order-amqp-worker/src/test-fixtures.ts:44

"A parallel root rather than OrderApi itself for the same reason … nothing can be layered over a graph that already provides"order-api/src/test-fixtures.ts:100

And each must be kept in sync by hand. PR #49 proved the drift risk concretely: adding two slices to OrderAmqpWorker meant remembering to add them to the recording root too, and the fixture's TSDoc had to grow a paragraph explaining that it "mirror[s] OrderAmqpWorker's own root". A copy with no gate is the thing this repo's own documentation rule warns about, applied to composition instead of prose.

The Logger case is the sharpest form: observability() mints fresh providers per call and di dedupes by reference, so a second call is [di] two providers registered for port "LoggerConfig" (measured). There is no way to layer a recording sink over a real root — only to rebuild the root beside it.

It is also the missing half of #50. That issue asks whether a slice should see the root's providers (needs-bubble-up, today) or only what it imports (NestJS). The reason Nest can afford strict import-visibility is that its testing module has overrideProvider; recomposition is not its substitution mechanism. Here, recomposition is the mechanism, which is exactly why import-visibility would close the seam. Decide these two together or the second one is unanswerable.

Where it would live, and what stays true. Nest's overrideProvider is on Test.createTestingModule, not on the application. The same split works here: it belongs in @btravstack/testing, beside bootFixture and tapped, and production composition stays override-free — so the original argument ("swapping an adapter is composing a different module") survives intact for the thing it was actually about.

The part worth designing carefully is how much of the compile-time guarantee an override keeps. Overriding a port with a provider of the same service type is checkable, and should be a type error otherwise. What an override cannot check is whether the resulting graph still makes sense — which is the honest cost, and is the same cost tapped already accepts.

Acceptance

  • A decision recorded in packages/testing/CLAUDE.md and in the root CLAUDE.md's public-surface section, which currently states the refusal.
  • If adopted: the override is type-checked against the port's service, lives only in @btravstack/testing, and the four parallel roots in examples/ collapse to overrides on the real root.
  • If the refusal stands: the four parallel roots get a stated maintenance rule, since nothing currently fails when one drifts from the root it mirrors.
  • Resolved together with Decide di's module visibility: needs bubble up today, NestJS scopes to imports #50 — they are one decision wearing two hats.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Wanted, not urgentdecisionNeeds a choice, not code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions