diff --git a/docs/decisions/adr-012-lazar-conformance.md b/docs/decisions/adr-012-lazar-conformance.md index 5385220..ae36053 100644 --- a/docs/decisions/adr-012-lazar-conformance.md +++ b/docs/decisions/adr-012-lazar-conformance.md @@ -168,3 +168,13 @@ See refactor log for full file-by-file inventory. - Refactor log: `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` - Reference repo: https://github.com/nikolovlazar/nextjs-clean-architecture - Prior ADRs: ADR-006 (vertical-feature-packages), ADR-008 (per-feature DI containers), ADR-011 (TDD foundation) + +## 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`. diff --git a/docs/decisions/adr-013-input-output-unification.md b/docs/decisions/adr-013-input-output-unification.md new file mode 100644 index 0000000..17b3697 --- /dev/null +++ b/docs/decisions/adr-013-input-output-unification.md @@ -0,0 +1,165 @@ +# ADR-013: Use-Case Input/Output Unification + Presenter Pattern + Feature-Scoped Error Mapping + +**Status:** Accepted +**Date:** 2026-05-06 +**Supersedes:** none — extends ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance) +**Spec:** docs/superpowers/specs/2026-05-06-input-output-unification-design.md +**Plan:** docs/superpowers/plans/2026-05-06-plan-9-io-unification.md +**Refactor log:** docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md + +## Context + +Plan 8 (ADR-012) established factory-function use cases and one-controller- +per-use-case. But the input contract was still defined three times — once +in the tRPC procedure's `.input(z.object({...}))`, once in the controller's +local `const inputSchema`, and once implicitly in the use case's TypeScript +parameter type. The three definitions drifted: the controller's +`z.string().min(3).max(31)` was stricter than the tRPC version's +`z.string()`. Output validation was TypeScript-only — repositories could +return malformed values and use cases happily passed them through. + +There was also no consistent error-translation between domain errors +(`ArticleNotFoundError`, `AuthenticationError`, …) and `TRPCError`, +meaning the wire response code was unpredictable per feature. + +Per-feature public-API surfaces conflated UI artifacts (query builders +imported React Query) with pure contracts (entity types) on the same +top-level export, making "what does this package expose to whom" muddy. + +## Decision + +Adopt four interlocking patterns, codified as 30 RFC-2119 rules in the +spec: + +1. **Use-case file is the single source of truth for input AND output + contracts.** Every use case exports `xInputSchema` (always a + `z.ZodObject`, even for void inputs via `z.object({}).strict()`) and + — for non-void use cases — `xOutputSchema`. The use-case body ends + with `xOutputSchema.parse(result)` before returning. Type aliases + `XInput`/`XOutput`/`IXUseCase` are exported alongside. + +2. **Controllers consume the use-case schema; output passes through a + co-located `function presenter`.** Controllers receive `unknown`, + `safeParse` against `xInputSchema`, throw `InputParseError` on + failure, then call the use case and pass the result through a + top-level `function presenter(value: XOutput)` defined in the same + file. The controller's return type is `Promise>`. Identity presenters are permitted and expected for + pass-through cases — the function form must always exist (R11) so + adding a transform is a one-line edit. Void-output controllers + (e.g., `signOutController`, `deleteMediaController`) skip the + presenter and return `Promise` (R12). + +3. **Feature-scoped error→TRPCError middleware.** Each feature's + `integrations/api/procedures.ts` exports an `xProcedure` built from + `t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"], + ...]))`. The factory `defineErrorMiddleware` lives in + `core-shared/trpc/`; it discriminates by `instanceof` and preserves + the original error as `TRPCError.cause`. **`core-shared` never + enumerates feature-specific error classes** — each feature passes its + own constructors in via its own `procedures.ts`. Routers use the + feature's `xProcedure` instead of bare `publicProcedure` and + `.input(xInputSchema)` instead of redefining input shapes. + +4. **Per-feature public surface split.** Feature root `.` exports only + contracts: domain types, domain errors, schemas, `IXUseCase`/`IXController` + aliases, router type, constants. UI artifacts (query builders, + future React components) move to a new `./ui` subpath + (`src/ui/index.ts`). Apps that need queries import from + `@repo//ui`; apps that need the type-only contract import + from `@repo/`. + +## Consequences + +### Positive + +- **Single source of truth for I/O contracts.** Schema drift is no + longer possible — there's one definition, imported by everyone. +- **Runtime-validated outputs.** `xOutputSchema.parse(...)` catches + "repo returned malformed data" bugs at the layer that owns the + contract, instead of silently flowing wrong shapes downstream. +- **Predictable error responses.** Every domain error maps to a known + `TRPCError.code` via the per-feature middleware; clients can rely on + status codes. +- **Discoverable transforms via presenter.** When a view needs to drop + fields, rename them, or serialize dates, the presenter function is + already there — change one function body. No structural refactor. +- **Clean public surface.** Feature root packages no longer pretend to + be UI packages; apps make explicit choices about what they need. +- **Frontend gets schemas for free.** Forms can `import { signInInputSchema + } from "@repo/auth"` and feed it into `react-hook-form` + `zodResolver` + with the same constraints the backend enforces. + +### Negative + +- **More code.** Every use case grows by ~10 lines (input + output + schema + parse). Every controller grows by ~5 lines (presenter, + even if identity). Acceptable cost for the consistency. +- **Per-feature `procedures.ts` boilerplate.** Five new files (~10 + lines each) — one per feature. Maintaining the error map is one of + the few feature-level chores; new error classes need adding to the + map. +- **Schemas run twice on the tRPC path** (tRPC's `.input()` parse + + controller's `safeParse`). Negligible cost; zero behavioral risk + because both use the same schema. Defense-in-depth value when the + controller is invoked from non-tRPC entry points. +- **Apps with existing imports may need updating** — `articleBySlugQuery`, + `pageBySlugQuery`, etc. now live behind `@repo//ui`. + (At Plan 9 land time, no apps consume these yet, so the cost is + forward-only.) + +## Alternatives considered + +- **Keep schemas in controllers (Lazar's reference pattern).** Lazar + has only one validation layer (server actions skip `.input()`), so + one schema is sufficient. Our entry point is tRPC, which insists on + a schema for type inference — putting the canonical schema in the + controller and exporting it for the router was considered. Rejected + because the use case is the contract owner; schemas describe the + *operation*, not the *transport*. + +- **Centralized error-name → code map in `core-shared`.** Considered + using `error.name` discrimination with a small global registry. + Rejected because it violates feature ownership — `core-shared` would + need to know about every feature's error classes. The + `defineErrorMiddleware` factory cleanly inverts the dependency: + `core-shared` provides the plumbing, features pass their own + constructors. + +- **Validate outputs only in tests.** Considered using TypeScript + types alone for output, deferring runtime validation to + contract-suite tests. Rejected because the cost of `.parse()` on + return is trivial and the bug-catching value at runtime is real + (Payload integrations have surprised us before). + +- **Presenters only when reshaping.** Considered Lazar's actual rule + (presenter only when there's a transform). Rejected (R11) because + the discoverable hook for future shaping is worth the trivial + identity-function boilerplate. + +- **Presenters in a separate `presenters/` folder.** Considered as a + concession to "controllers = thin orchestration". Rejected because + Lazar's reference co-locates the presenter with its consumer — the + controller — keeping the contract visible in one file. + +- **Shared `./schemas` subpath.** Considered exposing schemas only via + a dedicated subpath instead of the feature root. Rejected because + schemas ARE feature contracts — they belong with the other contracts + (types, errors). Adding a fourth subpath felt like ceremony. + +## Acceptance verification (Task 8, 2026-05-06) + +- All Plan 9 acceptance criteria from spec §8 met. +- Tests: 360 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. + +## 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) diff --git a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md index 39e7d65..ac15fbe 100644 --- a/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md +++ b/docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md @@ -13,6 +13,48 @@ doc-update items so docs are written once for the post-Plan-9 state. --- +## Summary + +**Completed:** 2026-05-06 +**Branch:** main (or feature/io-unification per execution choice) +**Total commits:** 14 (Tasks 1–9) +**Net test count change:** 325 → 360 (+35 tests, +11%) + +| 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 | `61baaae` | Refactor changelog scaffold | +| 2 | `e25b1f7`, `aac37fd` | core-shared defineErrorMiddleware + t export + jsdoc fix | +| 3 | `2bbec70`, `5b61674`, `70c7b7d` | auth migration + changelog §6.3 + test tightening | +| 4 | `86070b2`, `614c901` | blog migration + style dividers | +| 5 | `f4adf31` | marketing-pages migration | +| R6 | `9663c82`, `aff0ce0` | this.name in all error classes + changelog §7 | +| 6 | `27c79e6` | navigation migration | +| 7 | `2b67964` | media migration | +| 8 | `2df137c` | final verification + app caller stragglers + changelog | +| 9 | (this commit) | ADR-013 + changelog summary | + +### Conformance verification (Task 8) + +Spec acceptance criteria §8: all met. +- Every use case exports xInputSchema; non-void use cases also export xOutputSchema. +- Every non-void controller has a top-level presenter and uses ReturnType. +- Every feature has procedures.ts with feature-scoped error map. +- core-shared/trpc/define-error-middleware.ts is the only plumbing in core-shared; no central name→code registry. +- Per-feature package.json has ./ui subpath; root index.ts no longer exports query builders. +- R25 (output-validation) test exists per non-void use case. +- R26 (router error-mapping) test exists per feature. +- pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries && pnpm build all green. + +--- + ## 1. Files added - packages/core-shared/src/trpc/define-error-middleware.ts — middleware factory mapping [ErrorCtor, TRPC_CODE] tuples to TRPCError translation