# ADR-013: Use-Case Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping **Status:** Accepted **Date:** 2026-05-06 **Supersedes:** none — extends ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (feature conventions) ## Context ADR-012 established factory-function use cases and one-controller- per-use-case. But the input contract was still defined three times — once in the tRPC procedure's `.input(z.object({...}))`, once in the controller's local `const inputSchema`, and once implicitly in the use case's TypeScript parameter type. The three definitions drifted: the controller's `z.string().min(3).max(31)` was stricter than the tRPC version's `z.string()`. Output validation was TypeScript-only — repositories could return malformed values and use cases happily passed them through. There was also no consistent error-translation between domain errors (`ArticleNotFoundError`, `AuthenticationError`, …) and `TRPCError`, meaning the wire response code was unpredictable per feature. Per-feature public-API surfaces conflated UI artifacts (query builders imported React Query) with pure contracts (entity types) on the same top-level export, making "what does this package expose to whom" muddy. ## Decision Adopt four interlocking patterns, codified as 30 RFC-2119 rules in the spec: 1. **Use-case file is the single source of truth for input AND output contracts.** Every use case exports `xInputSchema` (always a `z.ZodObject`, even for void inputs via `z.object({}).strict()`) and — for non-void use cases — `xOutputSchema`. The use-case body ends with `xOutputSchema.parse(result)` before returning. Type aliases `XInput`/`XOutput`/`IXUseCase` are exported alongside. 2. **Controllers consume the use-case schema; output passes through a co-located `function presenter`.** Controllers receive `unknown`, `safeParse` against `xInputSchema`, throw `InputParseError` on failure, then call the use case and pass the result through a top-level `function presenter(value: XOutput)` defined in the same file. The controller's return type is `Promise>`. Identity presenters are permitted and expected for pass-through cases — the function form must always exist (R11) so adding a transform is a one-line edit. Void-output controllers (e.g., `signOutController`, `deleteMediaController`) skip the presenter and return `Promise` (R12). 3. **Feature-scoped error→TRPCError middleware.** Each feature's `integrations/api/procedures.ts` exports an `xProcedure` built from `t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"], ...]))`. The factory `defineErrorMiddleware` lives in `core-shared/trpc/`; it discriminates by `instanceof` and preserves the original error as `TRPCError.cause`. **`core-shared` never enumerates feature-specific error classes** — each feature passes its own constructors in via its own `procedures.ts`. Routers use the feature's `xProcedure` instead of bare `publicProcedure` and `.input(xInputSchema)` instead of redefining input shapes. 4. **Per-feature public surface split.** Feature root `.` exports only contracts: domain types, domain errors, schemas, `IXUseCase`/`IXController` aliases, router type, constants. UI artifacts (query builders, future React components) move to a new `./ui` subpath (`src/ui/index.ts`). Apps that need queries import from `@repo//ui`; apps that need the type-only contract import from `@repo/`. ## Consequences ### Positive - **Single source of truth for I/O contracts.** Schema drift is no longer possible — there's one definition, imported by everyone. - **Runtime-validated outputs.** `xOutputSchema.parse(...)` catches "repo returned malformed data" bugs at the layer that owns the contract, instead of silently flowing wrong shapes downstream. - **Predictable error responses.** Every domain error maps to a known `TRPCError.code` via the per-feature middleware; clients can rely on status codes. - **Discoverable transforms via presenter.** When a view needs to drop fields, rename them, or serialize dates, the presenter function is already there — change one function body. No structural refactor. - **Clean public surface.** Feature root packages no longer pretend to be UI packages; apps make explicit choices about what they need. - **Frontend gets schemas for free.** Forms can `import { signInInputSchema } from "@repo/auth"` and feed it into `react-hook-form` + `zodResolver` with the same constraints the backend enforces. ### Negative - **More code.** Every use case grows by ~10 lines (input + output schema + parse). Every controller grows by ~5 lines (presenter, even if identity). Acceptable cost for the consistency. - **Per-feature `procedures.ts` boilerplate.** Five new files (~10 lines each) — one per feature. Maintaining the error map is one of the few feature-level chores; new error classes need adding to the map. - **Schemas run twice on the tRPC path** (tRPC's `.input()` parse + controller's `safeParse`). Negligible cost; zero behavioral risk because both use the same schema. Defense-in-depth value when the controller is invoked from non-tRPC entry points. - **Apps with existing imports may need updating** — `articleBySlugQuery`, `pageBySlugQuery`, etc. now live behind `@repo//ui`. (At the time of this ADR, no apps consume these yet, so the cost is forward-only.) ## Alternatives considered - **Keep schemas in controllers.** The reference pattern has only one validation layer (server actions skip `.input()`), so one schema is sufficient. Our entry point is tRPC, which insists on a schema for type inference — putting the canonical schema in the controller and exporting it for the router was considered. Rejected because the use case is the contract owner; schemas describe the _operation_, not the _transport_. - **Centralized error-name → code map in `core-shared`.** Considered using `error.name` discrimination with a small global registry. Rejected because it violates feature ownership — `core-shared` would need to know about every feature's error classes. The `defineErrorMiddleware` factory cleanly inverts the dependency: `core-shared` provides the plumbing, features pass their own constructors. - **Validate outputs only in tests.** Considered using TypeScript types alone for output, deferring runtime validation to contract-suite tests. Rejected because the cost of `.parse()` on return is trivial and the bug-catching value at runtime is real (Payload integrations have surprised us before). - **Presenters only when reshaping.** Considered limiting presenters to cases with actual transforms. Rejected because the discoverable hook for future shaping is worth the trivial identity-function boilerplate. - **Presenters in a separate `presenters/` folder.** Considered as a concession to "controllers = thin orchestration". Rejected because co-locating the presenter with its controller keeps the contract visible in one file. - **Shared `./schemas` subpath.** Considered exposing schemas only via a dedicated subpath instead of the feature root. Rejected because schemas ARE feature contracts — they belong with the other contracts (types, errors). Adding a fourth subpath felt like ceremony. ## Acceptance criteria - Tests: 360 total. Coverage: every acceptance rule represented. - `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm build` green. - Five feature-level router error-mapping tests demonstrate domain error → `TRPCError.code` translation works end-to-end. ## References - Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (feature conventions)