--- 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(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 `` 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: `.` 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.