Codifies a single source of truth for use-case input/output contracts: - Every use case exports xInputSchema/XInput and xOutputSchema/XOutput from its own file; controllers and tRPC procedures import the same schema. Use case body runtime-validates output via .parse before returning. - Controllers gain a co-located top-level `function presenter` (Lazar-style) that reshapes entity output to view DTO; controller return type = ReturnType<typeof presenter>. - Domain error → TRPCError mapping moves to per-feature integrations/api/procedures.ts via a defineErrorMiddleware factory in core-shared/trpc/. core-shared never enumerates feature errors. - Per-feature public-API surface cleaned: feature root = contracts only (types, errors, schemas, IUseCase aliases, router type); new ./ui subpath holds query builders / components. 30 numbered rules using RFC-2119 MUST/SHOULD language so every enforceable decision is citable from CLAUDE.md and per-feature AGENTS.md during the post-Plan-9 doc-update pass. Includes file-shape templates, migration order (~9 commits), doc-update mapping per rule, and an ADR-013 mandate. Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
Use-Case Input/Output Unification, Presenter Pattern, Feature-Scoped Error Mapping & Public-Surface Cleanup — Design Spec
Date: 2026-05-06
Status: Approved (autonomous execution authorized)
Author: Claude Opus 4.7 (1M context)
Reviewer: Danijel
Supersedes (parts of): §7 of 2026-05-05-lazar-pattern-conformance-design.md (controller-local input schema → schema lives in use-case file; controllers gain a presenter; output is now schema-validated).
Extends: ADRs 008 (per-feature DI), 011 (TDD foundation), 012 (Lazar conformance).
1. Goal
Make the use case the single source of truth for its own input and output contracts. Today, three layers (tRPC procedure, controller, use case) each define their own version of the same shape, drifting subtly (e.g. tRPC's z.object({ username: z.string() }) is laxer than the controller's z.object({ username: z.string().min(3).max(31) })). After this refactor:
- Every use case exports
xInputSchema+XInputandxOutputSchema+XOutput. - Every consumer (controller, tRPC procedure, frontend form) imports the same schema/type — never redefines it.
- Use case output is runtime-validated before returning, not just TypeScript-typed.
- Controllers reshape entity output into view models via a co-located presenter function (Lazar pattern), and the controller's return type flows through tRPC to the client untouched.
- Domain errors are translated to
TRPCErrorby feature-scoped middleware —core-sharedprovides only the factory; each feature owns its own error→code map. - Per-feature public API is cleaned up: feature-root export = pure contracts; new
./uisubpath = UI artifacts (query builders, components).
The success criterion is operational: a developer (or agentic worker) opening any use-case file should see the complete contract for that operation in one place, and every other layer should be importing from there — not defining its own.
2. Non-goals
- No DI library swap. Stay on inversify with
.toDynamicValuefor factory bindings (per ADR-008, ADR-012). - No new "view layer" or
presenters/folder. Presenters live as top-level functions inside their controller file, faithful to Lazar. - No instrumentation/Sentry wrapping. Lazar's reference uses
instrumentationService.startSpanaround presenters and controllers; we still skip per ADR-012 §Adaptations. - No output stripping for security as this plan's primary concern. Presenters CAN be used to omit fields (e.g.,
passwordHash); this plan establishes the hook but does not enumerate what each presenter must strip — that's a per-feature concern handled when each presenter is written. - No renaming of existing test patterns or DI symbol conventions. Tests stay direct-injection; symbols stay per-feature.
- No retroactive change to the Plan 8 doc-update pass. The deferred CLAUDE.md / AGENTS.md / guides update pauses; it resumes after this plan lands so docs are written once for the post-Plan-9 state.
3. Rules (the contract — RFC 2119 language)
These rules govern all five features (auth, blog, media, marketing-pages, navigation). Every rule is enforceable by reading the file; many are also enforced by tests.
3.A — Use-case file rules
R1 — Input schema in use-case file. Every use case MUST export xInputSchema (a z.ZodObject, not a primitive Zod type) and XInput = z.infer<typeof xInputSchema> from the same file as the factory. The schema MUST be the only place this input shape is defined in the package. Use cases that conceptually take a single primitive (e.g. an id string) MUST wrap it: z.object({ id: z.string() }).strict(). This keeps tRPC .input(xInputSchema), controller safeParse, and frontend forms consistent in their object-shaped contract.
R2 — Output schema in use-case file (when output exists). Every non-void use case MUST export xOutputSchema and XOutput = z.infer<typeof xOutputSchema>. Output schemas SHOULD reuse entity schemas where possible (z.array(articleSchema), articleSchema.pick({ id: true, slug: true }), etc.).
R3 — Runtime output validation. Every non-void use-case body MUST end with xOutputSchema.parse(result) (or call .parse on intermediate results) before returning. Returning unparsed repository data is forbidden — repositories are trusted only for the type the use case asserts.
R4 — Factory function pattern. Every use case MUST be a factory: (deps) => async (input: XInput) => Promise<XOutput | void>. Each MUST export IXUseCase = ReturnType<typeof xUseCase>. (Plan 8 R4–R5 retained; this is the carryover.)
R5 — Void inputs are uniform. Use cases without input MUST still export xInputSchema = z.object({}).strict() and XInput. tRPC procedures use .input(xInputSchema) uniformly. There is no .query(handler) without .input(...).
R6 — Domain error class names. Every domain error class constructor MUST set this.name = "<ClassName>". (Belt-and-braces; instanceof is the primary discriminator, but name ensures correct serialization and stack traces.)
3.B — Controller file rules
R7 — One controller per use case. No multi-method controller files. (Plan 8 R7 retained.)
R8 — Controller is a factory. (useCase: IXUseCase, …) => async (input: unknown) => Promise<…>. Each exports IXController = ReturnType<typeof xController>. (Plan 8 R8 retained.)
R9 — Controller validates with the use-case schema. Controllers MUST xInputSchema.safeParse(input) (importing the schema from the use-case file), throw InputParseError on failure with cause: parsed.error, and pass parsed.data to the use case. Controllers MUST NOT redefine input schemas locally.
R10 — Controller input is unknown. The controller's input parameter MUST be typed unknown (not Partial<XInput>, not XInput). The schema is the gate; types after parsing flow from parsed.data.
R11 — Presenter for non-void output. Every controller whose use case returns non-void MUST define a top-level function presenter(value: XOutput): SomeView and call presenter(result) before returning. The controller's return type MUST be Promise<ReturnType<typeof presenter>>. Identity presenters ((x) => x) are permitted and expected for trivial pass-through cases — the function MUST exist regardless, so adding a transform later is a one-line edit instead of a structural change.
R12 — No presenter for void output. Controllers whose use case returns void (e.g., signOutController, deleteMediaController) MUST NOT have a presenter and MUST return Promise<void>. This is the single carve-out from R11.
3.C — tRPC integration rules
R13 — Feature-scoped procedure. Every feature MUST export an xProcedure from packages/<feature>/src/integrations/api/procedures.ts, built via t.procedure.use(defineErrorMiddleware([...])) from @repo/core-shared/trpc/define-error-middleware. Each tuple in the middleware list pairs a feature error class constructor with a TRPC_ERROR_CODE_KEY.
R14 — Routers use xProcedure. Feature routers (integrations/api/router.ts) MUST use the feature's own xProcedure, not the bare t.procedure and not another feature's procedure.
R15 — tRPC procedures use xInputSchema. Every procedure MUST be .input(xInputSchema) — the same schema imported by the controller. Routers MUST NOT redefine input schemas.
R16 — Errors map per-feature, never per-monorepo. core-shared MUST NOT enumerate any feature error class. Each feature owns its full error→TRPC_ERROR_CODE_KEY map in its own procedures.ts.
R17 — defineErrorMiddleware discriminates by instanceof. The core-shared factory uses e instanceof Ctor (not e.name) to match. Features pass their error class constructors directly to the factory.
3.D — Public-API surface rules
R18 — Feature root exports contracts only. packages/<feature>/src/index.ts MUST export only:
- Domain types (
Article,Session, …) - Domain errors (
ArticleNotFoundError,InputParseError, …) - All
xInputSchema,XInput,xOutputSchema,XOutput(from each use case) IXUseCase,IXControllertype aliases (optional but recommended for cross-feature consumers)- The router type alias (
BlogRouter) - Feature constants (
SESSION_COOKIE, etc.)
It MUST NOT export query builders, React components, or anything depending on React / TanStack Query / browser APIs.
R19 — ./ui subpath is mandatory. Each feature's package.json MUST list "./ui": "./src/ui/index.ts" in exports. UI artifacts (query builders, React components) MUST be re-exported from src/ui/index.ts and only reachable via this subpath.
R20 — Apps import queries from ./ui. App code (apps/web-next, apps/web-tanstack) MUST import query builders from @repo/<feature>/ui, not from @repo/<feature>.
R21 — Schemas reachable from feature root. Frontend forms requiring runtime validation (e.g. react-hook-form + zodResolver) import schemas from @repo/<feature> directly (root export). No new ./schemas subpath.
3.E — DI rules
R22 — Factory bindings via .toDynamicValue(). Use cases and controllers MUST be bound with .toDynamicValue((ctx) => factoryFn(ctx.container.get(...))). Repositories and services MAY continue to use .to(MockClass) or .toConstantValue(realInstance) (Plan 8 pattern).
R23 — DI symbols expanded. Every use case and controller MUST have a corresponding symbol in <FEATURE>_SYMBOLS (IXUseCase, IXController keys). (Plan 8 R12 retained.)
3.F — Test rules
R24 — Direct injection. Use-case and controller tests MUST construct mock dependencies and inject them into the factory; they MUST NOT call container.unbind/bind for use cases or controllers. (Plan 8 R14 retained.)
R25 — Output-validation tests. Each non-void use case MUST have a test asserting that xOutputSchema.parse throws when the repository returns a malformed value. The test seeds the mock repo with a non-conforming value and asserts the use case throws a Zod error.
R26 — Router error-mapping test. Each feature MUST have at least one router test asserting that a thrown domain error is translated to the expected TRPCError code (e.g., ArticleNotFoundError → TRPCError({ code: "NOT_FOUND" })). Calling via xRouter.createCaller({}) and asserting on the thrown error proves the middleware is wired.
R27 — Presenter shape tests. When a presenter strips, renames, or transforms fields, the controller test MUST assert the resulting view shape (omitted fields absent, transformed fields present in expected form). Identity presenters do not require this test.
R28 — Controller tests assert on view, not entity. When the controller has a non-identity presenter, controller tests MUST assert against the view shape returned by the presenter, not the use case's XOutput. (This catches regressions where the presenter accidentally short-circuits to (x) => x.)
3.G — Governance rules
R29 — ADR-013. This plan's adoption MUST be recorded in docs/decisions/adr-013-input-output-unification.md, written during the final commit of Plan 9. The ADR MUST link from ADR-008 (per-feature DI), ADR-011 (TDD foundation), and ADR-012 (Lazar conformance) as a follow-up decision.
R30 — Refactor changelog. A docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md MUST be scaffolded at the start of Plan 9 and populated incrementally per task — same structure as the Plan 8 changelog (file renames, files added/deleted, pattern changes, DI changes, test refactor patterns, open issues, and a doc-update checklist deferring CLAUDE.md / AGENTS.md / guides edits to a single follow-up pass).
4. Canonical file shapes (the templates new code copies)
4.1 — Use-case template
// packages/<feature>/src/application/use-cases/<verb-noun>.use-case.ts
import { z } from "zod";
import { entitySchema, type Entity } from "../../entities/models/<entity>";
import type { IXRepository } from "../repositories/<x>.repository.interface";
// ── Input ────────────────────────────────────────────────────────────────
export const xInputSchema = z
.object({
/* ... fields, with all constraints ... */
})
.strict();
export type XInput = z.infer<typeof xInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
export const xOutputSchema = z.array(entitySchema); // or entitySchema.pick(...) / a custom shape
export type XOutput = z.infer<typeof xOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IXUseCase = ReturnType<typeof xUseCase>;
export const xUseCase =
(xRepository: IXRepository) =>
async (input: XInput): Promise<XOutput> => {
const result = await xRepository.x(input);
return xOutputSchema.parse(result); // R3 — runtime guard
};
Void-input variant: xInputSchema = z.object({}).strict(). Void-output variant: omit xOutputSchema/XOutput entirely; factory returns Promise<void> and body omits the parse call.
4.2 — Controller template
// packages/<feature>/src/interface-adapters/controllers/<verb-noun>.controller.ts
import { InputParseError } from "../../entities/errors/common";
import {
xInputSchema,
type IXUseCase,
type XOutput,
} from "../../application/use-cases/<verb-noun>.use-case";
// R11 — presenter exists even when identity, so future shaping is one edit.
function presenter(value: XOutput) {
return value;
// example for a real reshape:
// return value.map((v) => ({ id: v.id, label: v.title }));
}
export type IXController = ReturnType<typeof xController>;
export const xController =
(xUseCase: IXUseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = xInputSchema.safeParse(input);
if (!parsed.success) {
throw new InputParseError("Invalid <verb-noun> input", { cause: parsed.error });
}
const result = await xUseCase(parsed.data);
return presenter(result);
};
Void-output controller (R12) returns Promise<void> and omits the presenter.
4.3 — Procedures template (per feature)
// packages/<feature>/src/integrations/api/procedures.ts
import { t } from "@repo/core-shared/trpc/init";
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
import { InputParseError } from "../../entities/errors/common";
import { /* feature errors */ } from "../../entities/errors/<domain>";
export const xProcedure = t.procedure.use(
defineErrorMiddleware([
[InputParseError, "BAD_REQUEST"],
// [FeatureNotFoundError, "NOT_FOUND"],
// [FeatureAuthError, "UNAUTHORIZED"],
]),
);
4.4 — Router template
// packages/<feature>/src/integrations/api/router.ts
import { router } from "@repo/core-shared/trpc/init";
import { xProcedure } from "./procedures";
import { xInputSchema } from "../../application/use-cases/<verb-noun>.use-case";
import { xContainer } from "../../di/container";
import { X_SYMBOLS } from "../../di/symbols";
import type { IXController } from "../../interface-adapters/controllers/<verb-noun>.controller";
export const xRouter = router({
doX: xProcedure.input(xInputSchema).query(async ({ input }) => {
const ctrl = xContainer.get<IXController>(X_SYMBOLS.IXController);
return ctrl(input);
}),
});
4.5 — core-shared plumbing (new file)
// packages/core-shared/src/trpc/define-error-middleware.ts
import { t } from "./init";
import { TRPCError, type TRPC_ERROR_CODE_KEY } from "@trpc/server";
type ErrorCtor = new (...args: never[]) => Error;
export function defineErrorMiddleware(
map: ReadonlyArray<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>,
) {
return t.middleware(async ({ next }) => {
try {
return await next();
} catch (e) {
if (e instanceof Error) {
for (const [Ctor, code] of map) {
if (e instanceof Ctor) {
throw new TRPCError({ code, message: e.message, cause: e });
}
}
}
throw e;
}
});
}
Re-exported from packages/core-shared/src/trpc/index.ts (or alongside init.ts).
4.6 — Public-API surface (per-feature package.json)
{
"exports": {
".": "./src/index.ts", // R18 — contracts only
"./ui": "./src/ui/index.ts", // R19 — UI artifacts (NEW)
"./api": "./src/integrations/api/index.ts", // tRPC router (composition only)
"./cms": "./src/integrations/cms/index.ts", // Payload (composition only)
"./di/bind-production": "./src/di/bind-production.ts"
}
}
src/index.ts re-exports types, errors, schemas, IUseCase/IController aliases, router type, constants. src/ui/index.ts re-exports query builders and components.
5. Layered guarantees (what you can rely on at each stage)
| Stage | Guarantee | Source of guarantee |
|---|---|---|
| Frontend form validation | Same constraints the backend uses | Frontend imports xInputSchema from @repo/<feature> |
tRPC .input(xInputSchema) |
Wire-shape parsed; client receives typed input | tRPC's standard input handling |
Controller safeParse(xInputSchema) |
Re-validates regardless of caller (CLI/cron/test) | R9, R10 |
| Use case body | Receives XInput (already validated) |
R4 |
xOutputSchema.parse(result) |
Use-case output conforms to the declared shape, even if repo lies | R3, R25 |
Controller presenter(result) |
View-shape DTO independent of entity drift | R11, R28 |
| tRPC response | Inferred from controller return type → typed RouterOutputs on client |
tRPC's standard inference |
| Domain error throw | Translated to TRPCError with the right code, automatically |
R13–R17, R26 |
6. Migration order
Each numbered step is a single commit. TDD throughout.
core-sharedplumbing — adddefine-error-middleware.tswith unit test; re-export from package index.- Public-surface cleanup, feature-by-feature — for each of
auth,blog,marketing-pages,navigation,media:- Create
src/ui/index.tsre-exporting query builders. - Add
"./ui": "./src/ui/index.ts"toexports. - Remove query-builder exports from
src/index.ts. - Update consumers (
apps/web-next/**,apps/web-tanstack/**). - One commit per feature.
- Create
authmigration — schemas + outputs + presenters +procedures.ts+ router migration. TDD per use case. One commit.blogmigration — same pattern. One commit.marketing-pagesmigration — same pattern. One commit.navigationmigration — same pattern. One commit.mediamigration — same pattern. One commit.- Final verification —
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm buildgreen; spot-check tRPC error responses for each feature. One verification commit if any straggler imports surface. - Refactor changelog scaffold —
docs/superpowers/refactor-logs/2026-05-06-input-output-unification.mdpopulated incrementally throughout the above. Final summary commit.
Total: ~9 commits (1 plumbing + 5 surface cleanup + 5 feature migrations + 1 verification + 1 changelog summary; potentially fewer if surface cleanup folds into the per-feature commit).
7. Doc update mapping (where each rule lands)
After Plan 9 lands, the deferred Plan 8 doc-update pass resumes and now also picks up Plan 9 rules. Each rule maps to one or more docs:
| Rule(s) | Lands in |
|---|---|
| R1, R2, R3, R4, R5, R10 | CLAUDE.md Key Conventions; per-feature AGENTS.md use-case section |
| R6 | per-feature AGENTS.md errors section; ADR-013 (this plan's ADR) |
| R7, R8, R9, R11, R12 | CLAUDE.md Key Conventions; per-feature AGENTS.md controllers section |
| R13, R14, R15 | root AGENTS.md boundary section; per-feature AGENTS.md integrations/api section |
| R16, R17 | core-shared/AGENTS.md; ADR-013 |
| R18, R19, R20, R21 | root AGENTS.md Per-Package Conventions; per-feature AGENTS.md Exports section |
| R22, R23 | per-feature AGENTS.md DI section |
| R24, R25, R26, R27, R28 | docs/guides/tdd-workflow.md (mocking decision tree); docs/guides/testing-strategy.md (test placement); docs/decisions/adr-011-tdd-foundation.md appendix |
A new ADR-013 ("input/output schema unification + presenter pattern + feature-scoped error mapping") MUST be created and linked from the relevant prior ADRs (008, 011, 012). The Plan 8 refactor log gets a one-line "superseded by Plan 9 in §X" annotation.
8. Acceptance criteria
- ✅ Every use case exports
xInputSchema,XInput. Void inputs usez.object({}).strict(). - ✅ Every non-void use case exports
xOutputSchema,XOutputand calls.parse(...)before returning. - ✅ Every non-void controller defines a
function presenterand returnsPromise<ReturnType<typeof presenter>>. - ✅ Every feature has
integrations/api/procedures.tswith its own error map. - ✅
core-shared/trpc/define-error-middleware.tsexists; no central name→code map anywhere. - ✅ Per-feature
package.jsonhas./uisubpath;src/index.tsno longer re-exports query builders. - ✅ Apps import queries from
@repo/<feature>/ui. - ✅ At least one R25 (output-validation) test per use case; at least one R26 (router error-mapping) test per feature.
- ✅ Full suite:
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm buildgreen. - ✅ tRPC errors carry expected
codefor every domain error class (verified by R26 tests). - ✅ Refactor log
docs/superpowers/refactor-logs/2026-05-06-input-output-unification.mdcomplete.
9. Tradeoffs (recorded for future readers)
| Decision | Pro | Con |
|---|---|---|
| Schema in use-case file | One source of truth; contract co-located with behavior | Use-case file imports zod (already a feature dep) |
| Output schema + runtime parse | Catches "repo lied" bugs at the layer that owns the contract | Extra parse on every call (cheap; well-shaped Zod) |
| Presenter always (even identity) | Discoverable hook for transforms; uniform file shape | Extra function presenter(x) { return x; } line in many files |
Controller safeParse retained |
Transport-agnostic; CLI/cron/test paths protected | Schema runs twice on the tRPC path (negligible cost) |
| Feature-scoped error maps | Errors stay feature-owned; core-shared knows nothing about features |
Boilerplate procedures.ts per feature (one short file each) |
.toDynamicValue factory bindings |
Already in use; no DI library swap | Bindings are wordier than .to(Class) |
./ui subpath |
Apps depend only on UI through the UI surface | One extra package.json exports entry per feature |
| New ADR-013 | Architectural decision is auditable | One more ADR to maintain |
10. Out of scope (explicit)
- Frontend
react-hook-form/zodResolverwiring (R21 says schemas are reachable; actually wiring forms is per-feature future work). - ESLint custom rule to enforce R1–R28 (rules are doc-level for now; consider as a follow-up if drift is observed).
tsd(TypeScript-deno-style assertion) tests forIXUseCase/IXControllershapes — interesting, deferred.- Migration of
apps/cmsserver-action surface (cms admin uses Payload directly, not feature controllers). - Plan 8 doc-update pass — paused, resumes post-Plan-9.
11. Post-approval next step
Invoke superpowers:writing-plans to produce Plan 9 with concrete file-by-file steps for each migration commit, TDD checkpoints, and verification gates per the §6 order.