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>
100 KiB
Plan 9 — Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping + Public-Surface Cleanup
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make every use case the single source of truth for its input AND output contract. Schemas live in the use-case file, are imported by both the controller and the tRPC procedure, and the use-case body runtime-validates output via .parse(...). Controllers gain co-located function presenter(...) (Lazar pattern). Domain errors map to TRPCError via per-feature middleware (defineErrorMiddleware factory in core-shared/trpc/). Per-feature public surface is split: root . = contracts; new ./ui subpath = UI artifacts.
Architecture: Use-case file becomes the only place an input/output shape is defined; everywhere else imports xInputSchema / xOutputSchema / XInput / XOutput. Each feature gets integrations/api/procedures.ts exporting an xProcedure built from t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"], ...])). Routers swap publicProcedure → xProcedure and .input(z.object({...})) → .input(xInputSchema). Each feature's src/index.ts keeps only contracts (types, errors, schemas, IXUseCase aliases, router type, constants). Query builders move to src/ui/index.ts exposed via the new ./ui subpath.
Tech Stack: TypeScript, Zod 3, tRPC 11, InversifyJS 6, Vitest 3, @repo/core-shared, @repo/core-testing.
Spec: docs/superpowers/specs/2026-05-06-input-output-unification-design.md — read first if any task is unclear.
Branch: feature/io-unification (or execute on main directly per project convention).
Refactor changelog: Maintain docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md throughout — every task ends with a changelog update.
Cross-cutting conventions (re-read at start of every task)
- TDD always — failing test, run, RED, implement, run, GREEN, refactor, commit.
- Source files use relative imports; test files use
@/alias. - No central error map —
core-sharedMUST NOT enumerate any feature's error class. Each feature'sprocedures.tsowns its tuples. - Object-shaped inputs only — every
xInputSchemais az.ZodObject(usez.object({}).strict()for void; wrap primitive args in an object). - Presenter always for non-void output — even identity
(x) => x(R11). Void use cases skip the presenter (R12). - Don't touch CLAUDE.md / AGENTS.md / docs/guides/ during this plan — those updates are deferred per spec §10.
- Commit per task with the message in each task's final step.
- After each task —
pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries. All green. - Update the refactor changelog at the end of each task before commit.
Task 1: Refactor changelog scaffold
Files:
-
Create:
docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md -
Step 1: Verify the directory exists
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(exporttinstance) -
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
tto the exports ofinit.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.jsonexports
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: R13–R17
EOF
)"
Task 3: Migrate auth feature
Files:
- Modify:
packages/auth/src/application/use-cases/sign-in.use-case.ts - Modify:
packages/auth/src/application/use-cases/sign-in.use-case.test.ts - Modify:
packages/auth/src/application/use-cases/sign-up.use-case.ts - Modify:
packages/auth/src/application/use-cases/sign-up.use-case.test.ts - Modify:
packages/auth/src/application/use-cases/sign-out.use-case.ts - Modify:
packages/auth/src/application/use-cases/sign-out.use-case.test.ts - Modify:
packages/auth/src/interface-adapters/controllers/sign-in.controller.ts - Modify:
packages/auth/src/interface-adapters/controllers/sign-in.controller.test.ts - Modify:
packages/auth/src/interface-adapters/controllers/sign-up.controller.ts - Modify:
packages/auth/src/interface-adapters/controllers/sign-up.controller.test.ts - Modify:
packages/auth/src/interface-adapters/controllers/sign-out.controller.ts - Modify:
packages/auth/src/interface-adapters/controllers/sign-out.controller.test.ts - Create:
packages/auth/src/integrations/api/procedures.ts - Modify:
packages/auth/src/integrations/api/router.ts - Modify or Create:
packages/auth/src/integrations/api/router.test.ts(add R26) - Create:
packages/auth/src/ui/index.ts - Modify:
packages/auth/src/index.ts(export schemas) - Modify:
packages/auth/package.json(add./uisubpath)
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.tswith 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.tsanalogously
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.tsand 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.tsand 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.tsto 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
./uisubpath topackage.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: R1–R6, R8–R15, R18, R19, R22–R26
EOF
)"
Task 4: Migrate blog feature
Files:
- Modify: every blog use-case + test (
get-articles,create-article,get-article-by-slug) - Modify: every blog controller + test (same three)
- Create:
packages/blog/src/integrations/api/procedures.ts - Modify:
packages/blog/src/integrations/api/router.ts - Modify or Create:
packages/blog/src/integrations/api/router.test.ts - Modify:
packages/blog/src/index.ts - Move:
articleBySlugQuery,listArticlesQueryfrom currentsrc/index.ts→src/ui/index.ts - Modify:
packages/blog/package.json(add./ui)
4.A — get-articles use case
- Step 1: Read current files for context
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.tscalls
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.tscalls
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.1–3.3, §5.1–5.2, §6.1–6.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: R1–R6, R8–R15, R18–R20, R22–R26
EOF
)"
Task 5: Migrate marketing-pages feature
Mirror Task 4 structure for two use cases (get-page-by-slug, get-site-settings) and two controllers. Site-settings has VOID input (getSiteSettings()) per current code — the schema becomes z.object({}).strict() (R5).
Files: all packages/marketing-pages/src/application/use-cases/*.{ts,test.ts}, all packages/marketing-pages/src/interface-adapters/controllers/*.{ts,test.ts}, integrations/api/procedures.ts (new), integrations/api/router.ts, integrations/api/router.test.ts, src/index.ts, src/ui/index.ts (new), package.json.
5.A — get-page-by-slug use case
- Step 1: Read current files
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.tswith presenter (identity),unknowninput, schema import. Pattern identical to blog. -
Step 7: Update
get-site-settings.controller.tssimilarly. Note: must callcontroller({})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_FOUNDand 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
./uitopackage.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: R1–R6, R8–R15, R18–R20, R22–R26
EOF
)"
Task 6: Migrate navigation feature
Single use case (get-header) — void input, non-void output.
Files: application/use-cases/get-header.use-case.{ts,test.ts}, interface-adapters/controllers/get-header.controller.{ts,test.ts}, integrations/api/procedures.ts (new), integrations/api/router.ts, integrations/api/router.test.ts, src/index.ts, src/ui/index.ts (new), package.json.
- Step 1: Update
get-header.use-case.ts
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.ts—HeaderNotFoundError → NOT_FOUNDand 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— removeheaderQuery; add schemas + types + IUseCase / IController aliases. -
Step 10: Add
./uitopackage.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: R1–R6, R8–R15, R18–R20, R22–R26
EOF
)"
Task 7: Migrate media feature
Three use cases — get-media, list-media, delete-media. Delete is void output.
Files: all media use cases + tests, all media controllers + tests, procedures.ts (new), router.ts, router.test.ts, src/index.ts, src/ui/index.ts (new), package.json.
7.A — get-media use case
- Step 1: Update
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 test —
MediaNotFoundError → NOT_FOUND,BAD_REQUESTon 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
./uitopackages/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: R1–R6, R8–R15, R18–R20, R22–R26
EOF
)"
Task 8: Final verification + boundary sweep
Files: none modified directly. Verification only. If any straggler imports surface (e.g., a test still casting input as Partial<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 R1–R28 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 R1–R28 represented.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
&& pnpm build` green.
- Five feature-level R26 router-error-mapping tests demonstrate domain
error → `TRPCError.code` translation works end-to-end.
(Fill in N from actual test count after Task 8 verification.)
## References
- Spec: `docs/superpowers/specs/2026-05-06-input-output-unification-design.md`
- Plan: `docs/superpowers/plans/2026-05-06-plan-9-io-unification.md`
- Refactor log: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`
- Reference (Lazar's blog post + repo): https://github.com/nikolovlazar/nextjs-clean-architecture
- Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
- Step 2: Append cross-reference to ADR-012
Edit docs/decisions/adr-012-lazar-conformance.md. At the very end (after the existing References section), add a new section:
## 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 1–9)
**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 R1–R30 is represented in at least one task.
- R1, R2, R3, R4, R5: Tasks 3.A–7.B (every use case migration step shows the schema + parse).
- R6 (error class names): existing classes already set
this.nameper 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.tsand updates router (3.G–3.H, 4.E–4.F, 5.D–5.E, 6.5–6.6, 7.E–7.F). - R16, R17: Task 2 implements
defineErrorMiddlewarewithinstanceofmatching. - R18, R19, R20, R21: per-feature
src/index.tscleanup +./uisubpath + 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/XInputpair lines up; controllers referenceXOutputtypes 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.