# Adding a New Feature — End-to-End Guide 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//`. > **Prefer the generator.** `pnpm turbo gen feature` produces a > Lazar-conformant single-entity / single-use-case package matching the > `navigation` reference shape (DI, tRPC router with tests, span + capture > sandwich, dev seed, contract suite). See > [Scaffolding a Feature](./scaffolding-a-feature.md). Use this guide when > the generator's Phase-1 scope doesn't fit — multi-entity layouts, custom > shapes, or extending an existing feature — or when you need to understand > what the generator emits and why. **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.** 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). --- ## 1. Overview Every feature package owns: | 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 | 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 ``` --- ## 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/comments", "private": true, "version": "0.0.0", "type": "module", "exports": { ".": "./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:*", "@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-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 3: `tsconfig.json` ```json { "extends": "@repo/core-typescript/base.json", "compilerOptions": { "rootDir": ".", "outDir": "dist", "lib": ["ES2022", "DOM"], "jsx": "preserve" }, "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } ``` ### Step 4: `vitest.config.ts` ```typescript import { defineConfig } from "vitest/config"; import path from "path"; export default defineConfig({ test: { environment: "node", globals: true, include: ["src/**/*.test.ts", "tests/**/*.test.ts"], }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, }); ``` Run `pnpm install` to link the new package into the workspace. --- ### Step 5: Entity model (RED → GREEN) Write the test first: ```typescript // src/entities/models/comment.test.ts import { describe, expect, it } from "vitest"; import { commentSchema } from "./comment"; describe("commentSchema", () => { it("accepts a valid comment", () => { const result = commentSchema.parse({ id: "c-1", articleId: "a-1", body: "Great post", authorId: "u-1", createdAt: new Date(), }); expect(result.body).toBe("Great post"); }); it("rejects an empty body", () => { expect(() => commentSchema.parse({ id: "c-1", articleId: "a-1", body: "", authorId: "u-1", createdAt: new Date() }), ).toThrow(); }); }); ``` ```bash pnpm test --filter @repo/comments -- comment.test.ts # RED — module not found ``` Implement: ```typescript // src/entities/models/comment.ts import { z } from "zod"; export const commentSchema = z.object({ id: z.string(), articleId: z.string(), body: z.string().min(1).max(2000), authorId: z.string(), createdAt: z.date(), }); export type Comment = z.infer; ``` ```bash pnpm test --filter @repo/comments -- comment.test.ts # GREEN ``` --- ### 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/models/comment"; export const commentFactory = defineFactory(({ sequence }) => ({ id: `comment-${sequence}`, articleId: "article-1", body: `Comment body ${sequence}`, authorId: "user-1", createdAt: new Date("2026-01-01T00:00:00Z"), })); ``` --- ### Step 9: Use case — factory function with input/output schemas (RED → GREEN) 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`) The body ends with `xOutputSchema.parse(result)` before returning. Write the test first (direct injection — no container): ```typescript // 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("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("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("exports getCommentsOutputSchema that validates Comment[]", () => { expect(getCommentsOutputSchema.safeParse([]).success).toBe(true); }); }); ``` ```bash pnpm test --filter @repo/comments -- get-comments.use-case.test.ts # RED ``` Implement: ```typescript // 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"; // ── Input ──────────────────────────────────────────────────────────────── export const getCommentsInputSchema = z .object({ articleId: z.string() }) .strict(); export type GetCommentsInput = z.infer; // ── Output ─────────────────────────────────────────────────────────────── export const getCommentsOutputSchema = z.array(commentSchema); export type GetCommentsOutput = z.infer; // ── Use case ───────────────────────────────────────────────────────────── export type IGetCommentsUseCase = ReturnType; export const getCommentsUseCase = (commentsRepository: ICommentsRepository) => async (input: GetCommentsInput): Promise => { const result = await commentsRepository.getCommentsForArticle(input.articleId); return getCommentsOutputSchema.parse(result); }; ``` ```bash pnpm test --filter @repo/comments -- get-comments.use-case.test.ts # GREEN ``` --- ### Step 10: Mock repository + contract suite (RED → GREEN) Define the contract once and run it against both the mock and the real Payload-backed repository: ```typescript // 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"; import { commentFactory } from "../__factories__/comment.factory"; export const commentsRepositoryContract = defineContractSuite( "ICommentsRepository", ({ buildSubject }) => { let repo: ICommentsRepository; beforeEach(async () => { commentFactory.reset(); repo = await buildSubject(); }); 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"); expect(created.body).toBe("Hello"); }); it("getCommentsForArticle returns comments for the given articleId", async () => { 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"); expect(results).toHaveLength(0); }); }, ); ``` 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"; import type { Comment } from "../../entities/models/comment"; @injectable() export class MockCommentsRepository implements ICommentsRepository { _comments: 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; } } ``` 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 "./comments.repository.mock"; describe("MockCommentsRepository", () => { commentsRepositoryContract.run(async () => new MockCommentsRepository()); }); ``` ```bash pnpm test --filter @repo/comments -- comments.repository.mock.test.ts # GREEN ``` --- ### Step 11: Controller — `unknown` input + presenter (RED → GREEN) 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"; import type { Comment } from "../../entities/models/comment"; type PayloadCommentDoc = { id: string | number; articleId?: string | null; body?: string | null; author?: string | number | null; createdAt?: string | null; }; function mapDoc(doc: PayloadCommentDoc): Comment { return { id: String(doc.id), articleId: doc.articleId ?? "", body: doc.body ?? "", authorId: doc.author != null ? String(doc.author) : "", createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0), }; } @injectable() export class CommentsRepository implements ICommentsRepository { constructor(private config: SanitizedConfig) {} async getComment(id: string): Promise { const payload = await getPayload({ config: this.config }); try { const doc = await payload.findByID({ collection: "comments", id, overrideAccess: true }); return mapDoc(doc as PayloadCommentDoc); } catch { return undefined; } } async getCommentsForArticle(articleId: string): Promise { const payload = await getPayload({ config: this.config }); const result = await payload.find({ collection: "comments", where: { articleId: { equals: articleId } } as never, overrideAccess: true, }); 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); } } ``` 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 { CommentsRepository } from "./comments.repository"; vi.mock("payload", () => ({ getPayload: vi.fn() })); describe("CommentsRepository", () => { commentsRepositoryContract.run(async () => { const store = new Map>(); const stub = { 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; }), }; const { getPayload } = await import("payload"); (getPayload as ReturnType).mockResolvedValue(stub); return new CommentsRepository({} as never); }); }); ``` --- ### Step 16: `bind-production.ts` ```typescript // 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"; 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)); } ``` --- ### Step 17: Payload collection ```typescript // src/integrations/cms/collections/comments.collection.ts import type { CollectionConfig } from "payload"; export const comments: CollectionConfig = { slug: "comments", admin: { useAsTitle: "body" }, fields: [ { name: "articleId", type: "text", required: true }, { name: "body", type: "textarea", required: true }, { name: "author", type: "relationship", relationTo: "users", required: true }, ], }; ``` ```typescript // src/integrations/cms/index.ts export { comments } from "./collections/comments.collection"; ``` --- ### Step 18: Public API — `src/index.ts` and `src/ui/index.ts` The feature root exports contracts only (types, errors, schemas, type aliases). UI artifacts live behind `./ui`. ```typescript // src/index.ts export type { Comment } from "./entities/models/comment"; export { CommentNotFoundError } from "./entities/errors/comment"; export { InputParseError } from "./entities/errors/common"; export type { CommentsRouter } from "./integrations/api/router"; // Use case schemas + types export { getCommentsInputSchema, getCommentsOutputSchema, type GetCommentsInput, type GetCommentsOutput, type IGetCommentsUseCase, } from "./application/use-cases/get-comments.use-case"; // Controller type aliases export type { IGetCommentsController } from "./interface-adapters/controllers/get-comments.controller"; ``` ```typescript // src/ui/query.ts type TrpcClient = { comments: { getComments: { queryOptions: (input: { articleId: string }) => unknown; }; }; }; export function getCommentsQuery(client: TrpcClient, articleId: string) { return client.comments.getComments.queryOptions({ articleId }); } ``` ```typescript // src/ui/index.ts export { getCommentsQuery } from "./query"; ``` --- ### Step 19: Wire into `core-api` and `core-cms` These are composition packages — this is the only time a feature-level symbol crosses a package boundary. **`packages/core-api/src/root.ts`** (or equivalent aggregator): ```typescript import { commentsRouter } from "@repo/comments/api"; export const appRouter = t.router({ comments: commentsRouter, // ... other features }); ``` **`packages/core-cms/src/collections/index.ts`** (or equivalent): ```typescript import { comments } from "@repo/comments/cms"; export const collections = [...existingCollections, comments]; ``` Add path aliases to `tsconfig.base.json`: ```json { "compilerOptions": { "paths": { "@repo/comments": ["packages/comments/src/index.ts"], "@repo/comments/api": ["packages/comments/src/integrations/api/index.ts"], "@repo/comments/ui": ["packages/comments/src/ui/index.ts"], "@repo/comments/cms": ["packages/comments/src/integrations/cms/index.ts"], "@repo/comments/di/bind-production": ["packages/comments/src/di/bind-production.ts"] } } } ``` Also add `@repo/comments: workspace:*` to `packages/core-api/package.json` and `packages/core-cms/package.json` dependencies. --- ### Step 20: App bootstrap Each app calls `bindProduction*` per feature at startup to swap mock implementations for real Payload-backed ones. For example, in `apps/web-next/src/server/bind-production.ts`: ```typescript import { bindProductionComments } from "@repo/comments/di/bind-production"; // called once at startup with the resolved Payload config await bindProductionComments(resolvedConfig); ``` --- ### Step 21: Final validation ```bash pnpm install pnpm typecheck --filter @repo/comments pnpm test --filter @repo/comments pnpm lint --filter @repo/comments pnpm turbo boundaries ``` All must pass before shipping. --- ## 4. Configuration Checklist | File | Key items | |---|---| | `package.json` | `"type": "module"`; exports map with `.`, `./ui`, `./api`, `./cms`, `./di/bind-production`; `@repo/core-shared`, `inversify`, `zod`, `payload` in deps | | `tsconfig.json` | `"rootDir": "."` (covers both `src/` and `tests/`); `"outDir": "dist"` | | `vitest.config.ts` | `resolve.alias: { "@": path.resolve(__dirname, "./src") }` | | `eslint.config.js` | extends `@repo/core-eslint`; tag set to `"feature"` in Turborepo `turbo.json` | | `tsconfig.base.json` | path aliases for every subpath export | | `turbo.json` | feature package must appear (or be glob-matched) in the workspace graph | --- ## 5. Common Pitfalls 1. **Forgetting `this.name` in domain error constructors.** Without it, `instanceof` checks in `defineErrorMiddleware` still work, but serialized stack traces label the error as `"Error"` instead of `"CommentNotFoundError"`, making debugging significantly harder. Set `this.name = "CommentNotFoundError"` in every domain error and `InputParseError` constructor (ADR-013 R6). 2. **Redefining the input schema in the controller.** The controller must `import { xInputSchema } from "../../application/use-cases/x.use-case"`. A local `const inputSchema = z.object({...})` in the controller silently diverges from the tRPC procedure's schema — this was the exact bug Plans 8 and 9 fixed. One schema, one source. 3. **Typing controller input as `Partial<...>` or `z.infer<...>`** instead of `unknown`. The controller receives unvalidated data from an external boundary; `unknown` is the correct type. Using a TypeScript type here bypasses the runtime `safeParse` guard entirely when callers are within the same TypeScript project. 4. **Skipping the presenter on a non-void controller.** Every non-void controller must define `function presenter(value: XOutput)` and return `Promise>`. Identity (`return value`) is fine, but the function must exist. Skipping it makes adding a view transform later a structural change instead of a one-line edit (ADR-013 R11). 5. **Adding feature error classes to `core-shared`.** `core-shared` must stay boundary-clean — it provides `defineErrorMiddleware` but knows nothing about `CommentNotFoundError` or any other feature error class. Feature packages pass their own constructors to `defineErrorMiddleware` in their own `procedures.ts`. Adding feature errors to `core-shared` violates the `feature → core` dependency direction enforced by ESLint boundaries and Turborepo. 6. **Re-exporting query builders from the feature root `src/index.ts`.** Query builders import React Query, which is a UI concern. The feature root (`.`) is a contract-only export consumed by apps, other features, and server-side code. UI artifacts go behind `./ui` (ADR-013 §Decision point 4). Apps import from `@repo/comments/ui`, not `@repo/comments`. 7. **Forgetting to add the `./ui` subpath to `package.json`.** If `src/ui/index.ts` exists but the `"./ui"` entry is missing from the `exports` map, TypeScript resolves the import in workspace mode but the build fails. Always keep the exports map in sync with `tsconfig.base.json` path aliases. --- ## 6. Cross-References - **ADR-012** (`docs/decisions/adr-012-lazar-conformance.md`) — factory-function use cases and controllers, entity layout, file naming, one-controller-per-use-case, `.toDynamicValue()` DI bindings, direct injection in tests. - **ADR-013** (`docs/decisions/adr-013-input-output-unification.md`) — use-case file as single source for `xInputSchema` + `xOutputSchema`; presenter pattern; per-feature `procedures.ts` error map; public surface split (`./` vs `./ui`). - **Refactor log — Plan 8** (`docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`) — file-by-file inventory of every rename, split, and pattern change applied to all existing features. - **Refactor log — Plan 9** (`docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`) — inventory of schema additions, presenter additions, `procedures.ts` additions, and `./ui` subpath additions across all 5 features. - **CLAUDE.md** (root) — Key Conventions section is the quick-reference summary; this guide is the authoritative walkthrough. - **Architecture overview** (`docs/architecture/overview.md`) — canonical data-flow diagram showing the full request path from React component through tRPC, controller, use case, repository, and back. - **TDD Workflow** (`docs/guides/tdd-workflow.md`) — required reading on RED → GREEN discipline, direct factory injection, and R25/R26 test obligations.