diff --git a/docs/guides/adding-a-feature.md b/docs/guides/adding-a-feature.md index 2d25f5d..7c67e44 100644 --- a/docs/guides/adding-a-feature.md +++ b/docs/guides/adding-a-feature.md @@ -1,69 +1,164 @@ # Adding a New Feature — End-to-End Guide -A feature is a vertical slice: entities, use cases, repositories, tRPC router, CMS integration, DI container, and UI components — all owned by one package. +A feature is a vertical slice: domain entities, use cases, repositories, +tRPC router, CMS collection, DI container, and query builders — all owned +by one package under `packages//`. -Decide upfront: **Is this a new feature or an extension of an existing one?** New features get a new package (e.g., `packages/comments`). Extensions add to an existing feature (e.g., adding an `unapprove-article` procedure to `packages/blog`). +**New feature or extension?** A new capability gets a new package (e.g., +`packages/comments`). Adding an operation to an existing feature (e.g., +a `publishArticle` procedure to `packages/blog`) means extending that +package — create new files alongside the existing ones, following the same +per-use-case patterns below. -> **TDD Order Required:** You may not advance to the next layer until the -> current layer's tests are red, then green. See [TDD Workflow](./tdd-workflow.md). +> **TDD Order Required.** Write a failing test before each implementation +> file. Advance to the next layer only after the current layer is green. +> See [TDD Workflow](./tdd-workflow.md). -## Part 1: New Feature Scaffold +--- -### Step 1: Decide shape +## 1. Overview -The smallest viable feature has: -- `entities/` — type definitions and schemas (Zod) -- `application/use-cases/` — one business operation -- `application/repositories/` — interface + mock implementation -- `infrastructure/repositories/` — Payload-backed implementation (if needed) -- `di/` — InversifyJS container + symbol table -- `integrations/api/` — tRPC router (optional if no read API) -- `integrations/cms/` — Payload collection/global (if Payload-backed) -- `ui/` — feature-specific components (atoms/molecules/organisms) +Every feature package owns: -### Step 2: Create the package +| Layer | What lives there | +|---|---| +| `entities/models/` | Zod schemas + inferred TypeScript types | +| `entities/errors/` | Domain error classes (`this.name` required); `common.ts` for `InputParseError` | +| `application/repositories/` | Repository interface (no implementation) | +| `application/use-cases/` | One factory per operation; owns `xInputSchema`, `xOutputSchema`, and `xOutputSchema.parse()` | +| `infrastructure/repositories/` | Real (`.repository.ts`) and mock (`.repository.mock.ts`) siblings | +| `interface-adapters/controllers/` | One factory per use case; accepts `unknown`, calls `safeParse`, runs presenter | +| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` | +| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) | +| `integrations/cms/` | Payload collection/global configs | +| `ui/` | Query builders and future React components (behind `./ui` subpath) | +| `__factories__/` | Test data factories | +| `__contracts__/` | Contract suites shared by mock and real repository tests | -```bash -mkdir -p packages//src/{entities,application/{use-cases,repositories},infrastructure/repositories,di,integrations/{api,cms},ui,interface-adapters/controllers,__factories__,__contracts__} +The walkthrough below builds a minimal `comments` feature from scratch. +All concrete code mirrors the `blog` package (the most fully developed +feature) — read `packages/blog/src/` alongside this guide. + +--- + +## 2. Canonical Folder Layout + +``` +packages/comments/ + src/ + entities/ + models/ + comment.ts # Zod schema + Comment type + comment.test.ts + errors/ + comment.ts # CommentNotFoundError (this.name required) + common.ts # InputParseError (this.name required) + errors.test.ts + application/ + repositories/ + comments.repository.interface.ts + use-cases/ + get-comments.use-case.ts # getCommentsInputSchema + getCommentsOutputSchema + parse + get-comments.use-case.test.ts + create-comment.use-case.ts + create-comment.use-case.test.ts + infrastructure/ + repositories/ + comments.repository.mock.ts # MockCommentsRepository + comments.repository.mock.test.ts # runs contract suite + comments.repository.ts # CommentsRepository (Payload-backed) + comments.repository.test.ts # runs contract suite against Payload stub + interface-adapters/ + controllers/ + get-comments.controller.ts # factory + presenter + get-comments.controller.test.ts + create-comment.controller.ts + create-comment.controller.test.ts + di/ + symbols.ts + module.ts + container.ts + container.test.ts + bind-production.ts + integrations/ + api/ + procedures.ts # commentsProcedure with feature error map + router.ts # commentsProcedure.input(xInputSchema) + router.test.ts # includes R26 error-mapping assertions + index.ts + cms/ + collections/ + comments.collection.ts + index.ts + ui/ + query.ts # query builders + index.ts # re-exports query builders + __factories__/ + comment.factory.ts + index.ts + __contracts__/ + comments-repository.contract.ts + index.ts # contracts only: types, errors, schemas, IUseCase/IController aliases + tests/ + comments.feature.test.ts # cross-layer integration (no container) + package.json + tsconfig.json + vitest.config.ts + eslint.config.js ``` -### Step 3: Create `package.json` +--- + +## 3. Step-by-Step Walkthrough + +### Step 1: Create the package scaffold + +```bash +mkdir -p packages/comments/src/{entities/{models,errors},application/{repositories,use-cases},infrastructure/repositories,interface-adapters/controllers,di,integrations/{api,cms/collections},ui,__factories__,__contracts__} +mkdir -p packages/comments/tests +``` + +### Step 2: `package.json` ```json { - "name": "@repo/", - "version": "0.0.1", + "name": "@repo/comments", "private": true, + "version": "0.0.0", "type": "module", "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" - }, - "./api": { - "types": "./dist/integrations/api/index.d.ts", - "import": "./dist/integrations/api/index.js" - }, - "./cms": { - "types": "./dist/integrations/cms/index.d.ts", - "import": "./dist/integrations/cms/index.js" - }, - "./di/bind-production": { - "types": "./dist/di/bind-production.d.ts", - "import": "./dist/di/bind-production.js" - } + ".": "./src/index.ts", + "./ui": "./src/ui/index.ts", + "./cms": "./src/integrations/cms/index.ts", + "./api": "./src/integrations/api/index.ts", + "./di/bind-production": "./src/di/bind-production.ts" + }, + "scripts": { + "build": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit" }, "dependencies": { "@repo/core-shared": "workspace:*", - "@repo/core-testing": "workspace:*" + "@trpc/server": "^11.0.0", + "inversify": "^6.2.0", + "payload": "^3.14.0", + "reflect-metadata": "^0.2.2", + "zod": "^3.24.0" }, "devDependencies": { - "@repo/core-typescript": "workspace:*" + "@repo/core-eslint": "workspace:*", + "@repo/core-testing": "workspace:*", + "@repo/core-typescript": "workspace:*", + "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^3.2.4", + "vitest": "^3.1.0" } } ``` -### Step 4: Create `tsconfig.json` +### Step 3: `tsconfig.json` ```json { @@ -74,12 +169,12 @@ mkdir -p packages//src/{entities,application/{use-cases,repositori "lib": ["ES2022", "DOM"], "jsx": "preserve" }, - "include": ["src/**/*"], + "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } ``` -### Step 5: Create `vitest.config.ts` +### Step 4: `vitest.config.ts` ```typescript import { defineConfig } from "vitest/config"; @@ -89,7 +184,7 @@ export default defineConfig({ test: { environment: "node", globals: true, - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "tests/**/*.test.ts"], }, resolve: { alias: { @@ -99,28 +194,16 @@ export default defineConfig({ }); ``` -### Step 6: Add to root `pnpm-workspace.yaml` (if not already included) - -```yaml -packages: - - "packages/*" -``` - -Run `pnpm install` — the new package is now part of the workspace. +Run `pnpm install` to link the new package into the workspace. --- -## Part 2: Build the Layers (Test-First) +### Step 5: Entity model (RED → GREEN) -The steps below use `comments` as the example feature name. Replace `comments`/`Comment`/`comment` with your feature name throughout. - ---- - -### Step 1: Write failing test for entity schema - -Create `packages/comments/src/entities/comment.test.ts`: +Write the test first: ```typescript +// src/entities/models/comment.test.ts import { describe, expect, it } from "vitest"; import { commentSchema } from "./comment"; @@ -136,34 +219,22 @@ describe("commentSchema", () => { expect(result.body).toBe("Great post"); }); - it("rejects empty body", () => { + it("rejects an empty body", () => { expect(() => - commentSchema.parse({ - id: "c-1", - articleId: "a-1", - body: "", - authorId: "u-1", - createdAt: new Date(), - }), + commentSchema.parse({ id: "c-1", articleId: "a-1", body: "", authorId: "u-1", createdAt: new Date() }), ).toThrow(); }); }); ``` -Run — confirm RED: - ```bash -pnpm test --filter @repo/comments -- comment.test.ts -# Error: Cannot find module './comment' +pnpm test --filter @repo/comments -- comment.test.ts # RED — module not found ``` ---- - -### Step 2: Implement entity to pass - -Create `packages/comments/src/entities/comment.ts`: +Implement: ```typescript +// src/entities/models/comment.ts import { z } from "zod"; export const commentSchema = z.object({ @@ -177,29 +248,83 @@ export const commentSchema = z.object({ export type Comment = z.infer; ``` -Create `packages/comments/src/entities/index.ts`: - -```typescript -export { commentSchema, type Comment } from "./comment.js"; -``` - -Run — confirm GREEN: - ```bash -pnpm test --filter @repo/comments -- comment.test.ts -# PASS ✓ accepts a valid comment -# PASS ✓ rejects empty body +pnpm test --filter @repo/comments -- comment.test.ts # GREEN ``` --- -### Step 3: Write factory - -Create `packages/comments/src/__factories__/comment.factory.ts`: +### Step 6: Domain errors ```typescript +// src/entities/errors/comment.ts +export class CommentNotFoundError extends Error { + constructor(message = "Comment not found", options?: ErrorOptions) { + super(message, options); + this.name = "CommentNotFoundError"; // required — R6 + } +} +``` + +```typescript +// src/entities/errors/common.ts +export class InputParseError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "InputParseError"; // required — R6 + } +} +``` + +Test both (colocated `errors.test.ts`): + +```typescript +// src/entities/errors/errors.test.ts +import { describe, expect, it } from "vitest"; +import { CommentNotFoundError } from "./comment"; +import { InputParseError } from "./common"; + +describe("CommentNotFoundError", () => { + it("has name CommentNotFoundError", () => { + const e = new CommentNotFoundError(); + expect(e.name).toBe("CommentNotFoundError"); + expect(e).toBeInstanceOf(Error); + }); +}); + +describe("InputParseError", () => { + it("has name InputParseError", () => { + const e = new InputParseError("bad"); + expect(e.name).toBe("InputParseError"); + }); +}); +``` + +--- + +### Step 7: Repository interface + +Interfaces have no implementation and no test. They are contracts. + +```typescript +// src/application/repositories/comments.repository.interface.ts +import type { Comment } from "../../entities/models/comment"; + +export interface ICommentsRepository { + getComment(id: string): Promise; + getCommentsForArticle(articleId: string): Promise; + createComment(input: Comment): Promise; +} +``` + +--- + +### Step 8: Test factory + +```typescript +// src/__factories__/comment.factory.ts import { defineFactory } from "@repo/core-testing/factory"; -import type { Comment } from "../entities/comment.js"; +import type { Comment } from "../entities/models/comment"; export const commentFactory = defineFactory(({ sequence }) => ({ id: `comment-${sequence}`, @@ -210,141 +335,117 @@ export const commentFactory = defineFactory(({ sequence }) => ({ })); ``` -Create `packages/comments/src/__factories__/index.ts`: - -```typescript -export { commentFactory } from "./comment.factory.js"; -``` - --- -### Step 4: Write failing test for use case (using factory + mock repo) +### Step 9: Use case — factory function with input/output schemas (RED → GREEN) -The use case depends on `ICommentsRepository`. Write the test before the repository exists; use a hand-rolled inline mock for now. +Every use case exports: +- `xInputSchema` — a `z.ZodObject` with `.strict()` (use `z.object({}).strict()` for void inputs) +- `xOutputSchema` — for non-void use cases +- `XInput` / `XOutput` types +- `IXUseCase` alias (`ReturnType`) -Create `packages/comments/src/application/use-cases/create-comment.use-case.test.ts`: +The body ends with `xOutputSchema.parse(result)` before returning. + +Write the test first (direct injection — no container): ```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { commentFactory } from "../../__factories__/comment.factory"; -import { createCommentUseCase } from "./create-comment.use-case"; -import { commentsContainer } from "../../di/container"; -import { COMMENTS_SYMBOLS } from "../../di/symbols"; -import type { ICommentsRepository } from "../repositories/comments-repository.interface"; -import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository"; +// src/application/use-cases/get-comments.use-case.test.ts +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; +import { + getCommentsUseCase, + getCommentsOutputSchema, +} from "@/application/use-cases/get-comments.use-case"; +import { MockCommentsRepository } from "@/infrastructure/repositories/comments.repository.mock"; +import { commentFactory } from "@/__factories__/comment.factory"; -describe("createCommentUseCase", () => { - let repo: MockCommentsRepository; - - beforeEach(() => { - if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) { - commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository); - } - repo = new MockCommentsRepository(); - commentsContainer - .bind(COMMENTS_SYMBOLS.ICommentsRepository) - .toConstantValue(repo); +describe("getCommentsUseCase", () => { + it("returns comments for an article", async () => { + const repo = new MockCommentsRepository(); commentFactory.reset(); + await repo.createComment(commentFactory.build({ articleId: "a-1" })); + + const useCase = getCommentsUseCase(repo); + const result = await useCase({ articleId: "a-1" }); + expect(result).toHaveLength(1); + expect(result[0]?.articleId).toBe("a-1"); }); - it("creates a comment with the correct fields", async () => { - const result = await createCommentUseCase({ - articleId: "a-1", - body: "Great post", - authorId: "u-1", - }); - expect(result.body).toBe("Great post"); - expect(result.articleId).toBe("a-1"); - expect(result.authorId).toBe("u-1"); - expect(typeof result.id).toBe("string"); + it("returns empty array when no comments exist", async () => { + const repo = new MockCommentsRepository(); + const useCase = getCommentsUseCase(repo); + const result = await useCase({ articleId: "missing" }); + expect(result).toEqual([]); + }); +}); + +// R25 — output validation +describe("getCommentsUseCase output validation (R25)", () => { + it("throws ZodError when the repository returns malformed data", async () => { + const repo = new MockCommentsRepository(); + (repo as unknown as { _comments: unknown[] })._comments.push({ id: 123 }); + + const useCase = getCommentsUseCase(repo); + await expect(useCase({ articleId: "a-1" })).rejects.toBeInstanceOf(ZodError); }); - it("throws when body is empty", async () => { - await expect( - createCommentUseCase({ articleId: "a-1", body: "", authorId: "u-1" }), - ).rejects.toThrow(); + it("exports getCommentsOutputSchema that validates Comment[]", () => { + expect(getCommentsOutputSchema.safeParse([]).success).toBe(true); }); }); ``` -Run — confirm RED: - ```bash -pnpm test --filter @repo/comments -- create-comment.use-case.test.ts -# Error: Cannot find module './create-comment.use-case' +pnpm test --filter @repo/comments -- get-comments.use-case.test.ts # RED ``` ---- - -### Step 5: Implement use case to pass - -Create `packages/comments/src/di/symbols.ts`: +Implement: ```typescript -export const COMMENTS_SYMBOLS = { - ICommentsRepository: Symbol("ICommentsRepository"), -} as const; -``` +// src/application/use-cases/get-comments.use-case.ts +import { z } from "zod"; +import { commentSchema } from "../../entities/models/comment"; +import type { ICommentsRepository } from "../repositories/comments.repository.interface"; -Create `packages/comments/src/application/repositories/comments-repository.interface.ts`: +// ── Input ──────────────────────────────────────────────────────────────── +export const getCommentsInputSchema = z + .object({ articleId: z.string() }) + .strict(); +export type GetCommentsInput = z.infer; -```typescript -import type { Comment } from "../../entities/comment.js"; +// ── Output ─────────────────────────────────────────────────────────────── +export const getCommentsOutputSchema = z.array(commentSchema); +export type GetCommentsOutput = z.infer; -export interface ICommentsRepository { - createComment(comment: Comment): Promise; - getCommentsForArticle(articleId: string): Promise; -} -``` +// ── Use case ───────────────────────────────────────────────────────────── +export type IGetCommentsUseCase = ReturnType; -Create `packages/comments/src/application/use-cases/create-comment.use-case.ts`: - -```typescript -import type { Comment } from "../../entities/comment.js"; -import { commentSchema } from "../../entities/comment.js"; -import { commentsContainer } from "../../di/container.js"; -import { COMMENTS_SYMBOLS } from "../../di/symbols.js"; -import type { ICommentsRepository } from "../repositories/comments-repository.interface.js"; - -export async function createCommentUseCase(input: { - articleId: string; - body: string; - authorId: string; -}): Promise { - const repo = commentsContainer.get( - COMMENTS_SYMBOLS.ICommentsRepository, - ); - const comment: Comment = { - id: crypto.randomUUID(), - articleId: input.articleId, - body: input.body, - authorId: input.authorId, - createdAt: new Date(), +export const getCommentsUseCase = + (commentsRepository: ICommentsRepository) => + async (input: GetCommentsInput): Promise => { + const result = await commentsRepository.getCommentsForArticle(input.articleId); + return getCommentsOutputSchema.parse(result); }; - commentSchema.parse(comment); // throws on invalid body - return repo.createComment(comment); -} ``` -Run — confirm GREEN: - ```bash -pnpm test --filter @repo/comments -- create-comment.use-case.test.ts -# PASS ✓ creates a comment with the correct fields -# PASS ✓ throws when body is empty +pnpm test --filter @repo/comments -- get-comments.use-case.test.ts # GREEN ``` --- -### Step 6: Write contract suite +### Step 10: Mock repository + contract suite (RED → GREEN) -Create `packages/comments/src/__contracts__/comments-repository.contract.ts`: +Define the contract once and run it against both the mock and the real +Payload-backed repository: ```typescript -import { it, expect, beforeEach } from "vitest"; +// src/__contracts__/comments-repository.contract.ts +import { beforeEach, expect, it } from "vitest"; import { defineContractSuite } from "@repo/core-testing/contract"; -import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js"; -import { commentFactory } from "../__factories__/comment.factory.js"; +import type { ICommentsRepository } from "../application/repositories/comments.repository.interface"; +import { commentFactory } from "../__factories__/comment.factory"; export const commentsRepositoryContract = defineContractSuite( @@ -357,7 +458,7 @@ export const commentsRepositoryContract = repo = await buildSubject(); }); - it("createComment returns a comment with the correct fields", async () => { + it("createComment returns the created comment", async () => { const seed = commentFactory.build({ body: "Hello" }); const created = await repo.createComment(seed); expect(typeof created.id).toBe("string"); @@ -365,83 +466,361 @@ export const commentsRepositoryContract = }); it("getCommentsForArticle returns comments for the given articleId", async () => { - const seed = commentFactory.build({ articleId: "a-1" }); - await repo.createComment(seed); + await repo.createComment(commentFactory.build({ articleId: "a-1" })); const results = await repo.getCommentsForArticle("a-1"); expect(results).toHaveLength(1); expect(results[0]?.articleId).toBe("a-1"); }); it("getCommentsForArticle returns empty array for unknown articleId", async () => { - const results = await repo.getCommentsForArticle("no-such-article"); + const results = await repo.getCommentsForArticle("no-such"); expect(results).toHaveLength(0); }); }, ); ``` ---- - -### Step 7: Implement Mock repo, run contract → green - -Create `packages/comments/src/infrastructure/repositories/mock-comments.repository.ts`: +Implement the mock: ```typescript +// src/infrastructure/repositories/comments.repository.mock.ts import "reflect-metadata"; import { injectable } from "inversify"; -import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface.js"; -import type { Comment } from "../../entities/comment.js"; +import type { ICommentsRepository } from "../../application/repositories/comments.repository.interface"; +import type { Comment } from "../../entities/models/comment"; @injectable() export class MockCommentsRepository implements ICommentsRepository { - private _comments: Comment[] = []; + _comments: Comment[] = []; - async createComment(comment: Comment): Promise { - this._comments.push(comment); - return comment; + async getComment(id: string): Promise { + return this._comments.find((c) => c.id === id); } async getCommentsForArticle(articleId: string): Promise { return this._comments.filter((c) => c.articleId === articleId); } + + async createComment(input: Comment): Promise { + this._comments.push(input); + return input; + } } ``` -Create `packages/comments/src/infrastructure/repositories/mock-comments.repository.test.ts`: +Run the contract against the mock: ```typescript +// src/infrastructure/repositories/comments.repository.mock.test.ts import { describe } from "vitest"; import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract"; -import { MockCommentsRepository } from "./mock-comments.repository"; +import { MockCommentsRepository } from "./comments.repository.mock"; describe("MockCommentsRepository", () => { commentsRepositoryContract.run(async () => new MockCommentsRepository()); }); ``` -Run — confirm GREEN: - ```bash -pnpm test --filter @repo/comments -- mock-comments.repository.test.ts -# PASS Contract: ICommentsRepository -# ✓ createComment returns a comment with the correct fields -# ✓ getCommentsForArticle returns comments for the given articleId -# ✓ getCommentsForArticle returns empty array for unknown articleId +pnpm test --filter @repo/comments -- comments.repository.mock.test.ts # GREEN ``` --- -### Step 8: Implement Payload repo (with vi.mock), run same contract → green +### Step 11: Controller — `unknown` input + presenter (RED → GREEN) -Create `packages/comments/src/infrastructure/repositories/payload-comments.repository.ts`: +Controllers import `xInputSchema` from the use-case file — never redefine it. +Every non-void controller defines a top-level `function presenter` and returns +`Promise>`. Identity is fine. ```typescript +// src/interface-adapters/controllers/get-comments.controller.test.ts +import { describe, expect, it } from "vitest"; +import { getCommentsController } from "@/interface-adapters/controllers/get-comments.controller"; +import { getCommentsUseCase } from "@/application/use-cases/get-comments.use-case"; +import { MockCommentsRepository } from "@/infrastructure/repositories/comments.repository.mock"; +import { InputParseError } from "@/entities/errors/common"; +import { commentFactory } from "@/__factories__/comment.factory"; + +describe("getCommentsController", () => { + it("returns comments on valid input", async () => { + const repo = new MockCommentsRepository(); + commentFactory.reset(); + await repo.createComment(commentFactory.build({ articleId: "a-1" })); + + const ctrl = getCommentsController(getCommentsUseCase(repo)); + const result = await ctrl({ articleId: "a-1" }); + expect(result).toHaveLength(1); + }); + + it("throws InputParseError when articleId is missing", async () => { + const repo = new MockCommentsRepository(); + const ctrl = getCommentsController(getCommentsUseCase(repo)); + await expect(ctrl({})).rejects.toBeInstanceOf(InputParseError); + }); + + it("throws InputParseError on unknown extra fields (strict)", async () => { + const repo = new MockCommentsRepository(); + const ctrl = getCommentsController(getCommentsUseCase(repo)); + await expect(ctrl({ articleId: "a-1", extra: true })).rejects.toBeInstanceOf(InputParseError); + }); +}); +``` + +```bash +pnpm test --filter @repo/comments -- get-comments.controller.test.ts # RED +``` + +Implement: + +```typescript +// src/interface-adapters/controllers/get-comments.controller.ts +import { InputParseError } from "../../entities/errors/common"; +import { + getCommentsInputSchema, + type GetCommentsOutput, + type IGetCommentsUseCase, +} from "../../application/use-cases/get-comments.use-case"; + +function presenter(value: GetCommentsOutput) { + return value; +} + +export type IGetCommentsController = ReturnType; + +export const getCommentsController = + (getCommentsUseCase: IGetCommentsUseCase) => + async (input: unknown): Promise> => { + const parsed = getCommentsInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-comments input", { cause: parsed.error }); + } + const result = await getCommentsUseCase(parsed.data); + return presenter(result); + }; +``` + +```bash +pnpm test --filter @repo/comments -- get-comments.controller.test.ts # GREEN +``` + +--- + +### Step 12: DI — symbols, module, container + +```typescript +// src/di/symbols.ts +export const COMMENTS_SYMBOLS = { + ICommentsRepository: Symbol.for("comments:ICommentsRepository"), + // Use cases + IGetCommentsUseCase: Symbol.for("comments:IGetCommentsUseCase"), + // Controllers + IGetCommentsController: Symbol.for("comments:IGetCommentsController"), +} as const; +``` + +```typescript +// src/di/module.ts +import { ContainerModule, type interfaces } from "inversify"; +import type { ICommentsRepository } from "../application/repositories/comments.repository.interface"; +import { MockCommentsRepository } from "../infrastructure/repositories/comments.repository.mock"; +import { + getCommentsUseCase, + type IGetCommentsUseCase, +} from "../application/use-cases/get-comments.use-case"; +import { + getCommentsController, + type IGetCommentsController, +} from "../interface-adapters/controllers/get-comments.controller"; +import { COMMENTS_SYMBOLS } from "./symbols"; + +export const CommentsModule = new ContainerModule((bind: interfaces.Bind) => { + bind(COMMENTS_SYMBOLS.ICommentsRepository).to(MockCommentsRepository); + + bind(COMMENTS_SYMBOLS.IGetCommentsUseCase).toDynamicValue((ctx) => + getCommentsUseCase( + ctx.container.get(COMMENTS_SYMBOLS.ICommentsRepository), + ), + ); + + bind(COMMENTS_SYMBOLS.IGetCommentsController).toDynamicValue((ctx) => + getCommentsController( + ctx.container.get(COMMENTS_SYMBOLS.IGetCommentsUseCase), + ), + ); +}); +``` + +```typescript +// src/di/container.ts +import "reflect-metadata"; +import { Container } from "inversify"; +import { CommentsModule } from "./module"; + +export const commentsContainer = new Container({ defaultScope: "Singleton" }); +commentsContainer.load(CommentsModule); +``` + +Verify DI wiring: + +```typescript +// src/di/container.test.ts +import { describe, it, expect } from "vitest"; +import { commentsContainer } from "@/di/container"; +import { COMMENTS_SYMBOLS } from "@/di/symbols"; + +describe("commentsContainer", () => { + it("resolves ICommentsRepository", () => { + expect(commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository)).toBeDefined(); + }); + + it("resolves IGetCommentsUseCase", () => { + expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase)).toBeDefined(); + }); + + it("resolves IGetCommentsController", () => { + expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController)).toBeDefined(); + }); +}); +``` + +--- + +### Step 13: `procedures.ts` — feature-scoped error map + +Each feature has exactly one `procedures.ts`. It exports an `xProcedure` +that wraps `defineErrorMiddleware` with the feature's own error constructors. +`core-shared` provides the factory but knows nothing about feature errors. + +```typescript +// src/integrations/api/procedures.ts +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; +import { CommentNotFoundError } from "../../entities/errors/comment"; +import { InputParseError } from "../../entities/errors/common"; + +export const commentsProcedure = t.procedure.use( + defineErrorMiddleware([ + [InputParseError, "BAD_REQUEST"], + [CommentNotFoundError, "NOT_FOUND"], + ]), +); +``` + +--- + +### Step 14: tRPC router (RED → GREEN, includes R26 error-mapping test) + +The router: +- uses `commentsProcedure` (never bare `publicProcedure`) +- calls `.input(xInputSchema)` importing from the use-case file — never redefines the schema inline +- resolves controllers from the container + +```typescript +// src/integrations/api/router.test.ts +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { TRPCError } from "@trpc/server"; +import { commentsContainer } from "@/di/container"; +import { CommentsModule } from "@/di/module"; +import { commentsRouter } from "@/integrations/api/router"; + +describe("commentsRouter", () => { + beforeEach(() => { + commentsContainer.unbindAll(); + commentsContainer.load(CommentsModule); + }); + afterEach(() => { + commentsContainer.unbindAll(); + }); + + it("exposes getComments procedure", () => { + expect(Object.keys(commentsRouter._def.procedures)).toContain("getComments"); + }); + + it("getComments returns empty array by default", async () => { + const caller = commentsRouter.createCaller({}); + expect(await caller.getComments({ articleId: "a-1" })).toEqual([]); + }); +}); + +// R26 — error mapping +describe("commentsRouter (R26 error mapping)", () => { + beforeEach(() => { + commentsContainer.unbindAll(); + commentsContainer.load(CommentsModule); + }); + afterEach(() => { + commentsContainer.unbindAll(); + }); + + it("translates zod parse failure → BAD_REQUEST", async () => { + const caller = commentsRouter.createCaller({}); + try { + await caller.getComments({} as unknown as { articleId: string }); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(TRPCError); + expect((e as TRPCError).code).toBe("BAD_REQUEST"); + } + }); +}); +``` + +```bash +pnpm test --filter @repo/comments -- router.test.ts # RED +``` + +Implement: + +```typescript +// src/integrations/api/router.ts +import { router } from "@repo/core-shared/trpc/init"; +import { commentsContainer } from "../../di/container"; +import { COMMENTS_SYMBOLS } from "../../di/symbols"; +import { getCommentsInputSchema } from "../../application/use-cases/get-comments.use-case"; +import type { IGetCommentsController } from "../../interface-adapters/controllers/get-comments.controller"; +import { commentsProcedure } from "./procedures"; + +export const commentsRouter = router({ + getComments: commentsProcedure + .input(getCommentsInputSchema) + .query(({ input }) => { + const ctrl = commentsContainer.get( + COMMENTS_SYMBOLS.IGetCommentsController, + ); + return ctrl(input); + }), +}); + +export type CommentsRouter = typeof commentsRouter; +``` + +```typescript +// src/integrations/api/index.ts +export { commentsRouter } from "./router"; +export type { CommentsRouter } from "./router"; +``` + +```bash +pnpm test --filter @repo/comments -- router.test.ts # GREEN +``` + +--- + +### Step 15: Real Payload-backed repository + +Implement `CommentsRepository` and run the same contract suite against it +via a Payload stub (`vi.mock("payload", ...)`). Mirror the pattern from +`packages/blog/src/infrastructure/repositories/articles.repository.ts`. + +```typescript +// src/infrastructure/repositories/comments.repository.ts import "reflect-metadata"; import { injectable } from "inversify"; import { getPayload } from "payload"; import type { SanitizedConfig } from "payload"; -import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface.js"; -import type { Comment } from "../../entities/comment.js"; +import type { ICommentsRepository } from "../../application/repositories/comments.repository.interface"; +import type { Comment } from "../../entities/models/comment"; type PayloadCommentDoc = { id: string | number; @@ -462,21 +841,17 @@ function mapDoc(doc: PayloadCommentDoc): Comment { } @injectable() -export class PayloadCommentsRepository implements ICommentsRepository { +export class CommentsRepository implements ICommentsRepository { constructor(private config: SanitizedConfig) {} - async createComment(comment: Comment): Promise { + async getComment(id: string): Promise { const payload = await getPayload({ config: this.config }); - const created = await payload.create({ - collection: "comments", - data: { - articleId: comment.articleId, - body: comment.body, - author: comment.authorId, - } as never, - overrideAccess: true, - }); - return mapDoc(created as PayloadCommentDoc); + try { + const doc = await payload.findByID({ collection: "comments", id, overrideAccess: true }); + return mapDoc(doc as PayloadCommentDoc); + } catch { + return undefined; + } } async getCommentsForArticle(articleId: string): Promise { @@ -488,344 +863,83 @@ export class PayloadCommentsRepository implements ICommentsRepository { }); return result.docs.map((d) => mapDoc(d as PayloadCommentDoc)); } + + async createComment(input: Comment): Promise { + const payload = await getPayload({ config: this.config }); + const created = await payload.create({ + collection: "comments", + data: { articleId: input.articleId, body: input.body, author: input.authorId } as never, + overrideAccess: true, + }); + return mapDoc(created as PayloadCommentDoc); + } } ``` -Create `packages/comments/src/infrastructure/repositories/payload-comments.repository.test.ts`: +Contract test with a Payload stub (see +`packages/blog/src/infrastructure/repositories/articles.repository.test.ts` +for the full `vi.mock("payload", ...)` pattern): ```typescript +// src/infrastructure/repositories/comments.repository.test.ts import { describe, vi } from "vitest"; import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract"; -import { PayloadCommentsRepository } from "./payload-comments.repository"; -import { stubPayloadConfig } from "@repo/core-testing/payload"; +import { CommentsRepository } from "./comments.repository"; vi.mock("payload", () => ({ getPayload: vi.fn() })); -describe("PayloadCommentsRepository", () => { +describe("CommentsRepository", () => { commentsRepositoryContract.run(async () => { const store = new Map>(); const stub = { - create: vi.fn(async ({ data }: { collection: string; data: Record }) => { + findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)), + find: vi.fn(async ({ where }: { where?: { articleId?: { equals: string } } }) => { + let docs = Array.from(store.values()); + if (where?.articleId) { + docs = docs.filter((d) => d["articleId"] === where.articleId?.equals); + } + return { docs }; + }), + create: vi.fn(async ({ data }: { data: Record }) => { const doc = { id: `stub-${store.size + 1}`, ...data }; store.set(String(doc.id), doc); return doc; }), - find: vi.fn(async ({ where }: { collection: string; where?: { articleId?: { equals: string } } }) => { - let docs = Array.from(store.values()); - if (where?.articleId) { - docs = docs.filter((d) => d.articleId === where.articleId?.equals); - } - return { docs }; - }), }; const { getPayload } = await import("payload"); (getPayload as ReturnType).mockResolvedValue(stub); - return new PayloadCommentsRepository(stubPayloadConfig); + return new CommentsRepository({} as never); }); }); ``` -Run — confirm GREEN: - -```bash -pnpm test --filter @repo/comments -- payload-comments.repository.test.ts -# PASS PayloadCommentsRepository > Contract: ICommentsRepository -# ✓ createComment returns a comment with the correct fields -# ✓ getCommentsForArticle returns comments for the given articleId -# ✓ getCommentsForArticle returns empty array for unknown articleId -``` - --- -### Step 9: Write failing controller test - -Create `packages/comments/src/interface-adapters/controllers/comments.controller.test.ts`: +### Step 16: `bind-production.ts` ```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { commentsContainer } from "../../di/container"; -import { COMMENTS_SYMBOLS } from "../../di/symbols"; -import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository"; -import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface"; -import { InputParseError } from "../../entities/errors"; -import { createCommentController } from "./comments.controller"; +// src/di/bind-production.ts +import type { SanitizedConfig } from "payload"; +import { commentsContainer } from "./container"; +import { COMMENTS_SYMBOLS } from "./symbols"; +import { CommentsRepository } from "../infrastructure/repositories/comments.repository"; -describe("comments controller", () => { - beforeEach(() => { - if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) { - commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository); - } - commentsContainer - .bind(COMMENTS_SYMBOLS.ICommentsRepository) - .toConstantValue(new MockCommentsRepository()); - }); - - describe("createCommentController", () => { - it("creates a comment on valid input", async () => { - const result = await createCommentController({ - articleId: "a-1", - body: "Nice article", - authorId: "u-1", - }); - expect(result.body).toBe("Nice article"); - }); - - it("throws InputParseError when body is missing", async () => { - await expect( - createCommentController({ articleId: "a-1", authorId: "u-1" }), - ).rejects.toBeInstanceOf(InputParseError); - }); - }); -}); -``` - -Run — confirm RED: - -```bash -pnpm test --filter @repo/comments -- comments.controller.test.ts -# Error: Cannot find module './comments.controller' -``` - ---- - -### Step 10: Implement controller - -Create `packages/comments/src/entities/errors.ts`: - -```typescript -export class InputParseError extends Error { - constructor(message: string, options?: { cause?: unknown }) { - super(message, options); - this.name = "InputParseError"; +export function bindProductionComments(config: SanitizedConfig): void { + if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) { + commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository); } + commentsContainer + .bind(COMMENTS_SYMBOLS.ICommentsRepository) + .toConstantValue(new CommentsRepository(config)); } ``` -Create `packages/comments/src/interface-adapters/controllers/comments.controller.ts`: - -```typescript -import { z } from "zod"; -import { InputParseError } from "../../entities/errors.js"; -import { createCommentUseCase } from "../../application/use-cases/create-comment.use-case.js"; -import type { Comment } from "../../entities/comment.js"; - -const createInputSchema = z.object({ - articleId: z.string().min(1), - body: z.string().min(1), - authorId: z.string().min(1), -}); - -export async function createCommentController( - input: Partial>, -): Promise { - const parsed = createInputSchema.safeParse(input); - if (!parsed.success) { - throw new InputParseError("Invalid create-comment input", { - cause: parsed.error, - }); - } - return createCommentUseCase(parsed.data); -} -``` - -Run — confirm GREEN: - -```bash -pnpm test --filter @repo/comments -- comments.controller.test.ts -# PASS ✓ creates a comment on valid input -# PASS ✓ throws InputParseError when body is missing -``` - --- -### Step 11: Write failing tRPC integration test - -Create `packages/comments/src/integrations/api/router.test.ts`: - -```typescript -import { beforeEach, describe, expect, it } from "vitest"; -import { commentsContainer } from "../../di/container"; -import { COMMENTS_SYMBOLS } from "../../di/symbols"; -import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository"; -import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface"; -import { commentsRouter } from "./router"; - -describe("commentsRouter", () => { - beforeEach(() => { - if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) { - commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository); - } - commentsContainer - .bind(COMMENTS_SYMBOLS.ICommentsRepository) - .toConstantValue(new MockCommentsRepository()); - }); - - it("exposes createComment procedure", () => { - const procedureNames = Object.keys(commentsRouter._def.procedures); - expect(procedureNames).toContain("createComment"); - }); - - it("createComment creates and returns the comment", async () => { - const caller = commentsRouter.createCaller({}); - const result = await caller.createComment({ - articleId: "a-1", - body: "Hello", - authorId: "u-1", - }); - expect(result.body).toBe("Hello"); - }); -}); -``` - -Run — confirm RED: - -```bash -pnpm test --filter @repo/comments -- router.test.ts -# Error: Cannot find module './router' -``` - ---- - -### Step 12: Wire router - -Create `packages/comments/src/di/container.ts`: - -```typescript -import "reflect-metadata"; -import { Container } from "inversify"; -import { MockCommentsRepository } from "../infrastructure/repositories/mock-comments.repository.js"; -import { COMMENTS_SYMBOLS } from "./symbols.js"; -import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js"; - -export const commentsContainer = new Container({ defaultScope: "Singleton" }); -commentsContainer - .bind(COMMENTS_SYMBOLS.ICommentsRepository) - .to(MockCommentsRepository); -``` - -Create `packages/comments/src/integrations/api/router.ts`: - -```typescript -import { z } from "zod"; -import { t } from "@repo/core-shared/trpc/init"; -import { createCommentController } from "../../interface-adapters/controllers/comments.controller.js"; - -export const commentsRouter = t.router({ - createComment: t.procedure - .input( - z.object({ - articleId: z.string().min(1), - body: z.string().min(1), - authorId: z.string().min(1), - }), - ) - .mutation(async ({ input }) => { - return createCommentController(input); - }), -}); -``` - -Create `packages/comments/src/integrations/api/index.ts`: - -```typescript -export { commentsRouter } from "./router.js"; -``` - -Run — confirm GREEN: - -```bash -pnpm test --filter @repo/comments -- router.test.ts -# PASS ✓ exposes createComment procedure -# PASS ✓ createComment creates and returns the comment -``` - ---- - -### Step 13: (UI optional) Write failing component test with renderWithProviders - -Create `packages/comments/src/ui/CommentForm.test.tsx`: - -```typescript -import { describe, expect, it } from "vitest"; -import { renderWithProviders } from "@repo/core-testing/react"; -import { CommentForm } from "./CommentForm"; - -describe("CommentForm", () => { - it("renders a textarea and submit button", () => { - const screen = renderWithProviders( - {}} />, - ); - expect(screen.getByRole("textbox")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /post comment/i })).toBeInTheDocument(); - }); -}); -``` - -Run — confirm RED: - -```bash -pnpm test --filter @repo/comments -- CommentForm.test.tsx -# Error: Cannot find module './CommentForm' -``` - ---- - -### Step 14: Implement component - -Create `packages/comments/src/ui/CommentForm.tsx`: - -```typescript -import { useState } from "react"; - -interface CommentFormProps { - articleId: string; - authorId: string; - onSuccess: () => void; -} - -export function CommentForm({ articleId, authorId, onSuccess }: CommentFormProps) { - const [body, setBody] = useState(""); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - // Wire to tRPC mutation in a real implementation - onSuccess(); - } - - return ( -
-