# 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. 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`). > **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). ## Part 1: New Feature Scaffold ### Step 1: Decide shape 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) ### Step 2: Create the package ```bash mkdir -p packages//src/{entities,application/{use-cases,repositories},infrastructure/repositories,di,integrations/{api,cms},ui,interface-adapters/controllers,__factories__,__contracts__} ``` ### Step 3: Create `package.json` ```json { "name": "@repo/", "version": "0.0.1", "private": true, "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" } }, "dependencies": { "@repo/core-shared": "workspace:*", "@repo/core-testing": "workspace:*" }, "devDependencies": { "@repo/core-typescript": "workspace:*" } } ``` ### Step 4: Create `tsconfig.json` ```json { "extends": "@repo/core-typescript/base.json", "compilerOptions": { "rootDir": ".", "outDir": "dist", "lib": ["ES2022", "DOM"], "jsx": "preserve" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ``` ### Step 5: Create `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"], }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, }); ``` ### 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. --- ## Part 2: Build the Layers (Test-First) 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`: ```typescript 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 empty body", () => { expect(() => 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' ``` --- ### Step 2: Implement entity to pass Create `packages/comments/src/entities/comment.ts`: ```typescript 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; ``` 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 ``` --- ### Step 3: Write factory Create `packages/comments/src/__factories__/comment.factory.ts`: ```typescript import { defineFactory } from "@repo/core-testing/factory"; import type { Comment } from "../entities/comment.js"; 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"), })); ``` 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) The use case depends on `ICommentsRepository`. Write the test before the repository exists; use a hand-rolled inline mock for now. Create `packages/comments/src/application/use-cases/create-comment.use-case.test.ts`: ```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"; 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); commentFactory.reset(); }); 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("throws when body is empty", async () => { await expect( createCommentUseCase({ articleId: "a-1", body: "", authorId: "u-1" }), ).rejects.toThrow(); }); }); ``` Run — confirm RED: ```bash pnpm test --filter @repo/comments -- create-comment.use-case.test.ts # Error: Cannot find module './create-comment.use-case' ``` --- ### Step 5: Implement use case to pass Create `packages/comments/src/di/symbols.ts`: ```typescript export const COMMENTS_SYMBOLS = { ICommentsRepository: Symbol("ICommentsRepository"), } as const; ``` Create `packages/comments/src/application/repositories/comments-repository.interface.ts`: ```typescript import type { Comment } from "../../entities/comment.js"; export interface ICommentsRepository { createComment(comment: Comment): Promise; getCommentsForArticle(articleId: string): Promise; } ``` 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(), }; 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 ``` --- ### Step 6: Write contract suite Create `packages/comments/src/__contracts__/comments-repository.contract.ts`: ```typescript import { it, expect, beforeEach } 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"; export const commentsRepositoryContract = defineContractSuite( "ICommentsRepository", ({ buildSubject }) => { let repo: ICommentsRepository; beforeEach(async () => { commentFactory.reset(); repo = await buildSubject(); }); it("createComment returns a comment with the correct fields", 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 () => { const seed = commentFactory.build({ articleId: "a-1" }); await repo.createComment(seed); 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"); expect(results).toHaveLength(0); }); }, ); ``` --- ### Step 7: Implement Mock repo, run contract → green Create `packages/comments/src/infrastructure/repositories/mock-comments.repository.ts`: ```typescript import "reflect-metadata"; import { injectable } from "inversify"; import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface.js"; import type { Comment } from "../../entities/comment.js"; @injectable() export class MockCommentsRepository implements ICommentsRepository { private _comments: Comment[] = []; async createComment(comment: Comment): Promise { this._comments.push(comment); return comment; } async getCommentsForArticle(articleId: string): Promise { return this._comments.filter((c) => c.articleId === articleId); } } ``` Create `packages/comments/src/infrastructure/repositories/mock-comments.repository.test.ts`: ```typescript import { describe } from "vitest"; import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract"; import { MockCommentsRepository } from "./mock-comments.repository"; 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 ``` --- ### Step 8: Implement Payload repo (with vi.mock), run same contract → green Create `packages/comments/src/infrastructure/repositories/payload-comments.repository.ts`: ```typescript 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"; 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 PayloadCommentsRepository implements ICommentsRepository { constructor(private config: SanitizedConfig) {} async createComment(comment: Comment): 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); } 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)); } } ``` Create `packages/comments/src/infrastructure/repositories/payload-comments.repository.test.ts`: ```typescript 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"; vi.mock("payload", () => ({ getPayload: vi.fn() })); describe("PayloadCommentsRepository", () => { commentsRepositoryContract.run(async () => { const store = new Map>(); const stub = { create: vi.fn(async ({ data }: { collection: string; 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); }); }); ``` 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`: ```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"; 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"; } } ``` 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 (