# 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 middleware** — `core-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 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`, `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., `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

```typescript
// 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

```typescript
// 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)

```typescript
// 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

```typescript
// 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)

```typescript
// 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`)

```jsonc
{
  "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.

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 verification** — `pnpm 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 scaffold** — `docs/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 R1–R28 (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.
