Files
agentic-dev/docs/superpowers/plans/2026-05-06-plan-9-io-unification.md
Danijel Martinek 88db39b3a3 docs(plan): Plan 9 — input/output unification implementation plan
9 tasks, TDD throughout, one commit per task:

1. Refactor changelog scaffold
2. core-shared/trpc/define-error-middleware.ts factory + tests
3. auth migration (3 use cases — schemas + presenter + procedures.ts + router + ./ui + tests)
4. blog migration (3 use cases)
5. marketing-pages migration (2 use cases)
6. navigation migration (1 use case)
7. media migration (3 use cases)
8. Final verification + boundary sweep
9. ADR-013 + final changelog summary

Every step has concrete code; no placeholders. Self-review shows full
spec coverage (R1–R30) with each rule mapped to one or more tasks.

Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:29:28 +02:00

100 KiB
Raw Blame History

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 publicProcedurexProcedure 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 mapcore-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 taskpnpm 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

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:

# 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<typeof xInputSchema>`); schema lives in same file |
| Use case returns `Promise<Entity>` | Use case returns `Promise<XOutput>` validated by `xOutputSchema.parse(...)` |
| Controller imports `z` and defines local `inputSchema` | Controller imports `xInputSchema` from the use-case file |
| Controller input typed `Partial<z.infer<...>>` | Controller input typed `unknown` |
| Controller returns `Promise<Entity>` | Controller has `function presenter(value: XOutput)`; returns `Promise<ReturnType<typeof presenter>>` (or `Promise<void>` 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
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
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

cat packages/core-shared/src/trpc/init.ts

Expected to see (verify exact content before editing):

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:

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:

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
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:

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<readonly [ErrorCtor, TRPC_ERROR_CODE_KEY]>,
) {
  return t.middleware(async ({ next }) => {
    try {
      return await next();
    } catch (e) {
      if (e instanceof Error) {
        for (const [Ctor, code] of map) {
          if (e instanceof Ctor) {
            throw new TRPCError({ code, message: e.message, cause: e });
          }
        }
      }
      throw e;
    }
  });
}
  • Step 6: Run the test to verify GREEN
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:

{
  "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
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
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: R13R17
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
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):

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
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:

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<typeof signInInputSchema>;

// ── Output ───────────────────────────────────────────────────────────────
export const signInOutputSchema = z.object({
  session: sessionSchema,
  cookie: cookieSchema,
});
export type SignInOutput = z.infer<typeof signInOutputSchema>;

// ── Use case ─────────────────────────────────────────────────────────────
export type ISignInUseCase = ReturnType<typeof signInUseCase>;

export const signInUseCase =
  (usersRepository: IUsersRepository, authenticationService: IAuthenticationService) =>
  async (input: SignInInput): Promise<SignInOutput> => {
    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):

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
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:

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<typeof signUpInputSchema>;

export const signUpOutputSchema = z.object({
  session: sessionSchema,
  cookie: cookieSchema,
});
export type SignUpOutput = z.infer<typeof signUpOutputSchema>;

Update the factory signature: (input: SignUpInput): Promise<SignUpOutput>. 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:

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):

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<typeof signOutInputSchema>;

// No xOutputSchema — use case returns void.

export type ISignOutUseCase = ReturnType<typeof signOutUseCase>;

export const signOutUseCase =
  (authenticationService: IAuthenticationService) =>
  async (input: SignOutInput): Promise<void> => {
    await authenticationService.invalidateSession(input.sessionId);
  };
  • Step 9: Run sign-out tests
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.

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
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:

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<typeof signInController>;

export const signInController =
  (signInUseCase: ISignInUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    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
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:

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<typeof signUpController>;

export const signUpController =
  (signUpUseCase: ISignUpUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    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<void>):

import { InputParseError } from "../../entities/errors/common";
import {
  signOutInputSchema,
  type ISignOutUseCase,
} from "../../application/use-cases/sign-out.use-case";

export type ISignOutController = ReturnType<typeof signOutController>;

export const signOutController =
  (signOutUseCase: ISignOutUseCase) =>
  async (input: unknown): Promise<void> => {
    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):

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
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:

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<ISignInController>(AUTH_SYMBOLS.ISignInController);
    return ctrl(input);
  }),

  signUp: authProcedure.input(signUpInputSchema).mutation(({ input }) => {
    const ctrl = authContainer.get<ISignUpController>(AUTH_SYMBOLS.ISignUpController);
    return ctrl(input);
  }),

  signOut: authProcedure.input(signOutInputSchema).mutation(({ input }) => {
    const ctrl = authContainer.get<ISignOutController>(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)
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:

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<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository).toConstantValue(users);
    authContainer
      .bind<IAuthenticationService>(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
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
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:

// 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:

# 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:

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:

{
  "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
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<typeof presenter> 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<void> 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
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: R1R6, R8R15, R18, R19, R22R26
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.tssrc/ui/index.ts
  • Modify: packages/blog/package.json (add ./ui)

4.A — get-articles use case

  • Step 1: Read current files for context
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<Article[]>.

  • Step 2: Write failing R25 test for get-articles

Add to packages/blog/src/application/use-cases/get-articles.use-case.test.ts:

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
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:

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<typeof getArticlesInputSchema>;

export const getArticlesOutputSchema = z.array(articleSchema);
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;

export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;

export const getArticlesUseCase =
  (articlesRepository: IArticlesRepository) =>
  async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
    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
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
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<typeof createArticleInputSchema>;

export const createArticleOutputSchema = articleSchema;
export type CreateArticleOutput = z.infer<typeof createArticleOutputSchema>;

function generateSlug(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
}

export type ICreateArticleUseCase = ReturnType<typeof createArticleUseCase>;

export const createArticleUseCase =
  (articlesRepository: IArticlesRepository) =>
  async (input: CreateArticleInput): Promise<CreateArticleOutput> => {
    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:

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
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<typeof getArticleBySlugInputSchema>;

export const getArticleBySlugOutputSchema = articleSchema;
export type GetArticleBySlugOutput = z.infer<typeof getArticleBySlugOutputSchema>;

export type IGetArticleBySlugUseCase = ReturnType<typeof getArticleBySlugUseCase>;

export const getArticleBySlugUseCase =
  (articlesRepository: IArticlesRepository) =>
  async (input: GetArticleBySlugInput): Promise<GetArticleBySlugOutput> => {
    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
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<typeof getArticlesController>;

export const getArticlesController =
  (getArticlesUseCase: IGetArticlesUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    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:

pnpm typecheck --filter @repo/blog

If TS complains about controller({ ... } as ...), simplify by passing the value directly (the param is unknown, anything fits):

await expect(controller({ limit: "not a number" })).rejects.toBeInstanceOf(InputParseError);

Run tests. Green.

  • Step 13: Update create-article.controller.ts
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<typeof createArticleController>;

export const createArticleController =
  (createArticleUseCase: ICreateArticleUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    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
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<typeof getArticleBySlugController>;

export const getArticleBySlugController =
  (getArticleBySlugUseCase: IGetArticleBySlugUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    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
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
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
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<IGetArticleBySlugController>(
        BLOG_SYMBOLS.IGetArticleBySlugController,
      );
      return ctrl(input);
    }),

  listArticles: blogProcedure
    .input(getArticlesInputSchema)
    .query(({ input }) => {
      const ctrl = blogContainer.get<IGetArticlesController>(
        BLOG_SYMBOLS.IGetArticlesController,
      );
      return ctrl(input);
    }),

  createArticle: blogProcedure
    .input(createArticleInputSchema)
    .mutation(({ input }) => {
      const ctrl = blogContainer.get<ICreateArticleController>(
        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
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<IArticlesRepository>(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:

// packages/blog/src/ui/index.ts
export { articleBySlugQuery, listArticlesQuery } from "./query";
  • Step 20: Update packages/blog/src/index.ts (remove queries; add schemas)
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
{
  "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
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
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.13.3, §5.15.2, §6.16.2 entries).

  • Step 25: Commit

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: R1R6, R8R15, R18R20, R22R26
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
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
// 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<typeof getPageBySlugInputSchema>;

export const getPageBySlugOutputSchema = pageSchema;
export type GetPageBySlugOutput = z.infer<typeof getPageBySlugOutputSchema>;

export type IGetPageBySlugUseCase = ReturnType<typeof getPageBySlugUseCase>;

export const getPageBySlugUseCase =
  (pagesRepository: IPagesRepository) =>
  async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput> => {
    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:

export const getPageBySlugOutputSchema = pageSchema;
// ...
async (input: GetPageBySlugInput): Promise<GetPageBySlugOutput | undefined> => {
  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
// 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<typeof getSiteSettingsInputSchema>;

export const getSiteSettingsOutputSchema = siteSettingsSchema;
export type GetSiteSettingsOutput = z.infer<typeof getSiteSettingsOutputSchema>;

export type IGetSiteSettingsUseCase = ReturnType<typeof getSiteSettingsUseCase>;

export const getSiteSettingsUseCase =
  (siteSettingsRepository: ISiteSettingsRepository) =>
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  async (_input: GetSiteSettingsInput): Promise<GetSiteSettingsOutput> => {
    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
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
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<IGetPageBySlugController>(
        MARKETING_PAGES_SYMBOLS.IGetPageBySlugController,
      );
      return ctrl(input);
    }),

  siteSettings: marketingPagesProcedure
    .input(getSiteSettingsInputSchema)
    .query(({ input }) => {
      const ctrl = marketingPagesContainer.get<IGetSiteSettingsController>(
        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
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.
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
  • Step 16: Update changelog with marketing-pages entries.

  • Step 17: Commit

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: R1R6, R8R15, R18R20, R22R26
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
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<typeof getHeaderInputSchema>;

export const getHeaderOutputSchema = headerSchema;
export type GetHeaderOutput = z.infer<typeof getHeaderOutputSchema>;

export type IGetHeaderUseCase = ReturnType<typeof getHeaderUseCase>;

export const getHeaderUseCase =
  (headerRepository: IHeaderRepository) =>
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  async (_input: GetHeaderInput): Promise<GetHeaderOutput> => {
    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

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<typeof getHeaderController>;

export const getHeaderController =
  (getHeaderUseCase: IGetHeaderUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    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

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
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<IGetHeaderController>(
        NAVIGATION_SYMBOLS.IGetHeaderController,
      );
      return ctrl(input);
    }),
});

export type NavigationRouter = typeof navigationRouter;
  • Step 7: Add R26 test in router.test.tsHeaderNotFoundError → NOT_FOUND and bad input → BAD_REQUEST.

  • Step 8: Create packages/navigation/src/ui/index.ts

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:

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: R1R6, R8R15, R18R20, R22R26
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
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<typeof getMediaInputSchema>;

export const getMediaOutputSchema = mediaSchema;
export type GetMediaOutput = z.infer<typeof getMediaOutputSchema>;

export type IGetMediaUseCase = ReturnType<typeof getMediaUseCase>;

export const getMediaUseCase =
  (mediaRepository: IMediaRepository) =>
  async (input: GetMediaInput): Promise<GetMediaOutput> => {
    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
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<typeof listMediaInputSchema>;

export const listMediaOutputSchema = z.array(mediaSchema);
export type ListMediaOutput = z.infer<typeof listMediaOutputSchema>;

export type IListMediaUseCase = ReturnType<typeof listMediaUseCase>;

export const listMediaUseCase =
  (mediaRepository: IMediaRepository) =>
  async (input: ListMediaInput): Promise<ListMediaOutput> => {
    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
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<typeof deleteMediaInputSchema>;

// No output schema — use case returns void.

export type IDeleteMediaUseCase = ReturnType<typeof deleteMediaUseCase>;

export const deleteMediaUseCase =
  (mediaRepository: IMediaRepository) =>
  async (input: DeleteMediaInput): Promise<void> => {
    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):

import { InputParseError } from "../../entities/errors/common";
import {
  deleteMediaInputSchema,
  type IDeleteMediaUseCase,
} from "../../application/use-cases/delete-media.use-case";

export type IDeleteMediaController = ReturnType<typeof deleteMediaController>;

export const deleteMediaController =
  (deleteMediaUseCase: IDeleteMediaUseCase) =>
  async (input: unknown): Promise<void> => {
    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
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
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<IGetMediaController>(MEDIA_SYMBOLS.IGetMediaController);
    return ctrl(input);
  }),
  listMedia: mediaProcedure.input(listMediaInputSchema).query(({ input }) => {
    const ctrl = mediaContainer.get<IListMediaController>(MEDIA_SYMBOLS.IListMediaController);
    return ctrl(input);
  }),
  deleteMedia: mediaProcedure.input(deleteMediaInputSchema).mutation(({ input }) => {
    const ctrl = mediaContainer.get<IDeleteMediaController>(MEDIA_SYMBOLS.IDeleteMediaController);
    return ctrl(input);
  }),
});

export type MediaRouter = typeof mediaRouter;

7.G — Router test (R26)

  • Step 13: Add R26 testMediaNotFoundError → 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:

// 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.
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
  • Step 18: Update changelog.

  • Step 19: Commit

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<void> 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: R1R6, R8R15, R18R20, R22R26
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<XInput>), fix in this task with a separate small commit.

  • Step 1: Run full validation
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:

# 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<void>" 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:

pnpm test -- router.test
  • Step 4: If anything is non-conformant, fix it inline and commit
git add <files>
git commit -m "chore(plan-9): straggler fixes from final verification

Plan 9 acceptance sweep caught <issue summary>. 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).
## 7. Open issues / deferred decisions

(Plan 9 verification, 2026-05-06):
- All R1R28 conformance checks passed against the spec.
- Tests: <N> total (was <M> pre-Plan-9). Net delta: <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:

# ADR-013: Use-Case Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping

**Status:** Accepted
**Date:** 2026-05-06
**Supersedes:** none — extends ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
**Spec:** docs/superpowers/specs/2026-05-06-input-output-unification-design.md
**Plan:** docs/superpowers/plans/2026-05-06-plan-9-io-unification.md
**Refactor log:** docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md

## Context

Plan 8 (ADR-012) established factory-function use cases and one-controller-
per-use-case. But the input contract was still defined three times — once
in the tRPC procedure's `.input(z.object({...}))`, once in the controller's
local `const inputSchema`, and once implicitly in the use case's TypeScript
parameter type. The three definitions drifted: the controller's
`z.string().min(3).max(31)` was stricter than the tRPC version's
`z.string()`. Output validation was TypeScript-only — repositories could
return malformed values and use cases happily passed them through.

There was also no consistent error-translation between domain errors
(`ArticleNotFoundError`, `AuthenticationError`, …) and `TRPCError`,
meaning the wire response code was unpredictable per feature.

Per-feature public-API surfaces conflated UI artifacts (query builders
imported React Query) with pure contracts (entity types) on the same
top-level export, making "what does this package expose to whom" muddy.

## Decision

Adopt four interlocking patterns, codified as 30 RFC-2119 rules in the
spec:

1. **Use-case file is the single source of truth for input AND output
   contracts.** Every use case exports `xInputSchema` (always a
   `z.ZodObject`, even for void inputs via `z.object({}).strict()`) and
   — for non-void use cases — `xOutputSchema`. The use-case body ends
   with `xOutputSchema.parse(result)` before returning. Type aliases
   `XInput`/`XOutput`/`IXUseCase` are exported alongside.

2. **Controllers consume the use-case schema; output passes through a
   co-located `function presenter`.** Controllers receive `unknown`,
   `safeParse` against `xInputSchema`, throw `InputParseError` on
   failure, then call the use case and pass the result through a
   top-level `function presenter(value: XOutput)` defined in the same
   file. The controller's return type is `Promise<ReturnType<typeof
   presenter>>`. Identity presenters are permitted and expected for
   pass-through cases — the function form must always exist (R11) so
   adding a transform is a one-line edit. Void-output controllers
   (e.g., `signOutController`, `deleteMediaController`) skip the
   presenter and return `Promise<void>` (R12).

3. **Feature-scoped error→TRPCError middleware.** Each feature's
   `integrations/api/procedures.ts` exports an `xProcedure` built from
   `t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"],
   ...]))`. The factory `defineErrorMiddleware` lives in
   `core-shared/trpc/`; it discriminates by `instanceof` and preserves
   the original error as `TRPCError.cause`. **`core-shared` never
   enumerates feature-specific error classes** — each feature passes its
   own constructors in via its own `procedures.ts`. Routers use the
   feature's `xProcedure` instead of bare `publicProcedure` and
   `.input(xInputSchema)` instead of redefining input shapes.

4. **Per-feature public surface split.** Feature root `.` exports only
   contracts: domain types, domain errors, schemas, `IXUseCase`/`IXController`
   aliases, router type, constants. UI artifacts (query builders,
   future React components) move to a new `./ui` subpath
   (`src/ui/index.ts`). Apps that need queries import from
   `@repo/<feature>/ui`; apps that need the type-only contract import
   from `@repo/<feature>`.

## Consequences

### Positive

- **Single source of truth for I/O contracts.** Schema drift is no
  longer possible — there's one definition, imported by everyone.
- **Runtime-validated outputs.** `xOutputSchema.parse(...)` catches
  "repo returned malformed data" bugs at the layer that owns the
  contract, instead of silently flowing wrong shapes downstream.
- **Predictable error responses.** Every domain error maps to a known
  `TRPCError.code` via the per-feature middleware; clients can rely on
  status codes.
- **Discoverable transforms via presenter.** When a view needs to drop
  fields, rename them, or serialize dates, the presenter function is
  already there — change one function body. No structural refactor.
- **Clean public surface.** Feature root packages no longer pretend to
  be UI packages; apps make explicit choices about what they need.
- **Frontend gets schemas for free.** Forms can `import { signInInputSchema
  } from "@repo/auth"` and feed it into `react-hook-form` + `zodResolver`
  with the same constraints the backend enforces.

### Negative

- **More code.** Every use case grows by ~10 lines (input + output
  schema + parse). Every controller grows by ~5 lines (presenter,
  even if identity). Acceptable cost for the consistency.
- **Per-feature `procedures.ts` boilerplate.** Five new files (~10
  lines each) — one per feature. Maintaining the error map is one of
  the few feature-level chores; new error classes need adding to the
  map.
- **Schemas run twice on the tRPC path** (tRPC's `.input()` parse +
  controller's `safeParse`). Negligible cost; zero behavioral risk
  because both use the same schema. Defense-in-depth value when the
  controller is invoked from non-tRPC entry points.
- **Apps with existing imports may need updating** — `articleBySlugQuery`,
  `pageBySlugQuery`, etc. now live behind `@repo/<feature>/ui`.
  (At Plan 9 land time, no apps consume these yet, so the cost is
  forward-only.)

## Alternatives considered

- **Keep schemas in controllers (Lazar's reference pattern).** Lazar
  has only one validation layer (server actions skip `.input()`), so
  one schema is sufficient. Our entry point is tRPC, which insists on
  a schema for type inference — putting the canonical schema in the
  controller and exporting it for the router was considered. Rejected
  because the use case is the contract owner; schemas describe the
  *operation*, not the *transport*.

- **Centralized error-name → code map in `core-shared`.** Considered
  using `error.name` discrimination with a small global registry.
  Rejected because it violates feature ownership — `core-shared` would
  need to know about every feature's error classes. The
  `defineErrorMiddleware` factory cleanly inverts the dependency:
  `core-shared` provides the plumbing, features pass their own
  constructors.

- **Validate outputs only in tests.** Considered using TypeScript
  types alone for output, deferring runtime validation to
  contract-suite tests. Rejected because the cost of `.parse()` on
  return is trivial and the bug-catching value at runtime is real
  (Payload integrations have surprised us before).

- **Presenters only when reshaping.** Considered Lazar's actual rule
  (presenter only when there's a transform). Rejected (R11) because
  the discoverable hook for future shaping is worth the trivial
  identity-function boilerplate.

- **Presenters in a separate `presenters/` folder.** Considered as a
  concession to "controllers = thin orchestration". Rejected because
  Lazar's reference co-locates the presenter with its consumer — the
  controller — keeping the contract visible in one file.

- **Shared `./schemas` subpath.** Considered exposing schemas only via
  a dedicated subpath instead of the feature root. Rejected because
  schemas ARE feature contracts — they belong with the other contracts
  (types, errors). Adding a fourth subpath felt like ceremony.

## Acceptance verification (Task 8, 2026-05-06)

- All Plan 9 acceptance criteria from spec §8 met.
- Tests: <N> total. Spec coverage: every R1R28 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:


## 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):

## Summary

**Completed:** 2026-05-06
**Branch:** main (or feature/io-unification per execution choice)
**Total commits:** 9 (Tasks 19)
**Net test count change:** <pre><post> (+<delta>)

| 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 | <sha> | Refactor changelog scaffold |
| 2 | <sha> | core-shared defineErrorMiddleware + t export |
| 3 | <sha> | auth migration |
| 4 | <sha> | blog migration |
| 5 | <sha> | marketing-pages migration |
| 6 | <sha> | navigation migration |
| 7 | <sha> | media migration |
| 8 | <sha> | Final verification (no commit if clean) |
| 9 | <sha> | 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<typeof presenter>.
- 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 <sha> and <pre>/<post>/<delta> from actual git log + test counts.)

  • Step 4: Final commit
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)

  • Spec coverage: every rule R1R30 is represented in at least one task.
    • R1, R2, R3, R4, R5: Tasks 3.A7.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.G3.H, 4.E4.F, 5.D5.E, 6.56.6, 7.E7.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.
  • Placeholder scan: no "TBD"/"TODO"; all code blocks are concrete; commit messages are full text.
  • 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.
  • 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.