---
id: binder-wrap-helper
title: Collapse binder duplication via wireUseCase helper
type: prd
status: approved
author: danijel
elicitation-session: improve-codebase-architecture-2026-05-13
created: 2026-05-13T00:00:00Z
updated: 2026-05-14T19:16:52.691Z
---

## Problem

Every feature's `bind-production.ts` and `bind-dev-seed.ts` independently inline the same `withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` wrapping for each use case. Concretely:

- Five of `pnpm fallow`'s top-ten clone groups come from binder pairs (auth, blog, media, marketing-pages, navigation).
- Per binder pair the duplication runs 30–79 lines.
- Adding a use case means editing two parallel blocks (production + dev-seed); forgetting one is silent drift not caught until `pnpm dev` boots (`assertFeatureConformance` fires) or coverage gates fail.
- The wrapping shape is structurally identical across features and modes: span options, capture tags, and the factory call are the only things that vary per use case.

The cost is paid at three sites:

1. **Authoring** — every new use case requires writing ~12 lines of mechanical boilerplate, twice.
2. **Reading** — every diff that touches a binder is dominated by repetitive shape; signal-to-noise is poor.
3. **Refactoring** — changes to the wrapping shape (e.g., adding a new brand, adjusting span attributes) require N × 2 simultaneous edits across the workspace.

The wrapping itself is correct and stays at DI bind time per ADR-014 / ADR-017. What's missing is the abstraction: a single point of composition that the binders call instead of inline.

## Goal

Introduce a `wireUseCase(...)` helper in `@repo/core-shared/conformance/` that encapsulates the `withSpan + withCapture (+ optional withAudit)` composition. Refactor all five features' binders to call the helper. The wrapping shape becomes a single source of truth; per-feature binders shrink to their decision content (which adapter to bind for which mode) plus a list of `wireUseCase` calls.

## In scope

- New module: `@repo/core-shared/conformance/wire-use-case.ts` exporting the helper + its types.
- Tests: a colocated `wire-use-case.test.ts` covering the helper's behaviour (span composition, capture composition, audit composition when applicable, brand attachment).
- Migration: all five features' `bind-production.ts` and `bind-dev-seed.ts` files (10 files) updated to call `wireUseCase` instead of inlining the wrappers.
- Existing binder-level tests must continue to pass (smoke-level coverage for the per-feature wiring).
- The `assertFeatureConformance` boot-time assertion (already at the tail of each binder) continues to fire on drift — the helper does not bypass it.
- Generator update: `pnpm turbo gen feature` template emits `wireUseCase` calls in the scaffolded binders, not the longhand inline form.

## Out of scope

- **Sub-shape (b)** (pre-wired factory exports at the use-case file level) — explicitly rejected during the architecture grilling that produced this PRD. Helper-inside-binder (sub-shape (a)) keeps the wrapping a binder concern, which preserves ADR-008's per-feature DI isolation.
- **Hoisting brand attachment out of `withSpan` / `withCapture` / `withAudit`** — the helper composes these existing wrappers; it does not replace them.
- **Changing the manifest schema** — manifests stay as they are. The helper reads what the binder passes; it does not consult the manifest directly.
- **Apps' `bindAll()` dispatcher** — the `apps/web-next/src/server/bind-production.ts` (and equivalent for tanstack/cms) stays untouched. It calls per-feature binders, which is unchanged.
- **Repository/service binding patterns** — only use-case bindings (the wrapped factories) move through the helper. Repository and service bindings stay as direct `.toConstantValue()` calls.
- **Controller bindings** — covered by the helper too (controllers are also wrapped the same way per ADR-013), but if scope creep is a concern, controllers can land in a follow-up.

## Constraints

- **ADR-008** — per-feature DI containers. The helper takes the container as a parameter; it does NOT introduce a global registry.
- **ADR-012, ADR-013** — factory-function use cases + controllers. The helper consumes factories of shape `(deps) => async (input) => output`; it does NOT change the factory shape.
- **ADR-014, ADR-017** — instrumentation interfaces (`ITracer`, `ILogger`) + OTel substrate. The helper composes the existing `withSpan` / `withCapture` wrappers; it does NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (rule R52 enforces this; the helper lives in `core-shared/conformance/`, not `core-shared/instrumentation/otel/`).
- **ADR-018** — audit logging. The helper composes `withAudit` when an audit emitter is provided in the call.
- **TS brand-slot enforcement** — `Instrumented` / `Captured` / `Audited` brands must remain present at the bind-time return value. The helper's return type carries the full brand stack so `assertFeatureConformance` continues to find the brands at boot.
- **Conformance ESLint rules** — the five rules in `core-eslint/rules/` (manifest must have a file, use case must have a test, etc.) keep firing unchanged. The helper does not interact with ESLint.
- **`pnpm fallow` clone-group baseline** — after the migration, the five top-ten clone groups should disappear. The PR's coverage check + fallow audit verify this.
- **L0 coverage** — `core-shared/conformance/wire-use-case.ts` lives in `core-shared`, not a feature, so its L0 band is `core-shared`'s vitest config's defaults (80/75/80/80 baseline). The new file is small enough that 100% is achievable; aim for 100%.
- **Generator-first** — the feature generator template must emit the new call shape; the legacy inline form is no longer emitted.
- **Hybrid versioning (ADR-021)** — this PR touches `core-shared` (bumps the root template version) and all five feature packages (bumps each feature's package version). The release-please rolling PR will reflect that.

## Success criteria

- `wireUseCase` lives at `packages/core-shared/src/conformance/wire-use-case.ts` and is exported from `@repo/core-shared/conformance`.
- A colocated `wire-use-case.test.ts` covers: span composition (Instrumented brand attached), capture composition (Captured brand attached), audit composition (Audited brand attached when audit emitter passed), and the no-audit branch.
- All ten binder files (auth × 2, blog × 2, media × 2, marketing-pages × 2, navigation × 2) call `wireUseCase` for their use cases (and controllers, if covered in this PRD).
- `pnpm typecheck && pnpm test && pnpm lint && pnpm conformance && pnpm fallow:audit` green.
- `pnpm test -- --coverage` green; per-feature L0 bands hold (100% on entities/use-cases/controllers).
- `pnpm coverage:diff` reports `pass` against `origin/main`.
- `pnpm fallow dupes` shows the five binder-pair clone groups have disappeared (down from the current 5-in-top-10 footprint).
- `pnpm dev` boots — `assertFeatureConformance` accepts every wired use case (brands present, manifest entries match).

## User stories

1. As a **feature author**, I want adding a use case to be a single-line binder edit (one `wireUseCase` call per mode) instead of a ~12-line inline wrapper block, so that I don't pay boilerplate cost for mechanical work.
2. As a **future architect**, I want the wrapping shape (`withSpan` + `withCapture` + optional `withAudit`) to live in one place, so that adjusting it across the workspace is a single-file change.
3. As an **AI implementer agent**, I want the generator to emit the helper-based shape, so that scaffolded features ship with the consolidated wrapping by default and don't drift back to inline form.
4. As an **AI reviewer agent**, I want the binders to read as decision content (which adapter, which mode) rather than mechanical boilerplate, so that diff review surfaces real changes.
5. As a **maintainer of `core-shared`**, I want the helper's behaviour fully unit-tested in isolation, so that changes to the wrapping composition are caught at `pnpm test` rather than at every feature's downstream binder test.
6. As a **CI consumer**, I want `pnpm fallow dupes` to no longer flag the five binder-pair clone groups, so that fallow output's signal-to-noise improves.
7. As a **template adopter**, I want `pnpm turbo gen feature` to scaffold binders that use the helper, so that new features participate in the consolidated wrapping pattern from the first commit.
8. As an **on-call engineer**, I want `assertFeatureConformance` to keep its current "missing brand fails boot" semantics, so that the refactor doesn't weaken the boot-time conformance gate.

## Implementation decisions

### The helper's call shape

The helper takes an options object covering everything the inline form expresses today:

- the DI container (so the helper performs the `bind(...).toConstantValue(...)` step)
- the DI symbol
- the use-case factory function + its deps tuple
- the feature name, layer, and use-case name (used to derive both `span.name` + capture tags)
- the tracer + logger (always required)
- the auditLog + audit input schema (optional — only when the manifest declares audits for this use case)

A second variant (or a `wireController` peer) covers controllers, which share the wrapping shape but have a different brand identity per ADR-013.

The helper returns the brand-stacked wired value so callers can hold a reference (e.g., for tests that bypass the container).

Exact API surface (signatures, generic parameters, the deps-tuple typing strategy, whether one entry point covers both use cases and controllers or two peer helpers do) lands during the implementation TDD cycle. The architecture grilling fixed the _shape_, not the exact signature.

### Where the helper lives

`packages/core-shared/src/conformance/wire-use-case.ts`, alongside the existing `define-feature.ts`, `coverage.ts`, `assert-bindings.ts`. Exported through `@repo/core-shared/conformance`. NOT in `core-shared/instrumentation/` (the helper composes interfaces from there but doesn't depend on the OTel SDK; placing it in `instrumentation/` would muddy the vendor-isolation boundary).

### Brand attachment

Brands continue to attach inside `withSpan` / `withCapture` / `withAudit` (the existing wrappers). The helper just orders the composition correctly (`withSpan` outermost per ADR-014). No brand-attachment logic moves into the helper.

### Migration shape per binder file

Inline blocks like:

```ts
const wrappedSignIn: ProductionUseCase<
  SignInInput,
  SignInOutput,
  AuthManifest["useCases"]["signIn"]
> = withSpan(
  tracer,
  { name: "auth.signIn", op: "use-case" },
  withCapture(
    logger,
    { feature: "auth", layer: "use-case", name: "auth.signIn" },
    signInUseCase(repo, authService),
  ),
);
authContainer
  .bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase)
  .toConstantValue(wrappedSignIn);
```

become:

```ts
wireUseCase({
  container: authContainer,
  symbol: AUTH_SYMBOLS.ISignInUseCase,
  factory: signInUseCase,
  deps: [repo, authService],
  feature: "auth",
  layer: "use-case",
  name: "signIn",
  tracer,
  logger,
});
```

Span name (`auth.signIn`) is derived from `feature + "." + name`. Capture tags are derived from `feature + layer + name`. The container `unbind` + `bind` pattern (already idempotent per ADR-008) is encapsulated.

### Audit-bearing use cases

When the manifest declares `audits: [...]` for a use case, the binder passes `auditLog` + the audit schema into `wireUseCase`. The helper composes `withAudit` inside `withCapture`. When `audits: []`, the audit composition is skipped (no Audited brand attached, no audit emission).

### Generator template

`turbo/generators/templates/feature/src/di/bind-production.ts.hbs` and `bind-dev-seed.ts.hbs` (assuming they exist; if not, equivalent files) get updated to emit the `wireUseCase` call shape. The anchor `<gen:use-cases>` continues to mark the injection point.

### Per-feature impact

| Feature         | Use cases to migrate                             | Audit-bearing? |
| --------------- | ------------------------------------------------ | -------------- |
| auth            | 3 (signIn, signUp, signOut)                      | check manifest |
| blog            | 3 (getArticles, getArticleBySlug, createArticle) | check manifest |
| media           | 3 (getMedia, listMedia, deleteMedia)             | check manifest |
| marketing-pages | 2 (getPageBySlug, getSiteSettings)               | check manifest |
| navigation      | 1 (getHeader)                                    | check manifest |

Total: 12 use cases × 2 modes = 24 inline wrap sites to migrate.

## Testing decisions

- **What "good test" means here**: tests assert on observable outcomes at the helper's interface (the wired value's behaviour when called, the brands attached, the container's binding shape) — not on internal composition order.
- **Helper-level unit tests** in `wire-use-case.test.ts`:
  - Wiring without audit composes `withSpan(withCapture(factory))`; both Instrumented + Captured brands present.
  - Wiring with audit composes `withSpan(withCapture(withAudit(factory)))`; all three brands present.
  - Span name derivation: `<feature>.<name>` for use cases; check the convention matches existing behaviour.
  - Capture tags: `{ feature, layer, name }` correctly applied.
  - Container binding: symbol → wired value; idempotent re-bind (unbind + bind) when called twice.
  - Brand presence asserted via the existing `isInstrumented` / `isCaptured` / `isAudited` runtime helpers from `core-shared/conformance/brand-runtime.ts`.
- **Per-feature binder tests**: should continue to pass without modification. If they assert exact shape of the wrapping internals (rather than observable behaviour), refactor them to assert through the helper's contract.
- **Integration tests**: existing `feature.test.ts` and `*-flow.feature.test.ts` files (e.g., `packages/auth/tests/sign-in-flow.feature.test.ts`) must continue to pass — they exercise the wired chain end-to-end.
- **Coverage**: `wire-use-case.ts` should hit 100% on entities/use-cases/controllers band equivalents (it lives in core-shared, baseline 80/75/80/80, but the file is small and a single helper should be exhaustively tested).
- **Prior art**: the existing wrappers' tests in `core-shared/instrumentation/` and `core-audit/with-audit.test.ts` are the closest pattern to mirror for the new helper's test file.

## Open questions

- **Q1: One helper or two (use cases + controllers)?** Use cases and controllers share the wrapping shape but the brand identity differs. **Recommended:** start with one `wireUseCase` that takes a `layer: "use-case" | "controller"` discriminator; if the controller path diverges meaningfully during implementation, split into `wireController` then.
- **Q2: Should the helper consult the manifest directly to decide whether to apply `withAudit`?** The manifest declares `audits: [...]` per use case. The binder could pass this in, OR the helper could read the manifest. **Recommended:** binder passes — keeps the helper a pure composition concern; avoids the helper depending on the manifest schema.
- **Q3: Does the helper handle the `unbind + bind` idempotency, or do callers?** **Recommended:** helper handles it. Every existing binder already does `if (container.isBound(sym)) container.unbind(sym); container.bind(sym).toConstantValue(...);` — consolidate.
- **Q4: Where do tracer + logger come from inside the helper?** Passed in by the caller (the binder already has them via `ctx`). The helper takes them as parameters; it does not reach into a global.

## Out of scope (deferred)

- Pre-wired factory exports (sub-shape (b) from the architecture grilling) — rejected for this iteration; revisit only if the helper-based approach fails to deliver the locality benefit.
- A `wireRepository` / `wireService` peer for repositories and services. Those bindings are direct `.toConstantValue(new RealOrMock(...))` calls and aren't structurally duplicated like the use cases; deepening them is a separate ADR conversation.
- The dupes that fallow surfaces outside the binders (e.g., the 117-line clone between `init-client.ts` and `init-client-react.ts` in `core-shared/instrumentation/sentry/`) — own initiative, not part of this PRD.
- Identity-presenter cleanup (Candidate 2 from the architecture skill's exploration) — separate PRD.

## Further notes

- This PRD is the output of the `improve-codebase-architecture` skill's grilling loop on Candidate 1, sub-shape (a). The companion candidates (2 / 3 / 4 / 5 housekeeping) remain as future deepening targets.
- After the implementation epic ships, `pnpm fallow dupes` will become a cleaner gate — losing five top-ten clone groups should let it surface remaining duplication signal more clearly.
- The release-please rolling PR will bump both `template-vertical` (root, for the generator + core-shared changes) and all five feature packages (for their binder changes). Per ADR-021's commit-path bump targeting, this is expected.
