# Plan 9 — Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping + Public-Surface Cleanup > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make every use case the single source of truth for its input AND output contract. Schemas live in the use-case file, are imported by both the controller and the tRPC procedure, and the use-case body runtime-validates output via `.parse(...)`. Controllers gain co-located `function presenter(...)` (Lazar pattern). Domain errors map to `TRPCError` via per-feature middleware (`defineErrorMiddleware` factory in `core-shared/trpc/`). Per-feature public surface is split: root `.` = contracts; new `./ui` subpath = UI artifacts. **Architecture:** Use-case file becomes the only place an input/output shape is defined; everywhere else imports `xInputSchema` / `xOutputSchema` / `XInput` / `XOutput`. Each feature gets `integrations/api/procedures.ts` exporting an `xProcedure` built from `t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"], ...]))`. Routers swap `publicProcedure` → `xProcedure` and `.input(z.object({...}))` → `.input(xInputSchema)`. Each feature's `src/index.ts` keeps only contracts (types, errors, schemas, `IXUseCase` aliases, router type, constants). Query builders move to `src/ui/index.ts` exposed via the new `./ui` subpath. **Tech Stack:** TypeScript, Zod 3, tRPC 11, InversifyJS 6, Vitest 3, `@repo/core-shared`, `@repo/core-testing`. **Spec:** `docs/superpowers/specs/2026-05-06-input-output-unification-design.md` — read first if any task is unclear. **Branch:** `feature/io-unification` (or execute on `main` directly per project convention). **Refactor changelog:** Maintain `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` throughout — every task ends with a changelog update. --- ## Cross-cutting conventions (re-read at start of every task) - **TDD always** — failing test, run, RED, implement, run, GREEN, refactor, commit. - **Source files use relative imports**; test files use `@/` alias. - **No central error map** — `core-shared` MUST NOT enumerate any feature's error class. Each feature's `procedures.ts` owns its tuples. - **Object-shaped inputs only** — every `xInputSchema` is a `z.ZodObject` (use `z.object({}).strict()` for void; wrap primitive args in an object). - **Presenter always for non-void output** — even identity `(x) => x` (R11). Void use cases skip the presenter (R12). - **Don't touch CLAUDE.md / AGENTS.md / docs/guides/** during this plan — those updates are deferred per spec §10. - **Commit per task** with the message in each task's final step. - **After each task** — `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries`. All green. - **Update the refactor changelog** at the end of each task before commit. --- ## Task 1: Refactor changelog scaffold **Files:** - Create: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` - [ ] **Step 1: Verify the directory exists** ```bash ls docs/superpowers/refactor-logs/ ``` Expected: existing `2026-05-05-lazar-pattern-conformance.md` is listed; the directory exists. - [ ] **Step 2: Create the changelog file** Write `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` with this exact content: ```markdown # Refactor Changelog — Input/Output Unification + Presenter + Error Middleware + Public-Surface Cleanup **Started:** 2026-05-06 **Spec:** [2026-05-06-input-output-unification-design.md](../specs/2026-05-06-input-output-unification-design.md) **Plan:** [2026-05-06-plan-9-io-unification.md](../plans/2026-05-06-plan-9-io-unification.md) **Branch:** feature/io-unification This document captures every architectural change made during Plan 9 execution, organized by category. After the plan is merged, use the "Doc update checklist" at the bottom to update external docs in a single follow-up pass — combined with the still-pending Plan 8 doc-update items so docs are written once for the post-Plan-9 state. --- ## 1. Files added (populated as work progresses) ## 2. Files modified (populated as work progresses) ## 3. Pattern changes (code-level) ### 3.1 Use-case files — input + output schemas + runtime parse (populated when use cases are migrated) ### 3.2 Controller files — presenter + unknown input + view return type (populated when controllers are migrated) ### 3.3 tRPC integration — feature-scoped procedures, schema reuse from use cases (populated when routers are migrated) ## 4. Error-middleware adoption (populated when defineErrorMiddleware is wired in core-shared and consumed by features) ## 5. Public-API surface ### 5.1 ./ui subpath added per feature (populated as features adopt the subpath) ### 5.2 Feature root index.ts cleanup (populated as features clean up their root exports) ## 6. Test additions ### 6.1 R25 — output-validation tests (use case) (populated when use cases gain malformed-input "repo lied" tests) ### 6.2 R26 — router error-mapping tests (populated when feature routers gain TRPCError-translation tests) ### 6.3 R27/R28 — presenter shape tests (populated when controllers with non-identity presenters add view-shape assertions) ## 7. Open issues / deferred decisions (populated as encountered) --- ## Doc update checklist (deferred — combined with paused Plan 8 doc pass) After Plan 9 lands, the still-pending Plan 8 doc-update items resume and now also pick up Plan 9 rules. Each item below covers BOTH plans' material so we touch every file once. - [ ] `CLAUDE.md` — Key Conventions: append schema-in-use-case rule, presenter rule, controller `unknown` input rule, `./ui` subpath rule, schema-export-from-root rule - [ ] `AGENTS.md` (root) — Per-Package Conventions: document `./ui` subpath, schema reachability from feature root, factory-bound use cases / controllers (carry-over from Plan 8) - [ ] `docs/guides/adding-a-feature.md` — restructure to use the Plan-9 use-case template (with input + output schemas + parse), the Plan-9 controller template (with presenter), and the new `procedures.ts` step (per-feature error map) - [ ] `docs/guides/tdd-workflow.md` — update mock decision tree + worked example to factory injection (Plan 8) + R25 output-validation test pattern (Plan 9) + R26 router-error-mapping test pattern (Plan 9) - [ ] `docs/guides/testing-strategy.md` — Mocking section: direct factory injection (Plan 8); R25/R26/R27/R28 test obligations (Plan 9) - [ ] `docs/architecture/vertical-feature-spec.md` — §6 file shape: schemas + presenter + procedures.ts; §10 testing: R25/R26/R27/R28 - [ ] `docs/architecture/overview.md` — data-flow box: add input schema, output schema, presenter, error-middleware lanes - [ ] `docs/architecture/dependency-flow.md` — verify (no expected change beyond a `./ui` mention) - [ ] `docs/decisions/adr-012-lazar-conformance.md` — append note that input/output unification + presenter + error middleware land in ADR-013 - [ ] `docs/decisions/adr-013-input-output-unification.md` — created in this plan's final task (Task 9); link from prior ADRs - [ ] Per-feature `AGENTS.md` (auth/blog/media/marketing-pages/navigation) — Plan 8 file paths + Plan 9 schema/presenter/procedures patterns - [ ] `packages/core-testing/AGENTS.md` — note R25/R26 test patterns - [ ] `packages/core-shared/AGENTS.md` — document `defineErrorMiddleware` and the `t` re-export - [ ] Plan 8 plan/spec — add a one-line annotation that some controller/router patterns shifted in Plan 9; link to this changelog --- ## Notes for the doc-update pass author When updating external docs, apply these substitutions globally: | Old reference | New reference | |---|---| | Use case takes `{ x }` directly typed | Use case takes `XInput` (`z.infer`); schema lives in same file | | Use case returns `Promise` | Use case returns `Promise` validated by `xOutputSchema.parse(...)` | | Controller imports `z` and defines local `inputSchema` | Controller imports `xInputSchema` from the use-case file | | Controller input typed `Partial>` | Controller input typed `unknown` | | Controller returns `Promise` | Controller has `function presenter(value: XOutput)`; returns `Promise>` (or `Promise` for void use cases) | | Router uses `publicProcedure` from `core-shared/trpc/init` | Router uses feature's `xProcedure` from `./procedures` | | Router redefines input schema as `z.object({...})` in `.input(...)` | Router uses `.input(xInputSchema)` imported from the use-case file | | Domain error reaches the wire as a generic `TRPCError({ code: "INTERNAL_SERVER_ERROR" })` | Domain error mapped to specific code by `defineErrorMiddleware` in `procedures.ts` | | Frontend imports `articleBySlugQuery` from `@repo/blog` | Frontend imports from `@repo/blog/ui` | ``` - [ ] **Step 3: Verify the file is readable** ```bash ls -la docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md head -20 docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md ``` - [ ] **Step 4: Commit** ```bash git add docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md git commit -m "$(cat <<'EOF' docs(refactor-log): scaffold Plan 9 input/output unification changelog Empty section template plus the doc-update checklist that the post-Plan-9 follow-up pass will work through (combined with the still- pending Plan 8 items so docs are written once). Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md §10, R30. EOF )" ``` --- ## Task 2: `core-shared` plumbing — `defineErrorMiddleware` **Files:** - Modify: `packages/core-shared/src/trpc/init.ts` (export `t` instance) - Create: `packages/core-shared/src/trpc/define-error-middleware.ts` - Create: `packages/core-shared/src/trpc/define-error-middleware.test.ts` - Modify: `packages/core-shared/package.json` (add export `./trpc/define-error-middleware`) - [ ] **Step 1: Read the current `init.ts`** ```bash cat packages/core-shared/src/trpc/init.ts ``` Expected to see (verify exact content before editing): ```typescript import { initTRPC } from "@trpc/server"; import superjson from "superjson"; const t = initTRPC.create({ transformer: superjson, }); export const router = t.router; export const publicProcedure = t.procedure; export const middleware = t.middleware; ``` - [ ] **Step 2: Add `t` to the exports of `init.ts`** Edit `packages/core-shared/src/trpc/init.ts`. Change `const t = initTRPC.create(...)` to `export const t = initTRPC.create(...)`. Final file: ```typescript import { initTRPC } from "@trpc/server"; import superjson from "superjson"; export const t = initTRPC.create({ transformer: superjson, }); export const router = t.router; export const publicProcedure = t.procedure; export const middleware = t.middleware; ``` - [ ] **Step 3: Write the failing test** Create `packages/core-shared/src/trpc/define-error-middleware.test.ts`: ```typescript import { describe, expect, it } from "vitest"; import { TRPCError } from "@trpc/server"; import { t } from "@/trpc/init"; import { defineErrorMiddleware } from "@/trpc/define-error-middleware"; class FooNotFoundError extends Error { constructor(message: string) { super(message); this.name = "FooNotFoundError"; } } class FooBadRequestError extends Error { constructor(message: string) { super(message); this.name = "FooBadRequestError"; } } const errorRouter = t.router({ notFound: t.procedure .use( defineErrorMiddleware([ [FooNotFoundError, "NOT_FOUND"], [FooBadRequestError, "BAD_REQUEST"], ]), ) .query(() => { throw new FooNotFoundError("nope"); }), badRequest: t.procedure .use( defineErrorMiddleware([ [FooNotFoundError, "NOT_FOUND"], [FooBadRequestError, "BAD_REQUEST"], ]), ) .query(() => { throw new FooBadRequestError("oops"); }), unmapped: t.procedure .use(defineErrorMiddleware([[FooNotFoundError, "NOT_FOUND"]])) .query(() => { throw new Error("plain"); }), }); describe("defineErrorMiddleware", () => { it("translates a mapped error to TRPCError with the configured code", async () => { const caller = errorRouter.createCaller({}); await expect(caller.notFound()).rejects.toMatchObject({ code: "NOT_FOUND", }); }); it("translates a different mapped error to a different code", async () => { const caller = errorRouter.createCaller({}); await expect(caller.badRequest()).rejects.toMatchObject({ code: "BAD_REQUEST", }); }); it("rethrows unmapped errors without translation", async () => { const caller = errorRouter.createCaller({}); await expect(caller.unmapped()).rejects.toBeInstanceOf(Error); // Not a TRPCError — propagates so tRPC default handling kicks in await expect(caller.unmapped()).rejects.not.toBeInstanceOf(TRPCError); }); it("preserves the original error as the cause", async () => { const caller = errorRouter.createCaller({}); try { await caller.notFound(); throw new Error("expected throw"); } catch (e) { expect(e).toBeInstanceOf(TRPCError); const trpcErr = e as TRPCError; expect(trpcErr.cause).toBeInstanceOf(FooNotFoundError); expect((trpcErr.cause as Error).message).toBe("nope"); } }); }); ``` - [ ] **Step 4: Run the test to verify RED** ```bash pnpm test --filter @repo/core-shared -- define-error-middleware ``` Expected: FAIL with `Cannot find module '@/trpc/define-error-middleware'` (or equivalent — the module doesn't exist yet). - [ ] **Step 5: Implement `defineErrorMiddleware`** Create `packages/core-shared/src/trpc/define-error-middleware.ts`: ```typescript import { TRPCError } from "@trpc/server"; import type { TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc"; import { t } from "./init"; type ErrorCtor = new (...args: never[]) => Error; /** * Build a tRPC middleware that translates domain errors to TRPCError. * * Each tuple pairs a constructor with a TRPC error code. The middleware * runs the procedure body inside a try/catch; on `instanceof Ctor`, * it throws a TRPCError with the configured code and the original error * preserved as `.cause`. Unmapped errors propagate untouched (tRPC's * default INTERNAL_SERVER_ERROR handling applies). * * Owned by features: each feature passes its own constructors in. * core-shared never enumerates feature-specific error classes. */ export function defineErrorMiddleware( map: ReadonlyArray, ) { 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; } }); } ``` - [ ] **Step 6: Run the test to verify GREEN** ```bash pnpm test --filter @repo/core-shared -- define-error-middleware ``` Expected: 4 tests pass. - [ ] **Step 7: Add the new module to `package.json` exports** Edit `packages/core-shared/package.json`. Add a new entry under `exports`: ```jsonc { "exports": { ".": "./src/index.ts", "./payload": "./src/payload/index.ts", "./trpc/init": "./src/trpc/init.ts", "./trpc/context": "./src/trpc/context.ts", "./trpc/define-error-middleware": "./src/trpc/define-error-middleware.ts" } } ``` - [ ] **Step 8: Verify boundaries + typecheck** ```bash pnpm install pnpm typecheck pnpm lint pnpm test pnpm turbo boundaries ``` All green. If `pnpm install` is a no-op because `package.json` only added an `exports` entry, that's fine. - [ ] **Step 9: Update changelog** Append to `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`: Under §1 Files added: ``` - packages/core-shared/src/trpc/define-error-middleware.ts — middleware factory mapping [ErrorCtor, TRPC_CODE] tuples to TRPCError translation - packages/core-shared/src/trpc/define-error-middleware.test.ts — 4 tests covering mapped translation, multiple codes, unmapped passthrough, cause preservation ``` Under §2 Files modified: ``` - packages/core-shared/src/trpc/init.ts — `t` instance now exported (was internal const) so feature procedures.ts can do `t.procedure.use(...)` - packages/core-shared/package.json — added "./trpc/define-error-middleware" subpath export ``` Under §4 Error-middleware adoption: ``` - core-shared infrastructure landed; feature routers will adopt in Tasks 3-7. - Discriminator: `instanceof Ctor` (not error name string), so duck-typing is impossible — features pass their own class constructors. - Cause preservation: TRPCError carries the original domain error in `.cause` for client structured-error inspection. ``` - [ ] **Step 10: Commit** ```bash git add packages/core-shared docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md git commit -m "$(cat <<'EOF' feat(core-shared): add defineErrorMiddleware factory + export t Factory takes [[ErrorCtor, TRPC_CODE], ...] tuples and returns a tRPC middleware that translates matching domain errors to TRPCError. Discrim- inates by instanceof; preserves original error as cause; unmapped errors propagate. core-shared never enumerates feature errors — each feature passes its own constructors in via integrations/api/procedures.ts (Tasks 3-7). Also exports the `t` instance from trpc/init.ts so feature procedure files can do t.procedure.use(...). Refactor log: §1, §2, §4 Spec: R13–R17 EOF )" ``` --- ## Task 3: Migrate `auth` feature **Files:** - Modify: `packages/auth/src/application/use-cases/sign-in.use-case.ts` - Modify: `packages/auth/src/application/use-cases/sign-in.use-case.test.ts` - Modify: `packages/auth/src/application/use-cases/sign-up.use-case.ts` - Modify: `packages/auth/src/application/use-cases/sign-up.use-case.test.ts` - Modify: `packages/auth/src/application/use-cases/sign-out.use-case.ts` - Modify: `packages/auth/src/application/use-cases/sign-out.use-case.test.ts` - Modify: `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts` - Modify: `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts` - Modify: `packages/auth/src/interface-adapters/controllers/sign-up.controller.ts` - Modify: `packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts` - Modify: `packages/auth/src/interface-adapters/controllers/sign-out.controller.ts` - Modify: `packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts` - Create: `packages/auth/src/integrations/api/procedures.ts` - Modify: `packages/auth/src/integrations/api/router.ts` - Modify or Create: `packages/auth/src/integrations/api/router.test.ts` (add R26) - Create: `packages/auth/src/ui/index.ts` - Modify: `packages/auth/src/index.ts` (export schemas) - Modify: `packages/auth/package.json` (add `./ui` subpath) ### 3.A — sign-in use case (input + output schemas + R25 test) - [ ] **Step 1: Read current sign-in use case** ```bash cat packages/auth/src/application/use-cases/sign-in.use-case.ts ``` Note current input shape (`{ username, password }`) and current return type (`Promise<{ session: Session; cookie: Cookie }>`). - [ ] **Step 2: Write a failing R25 output-validation test FIRST** Edit `packages/auth/src/application/use-cases/sign-in.use-case.test.ts`. Add a new test (keep existing tests intact): ```typescript import { signInOutputSchema } from "@/application/use-cases/sign-in.use-case"; // ... (preserve other imports) describe("signInUseCase output validation (R25)", () => { it("throws when authenticationService returns a malformed session", async () => { const users = new MockUsersRepository(); const seed = userFactory.build({ username: "alice" }); await users.createUser(seed); const auth = { verifyPassword: async () => true, // session missing required fields → should fail signInOutputSchema.parse createSession: async () => ({ session: { id: 123 }, cookie: null }), } as unknown as IAuthenticationService; const useCase = signInUseCase(users, auth); await expect(useCase({ username: "alice", password: "x" })).rejects.toThrow(/parse|invalid/i); }); it("exports an output schema that mirrors the success shape", () => { expect(signInOutputSchema).toBeDefined(); const parsed = signInOutputSchema.safeParse({ session: { id: "s1", userId: "u1", expiresAt: new Date() }, cookie: { name: "session", value: "s1", attributes: {} }, }); expect(parsed.success).toBe(true); }); }); ``` (Add `IAuthenticationService` to the imports if it's not already there; resolve from `@/application/services/authentication.service.interface`.) - [ ] **Step 3: Run RED** ```bash pnpm test --filter @repo/auth -- sign-in.use-case ``` Expected: new tests FAIL — `signInOutputSchema` is not exported. - [ ] **Step 4: Update `sign-in.use-case.ts` with input + output schemas + parse** Replace `packages/auth/src/application/use-cases/sign-in.use-case.ts` with: ```typescript import { z } from "zod"; import { AuthenticationError } from "../../entities/errors/auth"; import { cookieSchema } from "../../entities/models/cookie"; import { sessionSchema } from "../../entities/models/session"; import type { IUsersRepository } from "../repositories/users.repository.interface"; import type { IAuthenticationService } from "../services/authentication.service.interface"; // ── Input ──────────────────────────────────────────────────────────────── export const signInInputSchema = z .object({ username: z.string().min(3).max(31), password: z.string().min(6).max(255), }) .strict(); export type SignInInput = z.infer; // ── Output ─────────────────────────────────────────────────────────────── export const signInOutputSchema = z.object({ session: sessionSchema, cookie: cookieSchema, }); export type SignInOutput = z.infer; // ── Use case ───────────────────────────────────────────────────────────── export type ISignInUseCase = ReturnType; export const signInUseCase = (usersRepository: IUsersRepository, authenticationService: IAuthenticationService) => async (input: SignInInput): Promise => { const existingUser = await usersRepository.getUserByUsername(input.username); if (!existingUser) { throw new AuthenticationError("User does not exist"); } const validPassword = await authenticationService.verifyPassword( existingUser.passwordHash, input.password, ); if (!validPassword) { throw new AuthenticationError("Incorrect username or password"); } const result = await authenticationService.createSession(existingUser); return signInOutputSchema.parse(result); }; ``` If `cookieSchema` and `sessionSchema` aren't already exported from their entity files, this is the moment to add the exports (they ARE typically exported per entity-models convention; verify): ```bash grep -n "export const sessionSchema\|export const cookieSchema" packages/auth/src/entities/models/*.ts ``` If a schema export is missing, add it (the entity file already has the Zod schema definition; just `export` it). - [ ] **Step 5: Run GREEN** ```bash pnpm test --filter @repo/auth -- sign-in.use-case ``` Expected: all sign-in use-case tests pass (preexisting + new R25 ones). ### 3.B — sign-up use case (same pattern) - [ ] **Step 6: Update `sign-up.use-case.ts` analogously** Read the current file, then add: ```typescript export const signUpInputSchema = z .object({ username: z.string().min(3).max(31), password: z.string().min(6).max(255), confirmPassword: z.string().min(6).max(255), }) .strict() .refine((d) => d.password === d.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], }); export type SignUpInput = z.infer; export const signUpOutputSchema = z.object({ session: sessionSchema, cookie: cookieSchema, }); export type SignUpOutput = z.infer; ``` Update the factory signature: `(input: SignUpInput): Promise`. End body with `return signUpOutputSchema.parse(result)`. - [ ] **Step 7: Add R25 test for sign-up + run** Mirror the sign-in R25 test in `sign-up.use-case.test.ts`, then: ```bash pnpm test --filter @repo/auth -- sign-up.use-case ``` Expected: all green. ### 3.C — sign-out use case (void output) - [ ] **Step 8: Update `sign-out.use-case.ts`** Sign-out has input `{ sessionId: string }` and returns `void`. Per R5 (uniform input) + R12 (no presenter for void): ```typescript import { z } from "zod"; import type { IAuthenticationService } from "../services/authentication.service.interface"; export const signOutInputSchema = z.object({ sessionId: z.string() }).strict(); export type SignOutInput = z.infer; // No xOutputSchema — use case returns void. export type ISignOutUseCase = ReturnType; export const signOutUseCase = (authenticationService: IAuthenticationService) => async (input: SignOutInput): Promise => { await authenticationService.invalidateSession(input.sessionId); }; ``` - [ ] **Step 9: Run sign-out tests** ```bash pnpm test --filter @repo/auth -- sign-out.use-case ``` Expected: all green. (Sign-out has no R25 test — void output has nothing to validate.) ### 3.D — sign-in controller (presenter + unknown input) - [ ] **Step 10: Update sign-in controller test FIRST** Edit `packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts`. The new contract: controller takes `unknown`, returns the presenter's view (here: a Cookie). Change the existing tests to construct invalid input as `unknown` and verify the schema error path; verify the success path returns the cookie shape. ```typescript import { describe, it, expect } from "vitest"; import { signInController } from "@/interface-adapters/controllers/sign-in.controller"; import { signInUseCase } from "@/application/use-cases/sign-in.use-case"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { InputParseError } from "@/entities/errors/common"; import { userFactory } from "@/__factories__/user.factory"; describe("signInController", () => { it("returns a cookie on successful sign-in", async () => { const users = new MockUsersRepository(); const auth = new MockAuthenticationService(users); const seedUser = userFactory.build({ username: "alice" }); await users.createUser(seedUser); const useCase = signInUseCase(users, auth); const controller = signInController(useCase); const result = await controller({ username: "alice", password: seedUser.passwordHash.replace("hashed_", ""), }); expect(result).toBeDefined(); expect(result.name).toBeTruthy(); expect(result.value).toBeTruthy(); }); it("throws InputParseError on invalid input", async () => { const users = new MockUsersRepository(); const auth = new MockAuthenticationService(users); const useCase = signInUseCase(users, auth); const controller = signInController(useCase); await expect(controller({ username: "ab" } as unknown)).rejects.toBeInstanceOf(InputParseError); }); it("throws InputParseError when input is not an object", async () => { const users = new MockUsersRepository(); const auth = new MockAuthenticationService(users); const useCase = signInUseCase(users, auth); const controller = signInController(useCase); await expect(controller("garbage" as unknown)).rejects.toBeInstanceOf(InputParseError); }); }); ``` - [ ] **Step 11: Run RED** ```bash pnpm test --filter @repo/auth -- sign-in.controller ``` Expected: FAIL — controller still has old shape, third test (`"garbage"`) might pass by coincidence; the type signature check on input may be the visible failure. - [ ] **Step 12: Update sign-in controller** Replace `packages/auth/src/interface-adapters/controllers/sign-in.controller.ts` with: ```typescript import { InputParseError } from "../../entities/errors/common"; import { signInInputSchema, type ISignInUseCase, type SignInOutput, } from "../../application/use-cases/sign-in.use-case"; function presenter(value: SignInOutput) { return value.cookie; } export type ISignInController = ReturnType; export const signInController = (signInUseCase: ISignInUseCase) => async (input: unknown): Promise> => { const parsed = signInInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid sign-in input", { cause: parsed.error }); } const result = await signInUseCase(parsed.data); return presenter(result); }; ``` - [ ] **Step 13: Run GREEN** ```bash pnpm test --filter @repo/auth -- sign-in.controller ``` Expected: 3 tests pass. ### 3.E — sign-up controller (same pattern) - [ ] **Step 14: Update `sign-up.controller.ts` and its test** Test pattern mirrors sign-in. Controller pattern: ```typescript import { InputParseError } from "../../entities/errors/common"; import { signUpInputSchema, type ISignUpUseCase, type SignUpOutput, } from "../../application/use-cases/sign-up.use-case"; function presenter(value: SignUpOutput) { return value.cookie; } export type ISignUpController = ReturnType; export const signUpController = (signUpUseCase: ISignUpUseCase) => async (input: unknown): Promise> => { const parsed = signUpInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid sign-up input", { cause: parsed.error }); } const result = await signUpUseCase(parsed.data); return presenter(result); }; ``` Run `pnpm test --filter @repo/auth -- sign-up.controller`. Expected: green. ### 3.F — sign-out controller (no presenter — void) - [ ] **Step 15: Update `sign-out.controller.ts` and its test** Controller (R12 — no presenter, returns `Promise`): ```typescript import { InputParseError } from "../../entities/errors/common"; import { signOutInputSchema, type ISignOutUseCase, } from "../../application/use-cases/sign-out.use-case"; export type ISignOutController = ReturnType; export const signOutController = (signOutUseCase: ISignOutUseCase) => async (input: unknown): Promise => { const parsed = signOutInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid sign-out input", { cause: parsed.error }); } await signOutUseCase(parsed.data); }; ``` Test pattern (the existing test likely passes a string sessionId — change to pass an object): ```typescript import { describe, it, expect } from "vitest"; import { signOutController } from "@/interface-adapters/controllers/sign-out.controller"; import { signOutUseCase } from "@/application/use-cases/sign-out.use-case"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { InputParseError } from "@/entities/errors/common"; describe("signOutController", () => { it("returns void on successful sign-out", async () => { const users = new MockUsersRepository(); const auth = new MockAuthenticationService(users); const useCase = signOutUseCase(auth); const controller = signOutController(useCase); const result = await controller({ sessionId: "any" }); expect(result).toBeUndefined(); }); it("throws InputParseError on missing sessionId", async () => { const users = new MockUsersRepository(); const auth = new MockAuthenticationService(users); const useCase = signOutUseCase(auth); const controller = signOutController(useCase); await expect(controller({} as unknown)).rejects.toBeInstanceOf(InputParseError); }); }); ``` Run: `pnpm test --filter @repo/auth -- sign-out.controller`. Expected: green. ### 3.G — `procedures.ts` (auth error map) - [ ] **Step 16: Create `packages/auth/src/integrations/api/procedures.ts`** ```typescript import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import { AuthenticationError, UnauthenticatedError, UnauthorizedError, } from "../../entities/errors/auth"; import { InputParseError } from "../../entities/errors/common"; export const authProcedure = t.procedure.use( defineErrorMiddleware([ [InputParseError, "BAD_REQUEST"], [AuthenticationError, "UNAUTHORIZED"], [UnauthenticatedError, "UNAUTHORIZED"], [UnauthorizedError, "FORBIDDEN"], ]), ); ``` ### 3.H — Router migration - [ ] **Step 17: Replace router with feature procedure + schema imports** Replace `packages/auth/src/integrations/api/router.ts`: ```typescript import { router } from "@repo/core-shared/trpc/init"; import { authContainer } from "../../di/container"; import { AUTH_SYMBOLS } from "../../di/symbols"; import { signInInputSchema } from "../../application/use-cases/sign-in.use-case"; import { signUpInputSchema } from "../../application/use-cases/sign-up.use-case"; import { signOutInputSchema } from "../../application/use-cases/sign-out.use-case"; import type { ISignInController } from "../../interface-adapters/controllers/sign-in.controller"; import type { ISignUpController } from "../../interface-adapters/controllers/sign-up.controller"; import type { ISignOutController } from "../../interface-adapters/controllers/sign-out.controller"; import { authProcedure } from "./procedures"; export const authRouter = router({ signIn: authProcedure.input(signInInputSchema).mutation(({ input }) => { const ctrl = authContainer.get(AUTH_SYMBOLS.ISignInController); return ctrl(input); }), signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => { const ctrl = authContainer.get(AUTH_SYMBOLS.ISignUpController); return ctrl(input); }), signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => { const ctrl = authContainer.get(AUTH_SYMBOLS.ISignOutController); return ctrl(input); }), }); export type AuthRouter = typeof authRouter; ``` ### 3.I — Router test (R26 error mapping) - [ ] **Step 18: Read existing `router.test.ts` (if any)** ```bash ls packages/auth/src/integrations/api/ cat packages/auth/src/integrations/api/router.test.ts 2>/dev/null ``` If it doesn't exist, create it. If it exists, add R26 tests to it. - [ ] **Step 19: Write/extend router test for R26** Create or modify `packages/auth/src/integrations/api/router.test.ts`: ```typescript import { describe, it, expect, beforeEach } from "vitest"; import { TRPCError } from "@trpc/server"; import { authRouter } from "@/integrations/api/router"; import { authContainer } from "@/di/container"; import { AUTH_SYMBOLS } from "@/di/symbols"; import { MockUsersRepository } from "@/infrastructure/repositories/users.repository.mock"; import { MockAuthenticationService } from "@/infrastructure/services/authentication.service.mock"; import type { IUsersRepository } from "@/application/repositories/users.repository.interface"; import type { IAuthenticationService } from "@/application/services/authentication.service.interface"; describe("authRouter (R26 error mapping)", () => { beforeEach(() => { if (authContainer.isBound(AUTH_SYMBOLS.IUsersRepository)) { authContainer.unbind(AUTH_SYMBOLS.IUsersRepository); } if (authContainer.isBound(AUTH_SYMBOLS.IAuthenticationService)) { authContainer.unbind(AUTH_SYMBOLS.IAuthenticationService); } const users = new MockUsersRepository(); const auth = new MockAuthenticationService(users); authContainer.bind(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users); authContainer .bind(AUTH_SYMBOLS.IAuthenticationService) .toConstantValue(auth); }); it("translates AuthenticationError → UNAUTHORIZED on missing user", async () => { const caller = authRouter.createCaller({}); try { await caller.signIn({ username: "ghost", password: "long-enough" }); throw new Error("expected throw"); } catch (e) { expect(e).toBeInstanceOf(TRPCError); expect((e as TRPCError).code).toBe("UNAUTHORIZED"); } }); it("translates BAD_REQUEST when zod parse fails at the procedure boundary", async () => { const caller = authRouter.createCaller({}); try { await caller.signIn({ username: "ab", password: "x" } as unknown as { username: string; password: string; }); throw new Error("expected throw"); } catch (e) { expect(e).toBeInstanceOf(TRPCError); expect((e as TRPCError).code).toBe("BAD_REQUEST"); } }); }); ``` - [ ] **Step 20: Run RED then GREEN** ```bash pnpm test --filter @repo/auth -- router ``` Expected: tests pass against the new router. ### 3.J — Public-API surface cleanup - [ ] **Step 21: Read current `src/index.ts`** ```bash cat packages/auth/src/index.ts ``` (Currently exports `User`/`Session`/`Cookie`/`AuthRouter`/errors/`SESSION_COOKIE`. Auth has no query builders to move — its `ui/query.ts` is `export {}`.) - [ ] **Step 22: Create `src/ui/index.ts`** Auth has no UI artifacts yet. Create a placeholder so the `./ui` subpath resolves: ```typescript // packages/auth/src/ui/index.ts // Auth has no React Query option builders today (all auth procedures are // mutations). This file is the public UI surface for future components // and queries — extend rather than re-add to root index.ts. export {}; ``` If `packages/auth/src/ui/query.ts` exists with `export {};`, leave it (or delete; whichever is cleaner). Re-export from `index.ts`: ```bash # verify cat packages/auth/src/ui/query.ts ``` If it's just `export {}`, leave the file as-is — `ui/index.ts` is the new public surface. - [ ] **Step 23: Update `src/index.ts` to export schemas + IUseCase aliases** Replace `packages/auth/src/index.ts`: ```typescript export type { User } from "./entities/models/user"; export type { Session } from "./entities/models/session"; export type { Cookie } from "./entities/models/cookie"; export type { AuthRouter } from "./integrations/api/router"; export { AuthenticationError, UnauthenticatedError, UnauthorizedError, } from "./entities/errors/auth"; export { InputParseError } from "./entities/errors/common"; export { SESSION_COOKIE } from "./config"; // Use case schemas + types (Plan 9 R18) export { signInInputSchema, signInOutputSchema, type SignInInput, type SignInOutput, type ISignInUseCase, } from "./application/use-cases/sign-in.use-case"; export { signUpInputSchema, signUpOutputSchema, type SignUpInput, type SignUpOutput, type ISignUpUseCase, } from "./application/use-cases/sign-up.use-case"; export { signOutInputSchema, type SignOutInput, type ISignOutUseCase, } from "./application/use-cases/sign-out.use-case"; // Controller type aliases export type { ISignInController } from "./interface-adapters/controllers/sign-in.controller"; export type { ISignUpController } from "./interface-adapters/controllers/sign-up.controller"; export type { ISignOutController } from "./interface-adapters/controllers/sign-out.controller"; ``` - [ ] **Step 24: Add `./ui` subpath to `package.json`** Edit `packages/auth/package.json`. Add to `exports`: ```jsonc { "exports": { ".": "./src/index.ts", "./ui": "./src/ui/index.ts", "./cms": "./src/integrations/cms/index.ts", "./api": "./src/integrations/api/router.ts", "./di/bind-production": "./src/di/bind-production.ts" } } ``` ### 3.K — Verify - [ ] **Step 25: Full validation** ```bash pnpm install pnpm typecheck pnpm lint pnpm test pnpm turbo boundaries ``` All green. Tests should number the same as before plus 4 new ones (1× R25 sign-in, 1× R25 sign-up, 2× R26 router). ### 3.L — Update changelog - [ ] **Step 26: Append auth entries to the refactor changelog** Under §1 Files added: ``` - packages/auth/src/integrations/api/procedures.ts — authProcedure with feature error map (InputParse → BAD_REQUEST, Auth/Unauthenticated → UNAUTHORIZED, Unauthorized → FORBIDDEN) - packages/auth/src/ui/index.ts — placeholder UI surface (no queries today; mutations only) - packages/auth/src/integrations/api/router.test.ts — R26 error-mapping tests for signIn (UNAUTHORIZED on missing user, BAD_REQUEST on bad input) ``` Under §2 Files modified: ``` - packages/auth/src/application/use-cases/sign-in.use-case.ts — input + output schemas; output.parse before return; SignInInput/SignInOutput types exported - packages/auth/src/application/use-cases/sign-up.use-case.ts — input + output schemas (with confirmPassword refine); output.parse; types exported - packages/auth/src/application/use-cases/sign-out.use-case.ts — input schema only (void output, no presenter); SignOutInput exported - packages/auth/src/interface-adapters/controllers/sign-in.controller.ts — presenter returning cookie; unknown input; ReturnType return type - packages/auth/src/interface-adapters/controllers/sign-up.controller.ts — presenter returning cookie; unknown input - packages/auth/src/interface-adapters/controllers/sign-out.controller.ts — no presenter (void); unknown input; Promise return - packages/auth/src/integrations/api/router.ts — uses authProcedure, .input(xInputSchema) - packages/auth/src/index.ts — schemas + types now exported from feature root - packages/auth/package.json — added ./ui subpath - All affected use-case + controller tests ``` Under §3.1 / §3.2 / §3.3 / §5.1 / §5.2 / §6.1 / §6.2 — append the auth-specific summary one-liner (e.g., "auth migrated, all 3 use cases"). - [ ] **Step 27: Commit** ```bash git add packages/auth packages/core-shared docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md git commit -m "$(cat <<'EOF' refactor(auth): unify use-case I/O schemas + presenter + feature error map Per Plan 9 (spec R1-R28): - Use cases: input + output schemas (signIn, signUp); input-only for signOut (void output). Use case body validates output via outputSchema.parse before returning. - Controllers: receive `unknown`; safeParse with the use-case schema; presenter (returning cookie) for signIn/signUp; void return for signOut. - New integrations/api/procedures.ts with authProcedure built via defineErrorMiddleware([[InputParseError,"BAD_REQUEST"], [AuthenticationError,"UNAUTHORIZED"], [UnauthenticatedError, "UNAUTHORIZED"], [UnauthorizedError,"FORBIDDEN"]]). - Router uses authProcedure + .input(xInputSchema) for every procedure. - src/index.ts exports schemas + types + IUseCase/IController aliases. - package.json gains ./ui subpath; src/ui/index.ts placeholder (auth has no query builders today). - New tests: R25 output-validation per use case (signIn, signUp); R26 router error-mapping (UNAUTHORIZED on missing user, BAD_REQUEST on schema fail). Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2 Spec: R1–R6, R8–R15, R18, R19, R22–R26 EOF )" ``` --- ## Task 4: Migrate `blog` feature **Files:** - Modify: every blog use-case + test (`get-articles`, `create-article`, `get-article-by-slug`) - Modify: every blog controller + test (same three) - Create: `packages/blog/src/integrations/api/procedures.ts` - Modify: `packages/blog/src/integrations/api/router.ts` - Modify or Create: `packages/blog/src/integrations/api/router.test.ts` - Modify: `packages/blog/src/index.ts` - Move: `articleBySlugQuery`, `listArticlesQuery` from current `src/index.ts` → `src/ui/index.ts` - Modify: `packages/blog/package.json` (add `./ui`) ### 4.A — `get-articles` use case - [ ] **Step 1: Read current files for context** ```bash cat packages/blog/src/application/use-cases/get-articles.use-case.ts cat packages/blog/src/application/use-cases/get-articles.use-case.test.ts ``` Note: input is currently `options?: { status?, authorId?, limit?, offset? }`; output is `Promise`. - [ ] **Step 2: Write failing R25 test for `get-articles`** Add to `packages/blog/src/application/use-cases/get-articles.use-case.test.ts`: ```typescript import { getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case"; import { z } from "zod"; describe("getArticlesUseCase output validation (R25)", () => { it("throws when the repository returns a malformed article", async () => { const repo = new MockArticlesRepository(); // bypass the mock's createArticle (which is typed) by reaching into _articles directly (repo as unknown as { _articles: unknown[] })._articles.push({ id: 123 }); const useCase = getArticlesUseCase(repo); await expect(useCase({})).rejects.toBeInstanceOf(z.ZodError); }); it("exports an output schema that mirrors Article[]", () => { expect(getArticlesOutputSchema).toBeDefined(); expect(getArticlesOutputSchema.safeParse([]).success).toBe(true); }); }); ``` - [ ] **Step 3: Run RED** ```bash pnpm test --filter @repo/blog -- get-articles.use-case ``` Expected: FAIL — `getArticlesOutputSchema` does not exist; current `getArticlesUseCase` accepts `options?` not `XInput`. - [ ] **Step 4: Update `get-articles.use-case.ts`** Replace with: ```typescript import { z } from "zod"; import { articleSchema, articleStatusSchema } from "../../entities/models/article"; import type { IArticlesRepository } from "../repositories/articles.repository.interface"; export const getArticlesInputSchema = z .object({ status: articleStatusSchema.optional(), authorId: z.string().optional(), limit: z.number().int().positive().optional(), offset: z.number().int().nonnegative().optional(), }) .strict(); export type GetArticlesInput = z.infer; export const getArticlesOutputSchema = z.array(articleSchema); export type GetArticlesOutput = z.infer; export type IGetArticlesUseCase = ReturnType; export const getArticlesUseCase = (articlesRepository: IArticlesRepository) => async (input: GetArticlesInput): Promise => { const result = await articlesRepository.getArticles(input); return getArticlesOutputSchema.parse(result); }; ``` The existing `getArticlesUseCase()` (no-arg) call in tests breaks — update those calls to pass `{}`. - [ ] **Step 5: Update existing `get-articles.use-case.test.ts` calls** Find every `await useCase()` (no args) in this file and replace with `await useCase({})`. Also find any `await useCase({ status: "..." })` — those still work but verify they conform to the new schema. - [ ] **Step 6: Run GREEN** ```bash pnpm test --filter @repo/blog -- get-articles.use-case ``` Expected: all green (existing tests + 2 new R25 tests). ### 4.B — `create-article` use case - [ ] **Step 7: Update `create-article.use-case.ts`** ```typescript import { z } from "zod"; import { articleSchema } from "../../entities/models/article"; import type { IArticlesRepository } from "../repositories/articles.repository.interface"; export const createArticleInputSchema = z .object({ title: z.string().min(1).max(255), content: z.unknown().optional(), authorId: z.string(), slug: z.string().optional(), }) .strict(); export type CreateArticleInput = z.infer; export const createArticleOutputSchema = articleSchema; export type CreateArticleOutput = z.infer; function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } export type ICreateArticleUseCase = ReturnType; export const createArticleUseCase = (articlesRepository: IArticlesRepository) => async (input: CreateArticleInput): Promise => { const now = new Date(); const article = { id: crypto.randomUUID(), title: input.title, slug: input.slug ?? generateSlug(input.title), content: input.content ?? null, status: "draft" as const, authorId: input.authorId, createdAt: now, updatedAt: now, }; const result = await articlesRepository.createArticle(article); return createArticleOutputSchema.parse(result); }; ``` - [ ] **Step 8: Add R25 test for create-article** In `create-article.use-case.test.ts`, add: ```typescript import { createArticleOutputSchema } from "@/application/use-cases/create-article.use-case"; import { z } from "zod"; describe("createArticleUseCase output validation (R25)", () => { it("throws when repository returns a malformed article", async () => { const repo = { createArticle: async () => ({ id: 1 }) as unknown as never, } as unknown as IArticlesRepository; const useCase = createArticleUseCase(repo); await expect( useCase({ title: "X", authorId: "u1" }), ).rejects.toBeInstanceOf(z.ZodError); }); }); ``` (Add `IArticlesRepository` to imports if needed.) Run: `pnpm test --filter @repo/blog -- create-article.use-case`. Expected: green. ### 4.C — `get-article-by-slug` use case - [ ] **Step 9: Update `get-article-by-slug.use-case.ts`** ```typescript import { z } from "zod"; import { ArticleNotFoundError } from "../../entities/errors/article"; import { articleSchema } from "../../entities/models/article"; import type { IArticlesRepository } from "../repositories/articles.repository.interface"; export const getArticleBySlugInputSchema = z .object({ slug: z.string().min(1) }) .strict(); export type GetArticleBySlugInput = z.infer; export const getArticleBySlugOutputSchema = articleSchema; export type GetArticleBySlugOutput = z.infer; export type IGetArticleBySlugUseCase = ReturnType; export const getArticleBySlugUseCase = (articlesRepository: IArticlesRepository) => async (input: GetArticleBySlugInput): Promise => { const article = await articlesRepository.getArticleBySlug(input.slug); if (!article) { throw new ArticleNotFoundError(`Article with slug "${input.slug}" not found`); } return getArticleBySlugOutputSchema.parse(article); }; ``` - [ ] **Step 10: Add R25 test for get-article-by-slug** In `get-article-by-slug.use-case.test.ts`, add a test that the repository returning a malformed article triggers a parse error. Run test. Green. ### 4.D — Controllers (3) — presenter (identity for now) + unknown input - [ ] **Step 11: Update `get-articles.controller.ts`** ```typescript import { InputParseError } from "../../entities/errors/common"; import { getArticlesInputSchema, type GetArticlesOutput, type IGetArticlesUseCase, } from "../../application/use-cases/get-articles.use-case"; function presenter(value: GetArticlesOutput) { // identity for now (R11 — every non-void controller has a presenter) return value; } export type IGetArticlesController = ReturnType; export const getArticlesController = (getArticlesUseCase: IGetArticlesUseCase) => async (input: unknown): Promise> => { const parsed = getArticlesInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-articles input", { cause: parsed.error }); } const result = await getArticlesUseCase(parsed.data); return presenter(result); }; ``` - [ ] **Step 12: Update `get-articles.controller.test.ts` calls** The current test calls `controller({})` (still works) and `controller({ status: "published" })` (still works). The third test uses `{ limit: "not a number" }` cast — still works because schema rejects. No changes needed *unless* the call signature of `controller` changed (input is now `unknown` instead of `Partial<...>`). Check test compiles: ```bash pnpm typecheck --filter @repo/blog ``` If TS complains about `controller({ ... } as ...)`, simplify by passing the value directly (the param is `unknown`, anything fits): ```typescript await expect(controller({ limit: "not a number" })).rejects.toBeInstanceOf(InputParseError); ``` Run tests. Green. - [ ] **Step 13: Update `create-article.controller.ts`** ```typescript import { InputParseError } from "../../entities/errors/common"; import { createArticleInputSchema, type CreateArticleOutput, type ICreateArticleUseCase, } from "../../application/use-cases/create-article.use-case"; function presenter(value: CreateArticleOutput) { return value; } export type ICreateArticleController = ReturnType; export const createArticleController = (createArticleUseCase: ICreateArticleUseCase) => async (input: unknown): Promise> => { const parsed = createArticleInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid create-article input", { cause: parsed.error }); } const result = await createArticleUseCase(parsed.data); return presenter(result); }; ``` - [ ] **Step 14: Update `get-article-by-slug.controller.ts`** ```typescript import { InputParseError } from "../../entities/errors/common"; import { getArticleBySlugInputSchema, type GetArticleBySlugOutput, type IGetArticleBySlugUseCase, } from "../../application/use-cases/get-article-by-slug.use-case"; function presenter(value: GetArticleBySlugOutput) { return value; } export type IGetArticleBySlugController = ReturnType; export const getArticleBySlugController = (getArticleBySlugUseCase: IGetArticleBySlugUseCase) => async (input: unknown): Promise> => { const parsed = getArticleBySlugInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error }); } const result = await getArticleBySlugUseCase(parsed.data); return presenter(result); }; ``` - [ ] **Step 15: Run all blog controller tests** ```bash pnpm test --filter @repo/blog -- controllers ``` Expected: green. Update any test failures by removing `as Partial<...>` casts (input is now `unknown`). ### 4.E — `procedures.ts` (blog error map) - [ ] **Step 16: Create `packages/blog/src/integrations/api/procedures.ts`** ```typescript import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import { ArticleNotFoundError } from "../../entities/errors/article"; import { InputParseError } from "../../entities/errors/common"; export const blogProcedure = t.procedure.use( defineErrorMiddleware([ [InputParseError, "BAD_REQUEST"], [ArticleNotFoundError, "NOT_FOUND"], ]), ); ``` ### 4.F — Router migration - [ ] **Step 17: Replace `packages/blog/src/integrations/api/router.ts`** ```typescript import { router } from "@repo/core-shared/trpc/init"; import { blogContainer } from "../../di/container"; import { BLOG_SYMBOLS } from "../../di/symbols"; import { getArticlesInputSchema } from "../../application/use-cases/get-articles.use-case"; import { createArticleInputSchema } from "../../application/use-cases/create-article.use-case"; import { getArticleBySlugInputSchema } from "../../application/use-cases/get-article-by-slug.use-case"; import type { IGetArticlesController } from "../../interface-adapters/controllers/get-articles.controller"; import type { ICreateArticleController } from "../../interface-adapters/controllers/create-article.controller"; import type { IGetArticleBySlugController } from "../../interface-adapters/controllers/get-article-by-slug.controller"; import { blogProcedure } from "./procedures"; export const blogRouter = router({ articleBySlug: blogProcedure .input(getArticleBySlugInputSchema) .query(({ input }) => { const ctrl = blogContainer.get( BLOG_SYMBOLS.IGetArticleBySlugController, ); return ctrl(input); }), listArticles: blogProcedure .input(getArticlesInputSchema) .query(({ input }) => { const ctrl = blogContainer.get( BLOG_SYMBOLS.IGetArticlesController, ); return ctrl(input); }), createArticle: blogProcedure .input(createArticleInputSchema) .mutation(({ input }) => { const ctrl = blogContainer.get( BLOG_SYMBOLS.ICreateArticleController, ); return ctrl(input); }), }); export type BlogRouter = typeof blogRouter; ``` ### 4.G — Router test (R26) - [ ] **Step 18: Create or extend `packages/blog/src/integrations/api/router.test.ts`** ```typescript import { describe, it, expect, beforeEach } from "vitest"; import { TRPCError } from "@trpc/server"; import { blogRouter } from "@/integrations/api/router"; import { blogContainer } from "@/di/container"; import { BLOG_SYMBOLS } from "@/di/symbols"; import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock"; import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface"; describe("blogRouter (R26 error mapping)", () => { beforeEach(() => { if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) { blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository); } blogContainer .bind(BLOG_SYMBOLS.IArticlesRepository) .toConstantValue(new MockArticlesRepository()); }); it("translates ArticleNotFoundError → NOT_FOUND", async () => { const caller = blogRouter.createCaller({}); try { await caller.articleBySlug({ slug: "missing" }); throw new Error("expected throw"); } catch (e) { expect(e).toBeInstanceOf(TRPCError); expect((e as TRPCError).code).toBe("NOT_FOUND"); } }); it("translates zod parse failure → BAD_REQUEST", async () => { const caller = blogRouter.createCaller({}); try { await caller.articleBySlug({} as unknown as { slug: string }); throw new Error("expected throw"); } catch (e) { expect(e).toBeInstanceOf(TRPCError); expect((e as TRPCError).code).toBe("BAD_REQUEST"); } }); }); ``` ### 4.H — Public-API surface - [ ] **Step 19: Create `packages/blog/src/ui/index.ts`** The current `src/index.ts` re-exports `articleBySlugQuery` and `listArticlesQuery` from `./ui/query`. Move those re-exports to `src/ui/index.ts`: ```typescript // packages/blog/src/ui/index.ts export { articleBySlugQuery, listArticlesQuery } from "./query"; ``` - [ ] **Step 20: Update `packages/blog/src/index.ts` (remove queries; add schemas)** ```typescript export type { Article, ArticleStatus } from "./entities/models/article"; export type { BlogRouter } from "./integrations/api/router"; export { ArticleNotFoundError } from "./entities/errors/article"; export { InputParseError } from "./entities/errors/common"; // Use case schemas + types (Plan 9 R18) export { getArticlesInputSchema, getArticlesOutputSchema, type GetArticlesInput, type GetArticlesOutput, type IGetArticlesUseCase, } from "./application/use-cases/get-articles.use-case"; export { createArticleInputSchema, createArticleOutputSchema, type CreateArticleInput, type CreateArticleOutput, type ICreateArticleUseCase, } from "./application/use-cases/create-article.use-case"; export { getArticleBySlugInputSchema, getArticleBySlugOutputSchema, type GetArticleBySlugInput, type GetArticleBySlugOutput, type IGetArticleBySlugUseCase, } from "./application/use-cases/get-article-by-slug.use-case"; // Controller type aliases export type { IGetArticlesController } from "./interface-adapters/controllers/get-articles.controller"; export type { ICreateArticleController } from "./interface-adapters/controllers/create-article.controller"; export type { IGetArticleBySlugController } from "./interface-adapters/controllers/get-article-by-slug.controller"; ``` (Note: `articleBySlugQuery` and `listArticlesQuery` are no longer here.) - [ ] **Step 21: Update `packages/blog/package.json`** ```jsonc { "exports": { ".": "./src/index.ts", "./ui": "./src/ui/index.ts", "./cms": "./src/integrations/cms/index.ts", "./api": "./src/integrations/api/router.ts", "./di/bind-production": "./src/di/bind-production.ts" } } ``` - [ ] **Step 22: Confirm no app/package imports the moved queries from `@repo/blog`** ```bash grep -rln 'from "@repo/blog"' apps/ packages/ | xargs -I{} grep -l 'articleBySlugQuery\|listArticlesQuery' {} 2>/dev/null ``` Expected: empty (no consumers today). If any are found, change them to `from "@repo/blog/ui"`. ### 4.I — Verify + changelog + commit - [ ] **Step 23: Full validation** ```bash pnpm install pnpm typecheck pnpm lint pnpm test pnpm turbo boundaries ``` All green. - [ ] **Step 24: Update changelog with blog entries** (mirror auth section: §1 added — procedures.ts, ui/index.ts, router.test.ts; §2 modified — three use cases, three controllers, router, src/index.ts, package.json + tests; §3.1–3.3, §5.1–5.2, §6.1–6.2 entries). - [ ] **Step 25: Commit** ```bash git add packages/blog docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md git commit -m "$(cat <<'EOF' refactor(blog): unify use-case I/O schemas + presenter + feature error map Per Plan 9 (spec R1-R28): - Use cases: input + output schemas (getArticles, createArticle, getArticleBySlug). Output validated via outputSchema.parse before return. status field uses articleStatusSchema (was loose `string`). - Controllers: receive `unknown`; safeParse with use-case schema; identity presenter (R11) on every controller. - New integrations/api/procedures.ts with blogProcedure ([InputParseError → BAD_REQUEST], [ArticleNotFoundError → NOT_FOUND]). - Router uses blogProcedure + .input(xInputSchema) for all 3 procedures. - src/index.ts: remove articleBySlugQuery/listArticlesQuery re-exports; export schemas + types + IUseCase/IController aliases. - src/ui/index.ts (NEW): query builders moved here; package.json adds ./ui subpath. - New tests: R25 output-validation per use case; R26 router error- mapping (NOT_FOUND on missing slug, BAD_REQUEST on schema fail). Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2 Spec: R1–R6, R8–R15, R18–R20, R22–R26 EOF )" ``` --- ## Task 5: Migrate `marketing-pages` feature Mirror Task 4 structure for two use cases (`get-page-by-slug`, `get-site-settings`) and two controllers. Site-settings has VOID input (`getSiteSettings()`) per current code — the schema becomes `z.object({}).strict()` (R5). **Files:** all `packages/marketing-pages/src/application/use-cases/*.{ts,test.ts}`, all `packages/marketing-pages/src/interface-adapters/controllers/*.{ts,test.ts}`, `integrations/api/procedures.ts` (new), `integrations/api/router.ts`, `integrations/api/router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`. ### 5.A — `get-page-by-slug` use case - [ ] **Step 1: Read current files** ```bash cat packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts cat packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.test.ts ``` - [ ] **Step 2: Rewrite use case** ```typescript // packages/marketing-pages/src/application/use-cases/get-page-by-slug.use-case.ts import { z } from "zod"; import { PageNotFoundError } from "../../entities/errors/page"; import { pageSchema } from "../../entities/models/page"; import type { IPagesRepository } from "../repositories/pages.repository.interface"; export const getPageBySlugInputSchema = z.object({ slug: z.string().min(1) }).strict(); export type GetPageBySlugInput = z.infer; export const getPageBySlugOutputSchema = pageSchema; export type GetPageBySlugOutput = z.infer; export type IGetPageBySlugUseCase = ReturnType; export const getPageBySlugUseCase = (pagesRepository: IPagesRepository) => async (input: GetPageBySlugInput): Promise => { const page = await pagesRepository.getPageBySlug(input.slug); if (!page) { throw new PageNotFoundError(`Page with slug "${input.slug}" not found`); } return getPageBySlugOutputSchema.parse(page); }; ``` (Verify the current behavior — if `getPageBySlugUseCase` currently returns `undefined` for missing pages instead of throwing, this changes semantics. Read the existing file first; if it returns undefined, KEEP the undefined-return behavior and adjust the schema: ```typescript export const getPageBySlugOutputSchema = pageSchema; // ... async (input: GetPageBySlugInput): Promise => { const page = await pagesRepository.getPageBySlug(input.slug); if (!page) return undefined; return getPageBySlugOutputSchema.parse(page); }; ``` Either direction is fine — choose to match the existing test expectations.) - [ ] **Step 3: Add R25 test, run RED → GREEN.** ### 5.B — `get-site-settings` use case (void input) - [ ] **Step 4: Rewrite use case** ```typescript // packages/marketing-pages/src/application/use-cases/get-site-settings.use-case.ts import { z } from "zod"; import { siteSettingsSchema } from "../../entities/models/site-settings"; import type { ISiteSettingsRepository } from "../repositories/site-settings.repository.interface"; export const getSiteSettingsInputSchema = z.object({}).strict(); export type GetSiteSettingsInput = z.infer; export const getSiteSettingsOutputSchema = siteSettingsSchema; export type GetSiteSettingsOutput = z.infer; export type IGetSiteSettingsUseCase = ReturnType; export const getSiteSettingsUseCase = (siteSettingsRepository: ISiteSettingsRepository) => // eslint-disable-next-line @typescript-eslint/no-unused-vars async (_input: GetSiteSettingsInput): Promise => { const result = await siteSettingsRepository.getSiteSettings(); return getSiteSettingsOutputSchema.parse(result); }; ``` The `_input` is required by R5 (uniform input). The `_` prefix tells lint it's intentional. - [ ] **Step 5: Update existing tests** to pass `{}` as the input (was no-arg). Add R25 test. Run. ### 5.C — Controllers - [ ] **Step 6: Update `get-page-by-slug.controller.ts`** with presenter (identity), `unknown` input, schema import. Pattern identical to blog. - [ ] **Step 7: Update `get-site-settings.controller.ts`** similarly. Note: must call `controller({})` in tests. - [ ] **Step 8: Update both controller tests; run all green.** ### 5.D — `procedures.ts` - [ ] **Step 9: Create `packages/marketing-pages/src/integrations/api/procedures.ts`** ```typescript import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import { PageNotFoundError } from "../../entities/errors/page"; import { InputParseError } from "../../entities/errors/common"; export const marketingPagesProcedure = t.procedure.use( defineErrorMiddleware([ [InputParseError, "BAD_REQUEST"], [PageNotFoundError, "NOT_FOUND"], ]), ); ``` ### 5.E — Router - [ ] **Step 10: Update router** ```typescript import { router } from "@repo/core-shared/trpc/init"; import { marketingPagesContainer } from "../../di/container"; import { MARKETING_PAGES_SYMBOLS } from "../../di/symbols"; import { getPageBySlugInputSchema } from "../../application/use-cases/get-page-by-slug.use-case"; import { getSiteSettingsInputSchema } from "../../application/use-cases/get-site-settings.use-case"; import type { IGetPageBySlugController } from "../../interface-adapters/controllers/get-page-by-slug.controller"; import type { IGetSiteSettingsController } from "../../interface-adapters/controllers/get-site-settings.controller"; import { marketingPagesProcedure } from "./procedures"; export const marketingPagesRouter = router({ pageBySlug: marketingPagesProcedure .input(getPageBySlugInputSchema) .query(({ input }) => { const ctrl = marketingPagesContainer.get( MARKETING_PAGES_SYMBOLS.IGetPageBySlugController, ); return ctrl(input); }), siteSettings: marketingPagesProcedure .input(getSiteSettingsInputSchema) .query(({ input }) => { const ctrl = marketingPagesContainer.get( MARKETING_PAGES_SYMBOLS.IGetSiteSettingsController, ); return ctrl(input); }), }); export type MarketingPagesRouter = typeof marketingPagesRouter; ``` ### 5.F — Router test (R26) - [ ] **Step 11: Add R26 test** — assert `PageNotFoundError → NOT_FOUND` and zod-parse → `BAD_REQUEST` (mirrors blog router test). ### 5.G — Public-API surface - [ ] **Step 12: Create `packages/marketing-pages/src/ui/index.ts`** ```typescript export { pageBySlugQuery, siteSettingsQuery } from "./query"; ``` - [ ] **Step 13: Update `packages/marketing-pages/src/index.ts`** Remove `pageBySlugQuery, siteSettingsQuery` exports; add schemas + types + IUseCase / IController aliases (mirroring blog's pattern). - [ ] **Step 14: Add `./ui` to `package.json`.** ### 5.H — Verify + changelog + commit - [ ] **Step 15: Full validation pass.** ```bash pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries ``` - [ ] **Step 16: Update changelog with marketing-pages entries.** - [ ] **Step 17: Commit** ```bash git add packages/marketing-pages docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md git commit -m "$(cat <<'EOF' refactor(marketing-pages): unify use-case I/O schemas + presenter + feature error map Per Plan 9 (spec R1-R28): - Use cases: input + output schemas (getPageBySlug, getSiteSettings). Site-settings input is z.object({}).strict() per R5 (uniform input). - Controllers: unknown input + identity presenter; void output not applicable (both use cases return data). - New integrations/api/procedures.ts with marketingPagesProcedure ([InputParseError → BAD_REQUEST], [PageNotFoundError → NOT_FOUND]). - Router uses marketingPagesProcedure + .input(xInputSchema). - src/index.ts: remove pageBySlugQuery/siteSettingsQuery; export schemas + types + IUseCase/IController aliases. - src/ui/index.ts (NEW); package.json adds ./ui subpath. - R25 output-validation tests + R26 router error-mapping test. Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2 Spec: R1–R6, R8–R15, R18–R20, R22–R26 EOF )" ``` --- ## Task 6: Migrate `navigation` feature Single use case (`get-header`) — void input, non-void output. **Files:** `application/use-cases/get-header.use-case.{ts,test.ts}`, `interface-adapters/controllers/get-header.controller.{ts,test.ts}`, `integrations/api/procedures.ts` (new), `integrations/api/router.ts`, `integrations/api/router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`. - [ ] **Step 1: Update `get-header.use-case.ts`** ```typescript import { z } from "zod"; import { HeaderNotFoundError } from "../../entities/errors/header"; import { headerSchema } from "../../entities/models/header"; import type { IHeaderRepository } from "../repositories/header.repository.interface"; export const getHeaderInputSchema = z.object({}).strict(); export type GetHeaderInput = z.infer; export const getHeaderOutputSchema = headerSchema; export type GetHeaderOutput = z.infer; export type IGetHeaderUseCase = ReturnType; export const getHeaderUseCase = (headerRepository: IHeaderRepository) => // eslint-disable-next-line @typescript-eslint/no-unused-vars async (_input: GetHeaderInput): Promise => { const header = await headerRepository.getHeader(); if (!header) { throw new HeaderNotFoundError("Header global not found"); } return getHeaderOutputSchema.parse(header); }; ``` (Adjust the `if (!header) throw` only if the existing use case throws on missing; if it returns undefined, mirror that. Read first.) - [ ] **Step 2: Update tests to pass `{}` to the use case + add R25 test.** - [ ] **Step 3: Update `get-header.controller.ts`** ```typescript import { InputParseError } from "../../entities/errors/common"; import { getHeaderInputSchema, type GetHeaderOutput, type IGetHeaderUseCase, } from "../../application/use-cases/get-header.use-case"; function presenter(value: GetHeaderOutput) { return value; } export type IGetHeaderController = ReturnType; export const getHeaderController = (getHeaderUseCase: IGetHeaderUseCase) => async (input: unknown): Promise> => { const parsed = getHeaderInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid get-header input", { cause: parsed.error }); } const result = await getHeaderUseCase(parsed.data); return presenter(result); }; ``` - [ ] **Step 4: Update controller test (call with `{}`).** - [ ] **Step 5: Create `packages/navigation/src/integrations/api/procedures.ts`** ```typescript import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import { HeaderNotFoundError } from "../../entities/errors/header"; import { InputParseError } from "../../entities/errors/common"; export const navigationProcedure = t.procedure.use( defineErrorMiddleware([ [InputParseError, "BAD_REQUEST"], [HeaderNotFoundError, "NOT_FOUND"], ]), ); ``` - [ ] **Step 6: Update router** ```typescript import { router } from "@repo/core-shared/trpc/init"; import { navigationContainer } from "../../di/container"; import { NAVIGATION_SYMBOLS } from "../../di/symbols"; import { getHeaderInputSchema } from "../../application/use-cases/get-header.use-case"; import type { IGetHeaderController } from "../../interface-adapters/controllers/get-header.controller"; import { navigationProcedure } from "./procedures"; export const navigationRouter = router({ header: navigationProcedure .input(getHeaderInputSchema) .query(({ input }) => { const ctrl = navigationContainer.get( NAVIGATION_SYMBOLS.IGetHeaderController, ); return ctrl(input); }), }); export type NavigationRouter = typeof navigationRouter; ``` - [ ] **Step 7: Add R26 test in `router.test.ts`** — `HeaderNotFoundError → NOT_FOUND` and bad input → `BAD_REQUEST`. - [ ] **Step 8: Create `packages/navigation/src/ui/index.ts`** ```typescript export { headerQuery } from "./query"; ``` - [ ] **Step 9: Update `packages/navigation/src/index.ts`** — remove `headerQuery`; add schemas + types + IUseCase / IController aliases. - [ ] **Step 10: Add `./ui` to `package.json`.** - [ ] **Step 11: Validate all green; update changelog; commit:** ```bash git add packages/navigation docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md git commit -m "$(cat <<'EOF' refactor(navigation): unify use-case I/O schemas + presenter + feature error map Per Plan 9 (spec R1-R28): - getHeader use case: input z.object({}).strict() (R5); output = headerSchema parsed at runtime. - getHeader controller: unknown input + identity presenter. - New integrations/api/procedures.ts with navigationProcedure ([InputParseError → BAD_REQUEST], [HeaderNotFoundError → NOT_FOUND]). - Router uses navigationProcedure + .input(getHeaderInputSchema). - src/index.ts: remove headerQuery; export schemas + IUseCase/Controller aliases. - src/ui/index.ts (NEW); package.json adds ./ui subpath. - R25 + R26 tests added. Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2 Spec: R1–R6, R8–R15, R18–R20, R22–R26 EOF )" ``` --- ## Task 7: Migrate `media` feature Three use cases — `get-media`, `list-media`, `delete-media`. Delete is void output. **Files:** all media use cases + tests, all media controllers + tests, `procedures.ts` (new), `router.ts`, `router.test.ts`, `src/index.ts`, `src/ui/index.ts` (new), `package.json`. ### 7.A — `get-media` use case - [ ] **Step 1: Update** ```typescript import { z } from "zod"; import { MediaNotFoundError } from "../../entities/errors/media"; import { mediaSchema } from "../../entities/models/media"; import type { IMediaRepository } from "../repositories/media.repository.interface"; export const getMediaInputSchema = z.object({ id: z.string().min(1) }).strict(); export type GetMediaInput = z.infer; export const getMediaOutputSchema = mediaSchema; export type GetMediaOutput = z.infer; export type IGetMediaUseCase = ReturnType; export const getMediaUseCase = (mediaRepository: IMediaRepository) => async (input: GetMediaInput): Promise => { const media = await mediaRepository.getMedia(input.id); if (!media) { throw new MediaNotFoundError(`Media with id "${input.id}" not found`); } return getMediaOutputSchema.parse(media); }; ``` - [ ] **Step 2: Add R25 test for malformed media. Run.** ### 7.B — `list-media` use case - [ ] **Step 3: Update** ```typescript import { z } from "zod"; import { mediaSchema } from "../../entities/models/media"; import type { IMediaRepository } from "../repositories/media.repository.interface"; export const listMediaInputSchema = z .object({ limit: z.number().int().positive().optional(), offset: z.number().int().nonnegative().optional(), }) .strict(); export type ListMediaInput = z.infer; export const listMediaOutputSchema = z.array(mediaSchema); export type ListMediaOutput = z.infer; export type IListMediaUseCase = ReturnType; export const listMediaUseCase = (mediaRepository: IMediaRepository) => async (input: ListMediaInput): Promise => { const result = await mediaRepository.listMedia(input); return listMediaOutputSchema.parse(result); }; ``` - [ ] **Step 4: Add R25 test, run.** ### 7.C — `delete-media` use case (void output) - [ ] **Step 5: Update** ```typescript import { z } from "zod"; import { MediaNotFoundError } from "../../entities/errors/media"; import type { IMediaRepository } from "../repositories/media.repository.interface"; export const deleteMediaInputSchema = z.object({ id: z.string().min(1) }).strict(); export type DeleteMediaInput = z.infer; // No output schema — use case returns void. export type IDeleteMediaUseCase = ReturnType; export const deleteMediaUseCase = (mediaRepository: IMediaRepository) => async (input: DeleteMediaInput): Promise => { const existing = await mediaRepository.getMedia(input.id); if (!existing) { throw new MediaNotFoundError(`Media with id "${input.id}" not found`); } await mediaRepository.deleteMedia(input.id); }; ``` (Verify against existing logic — adapt to existing throw vs return-undefined behavior.) - [ ] **Step 6: Update tests, run.** ### 7.D — Controllers - [ ] **Step 7-9: `get-media.controller.ts`, `list-media.controller.ts` — both with identity presenter; `delete-media.controller.ts` — no presenter, void return.** `delete-media` controller (R12): ```typescript import { InputParseError } from "../../entities/errors/common"; import { deleteMediaInputSchema, type IDeleteMediaUseCase, } from "../../application/use-cases/delete-media.use-case"; export type IDeleteMediaController = ReturnType; export const deleteMediaController = (deleteMediaUseCase: IDeleteMediaUseCase) => async (input: unknown): Promise => { const parsed = deleteMediaInputSchema.safeParse(input); if (!parsed.success) { throw new InputParseError("Invalid delete-media input", { cause: parsed.error }); } await deleteMediaUseCase(parsed.data); }; ``` `get-media` and `list-media` controllers follow blog's identity-presenter pattern. - [ ] **Step 10: Update all three controller tests; run.** ### 7.E — `procedures.ts` (media) - [ ] **Step 11: Create** ```typescript import { t } from "@repo/core-shared/trpc/init"; import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; import { MediaNotFoundError } from "../../entities/errors/media"; import { InputParseError } from "../../entities/errors/common"; export const mediaProcedure = t.procedure.use( defineErrorMiddleware([ [InputParseError, "BAD_REQUEST"], [MediaNotFoundError, "NOT_FOUND"], ]), ); ``` ### 7.F — Router - [ ] **Step 12: Update** ```typescript import { router } from "@repo/core-shared/trpc/init"; import { mediaContainer } from "../../di/container"; import { MEDIA_SYMBOLS } from "../../di/symbols"; import { getMediaInputSchema } from "../../application/use-cases/get-media.use-case"; import { listMediaInputSchema } from "../../application/use-cases/list-media.use-case"; import { deleteMediaInputSchema } from "../../application/use-cases/delete-media.use-case"; import type { IGetMediaController } from "../../interface-adapters/controllers/get-media.controller"; import type { IListMediaController } from "../../interface-adapters/controllers/list-media.controller"; import type { IDeleteMediaController } from "../../interface-adapters/controllers/delete-media.controller"; import { mediaProcedure } from "./procedures"; export const mediaRouter = router({ getMedia: mediaProcedure.input(getMediaInputSchema).query(({ input }) => { const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IGetMediaController); return ctrl(input); }), listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => { const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IListMediaController); return ctrl(input); }), deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => { const ctrl = mediaContainer.get(MEDIA_SYMBOLS.IDeleteMediaController); return ctrl(input); }), }); export type MediaRouter = typeof mediaRouter; ``` ### 7.G — Router test (R26) - [ ] **Step 13: Add R26 test** — `MediaNotFoundError → NOT_FOUND`, `BAD_REQUEST` on bad input. ### 7.H — Public-API surface - [ ] **Step 14: Create `packages/media/src/ui/index.ts`** Media has no UI today. Placeholder: ```typescript // packages/media/src/ui/index.ts // Media has no React Query option builders today. This file is the // public UI surface for future components and queries — extend rather // than re-add to root index.ts. export {}; ``` - [ ] **Step 15: Update `packages/media/src/index.ts`** — add schemas + types + IUseCase / IController aliases (mirroring blog). - [ ] **Step 16: Add `./ui` to `packages/media/package.json`.** ### 7.I — Verify + changelog + commit - [ ] **Step 17: Full validation.** ```bash pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries ``` - [ ] **Step 18: Update changelog.** - [ ] **Step 19: Commit** ```bash git add packages/media docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md git commit -m "$(cat <<'EOF' refactor(media): unify use-case I/O schemas + presenter + feature error map Per Plan 9 (spec R1-R28): - Use cases: input + output schemas (getMedia, listMedia); deleteMedia has input schema only (void output, R12 — no presenter). - Controllers: unknown input + identity presenter on getMedia/listMedia; Promise on deleteMedia. - New integrations/api/procedures.ts with mediaProcedure ([InputParseError → BAD_REQUEST], [MediaNotFoundError → NOT_FOUND]). - Router uses mediaProcedure + .input(xInputSchema). - src/index.ts exports schemas + types; src/ui/index.ts placeholder (media has no queries today); package.json adds ./ui subpath. - R25 + R26 tests added. Refactor log: §1, §2, §3.1, §3.2, §3.3, §5.1, §5.2, §6.1, §6.2 Spec: R1–R6, R8–R15, R18–R20, R22–R26 EOF )" ``` --- ## Task 8: Final verification + boundary sweep **Files:** none modified directly. Verification only. If any straggler imports surface (e.g., a test still casting input as `Partial`), fix in this task with a separate small commit. - [ ] **Step 1: Run full validation** ```bash pnpm install pnpm typecheck pnpm lint pnpm test pnpm turbo boundaries pnpm build ``` All green. - [ ] **Step 2: Verify Plan 9 acceptance criteria from spec §8** Check each: ```bash # Every use case exports xInputSchema: grep -L "export const .*InputSchema" packages/*/src/application/use-cases/*.ts | grep -v test # Expected: empty (every non-test file has a schema) # Every non-void use case exports xOutputSchema: # (manual review; void use cases are sign-out and delete-media) grep -L "export const .*OutputSchema" packages/*/src/application/use-cases/*.ts | grep -v test # Expected: only sign-out.use-case.ts and delete-media.use-case.ts # Every controller has presenter or returns void: grep -L "function presenter\|Promise" packages/*/src/interface-adapters/controllers/*.ts | grep -v test # Expected: empty # Every feature has procedures.ts: ls packages/{auth,blog,marketing-pages,navigation,media}/src/integrations/api/procedures.ts # Expected: 5 files listed # Every feature package.json has ./ui: for f in auth blog marketing-pages navigation media; do grep -q '"./ui"' packages/$f/package.json && echo "$f: OK" || echo "$f: MISSING" done # Expected: 5 × OK ``` - [ ] **Step 3: Spot-check one tRPC error response per feature** Pick one router test per feature; ensure each emits the expected `TRPCError.code`. Already covered by R26 tests in each feature's `router.test.ts` — verify output: ```bash pnpm test -- router.test ``` - [ ] **Step 4: If anything is non-conformant, fix it inline and commit** ```bash git add git commit -m "chore(plan-9): straggler fixes from final verification Plan 9 acceptance sweep caught . Refactor log §7." ``` If everything's green, no commit needed. - [ ] **Step 5: Update changelog with verification summary** under §7 (Open issues / deferred decisions or a new "Verification" subsection). ```markdown ## 7. Open issues / deferred decisions (Plan 9 verification, 2026-05-06): - All R1–R28 conformance checks passed against the spec. - Tests: total (was pre-Plan-9). Net delta: (R25/R26 additions). - typecheck / lint / boundaries / build / test all green. - Doc-update pass remains deferred — combined with paused Plan 8 items, executes in a single follow-up pass. ``` (Fill in actual numbers from `pnpm test` output.) --- ## Task 9: ADR-013 + final changelog summary **Files:** - Create: `docs/decisions/adr-013-input-output-unification.md` - Modify: `docs/decisions/adr-012-lazar-conformance.md` (one-line cross-reference) - Modify: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (top "Summary" section) - [ ] **Step 1: Create ADR-013** Write `docs/decisions/adr-013-input-output-unification.md`: ```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>`. 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` (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//ui`; apps that need the type-only contract import from `@repo/`. ## 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//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: 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. (Fill in N from actual test count after Task 8 verification.) ## 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) ``` - [ ] **Step 2: Append cross-reference to ADR-012** Edit `docs/decisions/adr-012-lazar-conformance.md`. At the very end (after the existing References section), add a new section: ```markdown ## Update — 2026-05-06 Plan 9 (ADR-013) further unifies the input/output schema story: schemas now live in the use-case file (a refinement of §What we adopted #1's "factory-function use cases"); controllers gain a co-located `function presenter` (extending §What we adopted #4's "one-controller-per-use-case"); domain error → `TRPCError` translation runs through a per-feature middleware factory (a new concern not in this ADR). See `docs/decisions/adr-013-input-output-unification.md`. ``` - [ ] **Step 3: Add Summary section to refactor changelog** Prepend a Summary section to `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (right after the front-matter): ```markdown ## Summary **Completed:** 2026-05-06 **Branch:** main (or feature/io-unification per execution choice) **Total commits:** 9 (Tasks 1–9) **Net test count change:**
 (+)

| Category | Count |
|---|---|
| Files added | 5 × procedures.ts + 5 × ui/index.ts + 1 × define-error-middleware{.ts,.test.ts} + 1 × ADR + 1 × changelog = 14 (approx; some ui/index.ts replace existing query.ts) |
| Files modified | 12 use cases + 12 controllers + 5 routers + 5 src/index.ts + 5 package.json + 5 router tests + ~25 test files = ~70 |
| New tRPC error codes mapped | 5 (BAD_REQUEST, NOT_FOUND, UNAUTHORIZED, FORBIDDEN, plus tRPC built-in BAD_REQUEST from zod) |

### Tasks completed

| Task | Commit | Description |
|---|---|---|
| 1 |  | Refactor changelog scaffold |
| 2 |  | core-shared defineErrorMiddleware + t export |
| 3 |  | auth migration |
| 4 |  | blog migration |
| 5 |  | marketing-pages migration |
| 6 |  | navigation migration |
| 7 |  | media migration |
| 8 |  | Final verification (no commit if clean) |
| 9 |  | ADR-013 + changelog summary |

### Conformance verification (Task 8)

Spec acceptance criteria §8: all met.
- Every use case exports xInputSchema; non-void use cases also export xOutputSchema.
- Every non-void controller has a top-level presenter and uses ReturnType.
- Every feature has procedures.ts with feature-scoped error map.
- core-shared/trpc/define-error-middleware.ts is the only plumbing in core-shared; no central name→code registry.
- Per-feature package.json has ./ui subpath; root index.ts no longer exports query builders.
- R25 (output-validation) test exists per non-void use case.
- R26 (router error-mapping) test exists per feature.
- pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm build all green.
```

(Fill in `` and `
//` from actual git log + test counts.)

- [ ] **Step 4: Final commit**

```bash
git add docs/decisions/adr-013-input-output-unification.md docs/decisions/adr-012-lazar-conformance.md docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
git commit -m "$(cat <<'EOF'
docs(adr): ADR-013 input/output unification + Plan 9 changelog summary

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
EOF
)"
```

---

## Self-review (after writing the plan)

- [x] Spec coverage: every rule R1–R30 is represented in at least one task.
  - R1, R2, R3, R4, R5: Tasks 3.A–7.B (every use case migration step shows the schema + parse).
  - R6 (error class names): existing classes already set `this.name` per Plan 8 — verified by R26 tests in each feature router test.
  - R7, R8, R9, R10: every controller migration step (3.D, 3.E, 3.F, 4.D, 5.C, 6.3, 7.D).
  - R11, R12: presenter / void distinction shown explicitly per controller.
  - R13, R14, R15: each feature creates `procedures.ts` and updates router (3.G–3.H, 4.E–4.F, 5.D–5.E, 6.5–6.6, 7.E–7.F).
  - R16, R17: Task 2 implements `defineErrorMiddleware` with `instanceof` matching.
  - R18, R19, R20, R21: per-feature `src/index.ts` cleanup + `./ui` subpath + apps unaffected (no current consumers).
  - R22, R23: existing Plan 8 DI pattern retained — no changes needed.
  - R24, R25, R26, R27, R28: tests added per feature; identity presenters mean R27/R28 are vacuously satisfied (no shape change to assert).
  - R29: Task 9 creates ADR-013.
  - R30: Task 1 scaffolds the refactor log.
- [x] Placeholder scan: no "TBD"/"TODO"; all code blocks are concrete; commit messages are full text.
- [x] Type consistency: every `xInputSchema`/`XInput` pair lines up; controllers reference `XOutput` types from their use case; every router imports schemas from the same path used in tests.
- [x] No cross-task references — each per-feature task is self-contained (the auth task does not depend on the blog task being done).

## Execution

Per `superpowers:subagent-driven-development`: dispatch one implementer subagent per task with full task text + context, then spec compliance review, then code quality review, then proceed.