Files
agentic-dev/docs/superpowers/specs/2026-05-06-input-output-unification-design.md
Danijel Martinek 4b6b61ab4c docs(spec): Plan 9 — input/output unification + presenter pattern + feature-scoped error mapping + public-surface cleanup
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>
2026-05-06 09:17:50 +02:00

24 KiB
Raw Blame History

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 + XInput and xOutputSchema + 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 TRPCError by feature-scoped middlewarecore-shared provides only the factory; each feature owns its own error→code map.
  • Per-feature public API is cleaned up: feature-root export = pure contracts; new ./ui subpath = 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 .toDynamicValue for 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.startSpan around 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 R4R5 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, IXController type 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., ArticleNotFoundErrorTRPCError({ 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 R13R17, R26

6. Migration order

Each numbered step is a single commit. TDD throughout.

  1. core-shared plumbing — add define-error-middleware.ts with unit test; re-export from package index.
  2. Public-surface cleanup, feature-by-feature — for each of auth, blog, marketing-pages, navigation, media:
    • Create src/ui/index.ts re-exporting query builders.
    • Add "./ui": "./src/ui/index.ts" to exports.
    • Remove query-builder exports from src/index.ts.
    • Update consumers (apps/web-next/**, apps/web-tanstack/**).
    • One commit per feature.
  3. auth migration — schemas + outputs + presenters + procedures.ts + router migration. TDD per use case. One commit.
  4. blog migration — same pattern. One commit.
  5. marketing-pages migration — same pattern. One commit.
  6. navigation migration — same pattern. One commit.
  7. media migration — same pattern. One commit.
  8. Final verificationpnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm build green; spot-check tRPC error responses for each feature. One verification commit if any straggler imports surface.
  9. Refactor changelog scaffolddocs/superpowers/refactor-logs/2026-05-06-input-output-unification.md populated 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 use z.object({}).strict().
  • Every non-void use case exports xOutputSchema, XOutput and calls .parse(...) before returning.
  • Every non-void controller defines a function presenter and returns Promise<ReturnType<typeof presenter>>.
  • Every feature has integrations/api/procedures.ts with its own error map.
  • core-shared/trpc/define-error-middleware.ts exists; no central name→code map anywhere.
  • Per-feature package.json has ./ui subpath; src/index.ts no 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 build green.
  • tRPC errors carry expected code for every domain error class (verified by R26 tests).
  • Refactor log docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md complete.

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/zodResolver wiring (R21 says schemas are reachable; actually wiring forms is per-feature future work).
  • ESLint custom rule to enforce R1R28 (rules are doc-level for now; consider as a follow-up if drift is observed).
  • tsd (TypeScript-deno-style assertion) tests for IXUseCase/IXController shapes — interesting, deferred.
  • Migration of apps/cms server-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.