Records the Plan 9 architectural decision (schemas in use-case file, runtime output validation, presenter pattern, feature-scoped error middleware, ./ui subpath split). ADR-012 gets a one-line cross- reference to the new ADR. Refactor log gets a Summary section with commit table and conformance checklist. Plan 9 complete. The deferred doc-update pass (CLAUDE.md / AGENTS.md / guides) — combined with the still-pending Plan 8 items — is the next follow-up. Refactor log: Summary, doc-update checklist Spec: R29, R30
166 lines
8.4 KiB
Markdown
166 lines
8.4 KiB
Markdown
# 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 (Lazar conformance)
|
||
**Spec:** docs/superpowers/specs/2026-05-06-input-output-unification-design.md
|
||
**Plan:** docs/superpowers/plans/2026-05-06-plan-9-io-unification.md
|
||
**Refactor log:** docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
|
||
|
||
## Context
|
||
|
||
Plan 8 (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<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 return `Promise<void>` (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/<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.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/<feature>/ui`.
|
||
(At Plan 9 land time, no apps consume these yet, so the cost is
|
||
forward-only.)
|
||
|
||
## Alternatives considered
|
||
|
||
- **Keep schemas in controllers (Lazar's reference pattern).** Lazar
|
||
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 Lazar's actual rule
|
||
(presenter only when there's a transform). Rejected (R11) 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
|
||
Lazar's reference co-locates the presenter with its consumer — the
|
||
controller — keeping 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 verification (Task 8, 2026-05-06)
|
||
|
||
- All Plan 9 acceptance criteria from spec §8 met.
|
||
- Tests: 360 total. Spec coverage: every R1–R28 represented.
|
||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
|
||
&& pnpm build` green.
|
||
- Five feature-level R26 router-error-mapping tests demonstrate domain
|
||
error → `TRPCError.code` translation works end-to-end.
|
||
|
||
## References
|
||
|
||
- Spec: `docs/superpowers/specs/2026-05-06-input-output-unification-design.md`
|
||
- Plan: `docs/superpowers/plans/2026-05-06-plan-9-io-unification.md`
|
||
- Refactor log: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`
|
||
- Reference (Lazar's blog post + repo): https://github.com/nikolovlazar/nextjs-clean-architecture
|
||
- Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
|