7.7 KiB
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:
-
Use-case file is the single source of truth for input AND output contracts. Every use case exports
xInputSchema(always az.ZodObject, even for void inputs viaz.object({}).strict()) and — for non-void use cases —xOutputSchema. The use-case body ends withxOutputSchema.parse(result)before returning. Type aliasesXInput/XOutput/IXUseCaseare exported alongside. -
Controllers consume the use-case schema; output passes through a co-located
function presenter. Controllers receiveunknown,safeParseagainstxInputSchema, throwInputParseErroron failure, then call the use case and pass the result through a top-levelfunction presenter(value: XOutput)defined in the same file. The controller's return type isPromise<ReturnType<typeof presenter>>. 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 returnPromise<void>(R12). -
Feature-scoped error→TRPCError middleware. Each feature's
integrations/api/procedures.tsexports anxProcedurebuilt fromt.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"], ...])). The factorydefineErrorMiddlewarelives incore-shared/trpc/; it discriminates byinstanceofand preserves the original error asTRPCError.cause.core-sharednever enumerates feature-specific error classes — each feature passes its own constructors in via its ownprocedures.ts. Routers use the feature'sxProcedureinstead of barepublicProcedureand.input(xInputSchema)instead of redefining input shapes. -
Per-feature public surface split. Feature root
.exports only contracts: domain types, domain errors, schemas,IXUseCase/IXControlleraliases, router type, constants. UI artifacts (query builders, future React components) move to a new./uisubpath (src/ui/index.ts). Apps that need queries import from@repo/<feature>/ui; apps that need the type-only contract import from@repo/<feature>.
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.codevia 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 intoreact-hook-form+zodResolverwith 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.tsboilerplate. 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'ssafeParse). 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/<feature>/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 usingerror.namediscrimination with a small global registry. Rejected because it violates feature ownership —core-sharedwould need to know about every feature's error classes. ThedefineErrorMiddlewarefactory cleanly inverts the dependency:core-sharedprovides 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
./schemassubpath. 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 buildgreen.- Five feature-level router error-mapping tests demonstrate domain
error →
TRPCError.codetranslation works end-to-end.
References
- Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (feature conventions)