# 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/<feature>/`.

> **Prefer the generator.** `pnpm turbo gen feature` produces a
> A 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 default 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).

---

## Workflow ordering

For any new use case, follow these four steps in order:

1. **Manifest entry** — declare the use case in `src/feature.manifest.ts` with its `mutates` flag and (initially empty) `audits` / `publishes` / `consumes` arrays.
2. **Contracts** — export `xInputSchema`, `xOutputSchema`, and the `IXUseCase` type alias from the use-case file. Factory body starts as `throw new Error("not implemented")`.
3. **Tests (red)** — write the failing test that exercises the contract via the factory + a mock repository.
4. **Implementation (green)** — fill the factory body until the tests pass.

The `feature-must-have-manifest` ESLint rule will catch step 1 omissions; `usecase-must-have-test-file` catches step 3. The boot assertion (`assertFeatureConformance` at the tail of `bindProductionX`) catches forgotten wrappers at startup.

For the fast path, run `pnpm turbo gen feature <name>` — the generator emits the manifest + contracts + bind-production with the assertion already wired in.

---

## 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 (`<noun>.repository.ts`) and mock (`<noun>.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 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<typeof commentSchema>;
```

```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<Comment | undefined>;
  getCommentsForArticle(articleId: string): Promise<Comment[]>;
  createComment(input: Comment): Promise<Comment>;
}
```

---

### 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<Comment>(({ 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<typeof xUseCase>`)

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([]);
  });
});

// output validation
describe("getCommentsUseCase output validation", () => {
  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<typeof getCommentsInputSchema>;

// ── Output ───────────────────────────────────────────────────────────────
export const getCommentsOutputSchema = z.array(commentSchema);
export type GetCommentsOutput = z.infer<typeof getCommentsOutputSchema>;

// ── Use case ─────────────────────────────────────────────────────────────
export type IGetCommentsUseCase = ReturnType<typeof getCommentsUseCase>;

export const getCommentsUseCase =
  (commentsRepository: ICommentsRepository) =>
  async (input: GetCommentsInput): Promise<GetCommentsOutput> => {
    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>(
    "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<Comment | undefined> {
    return this._comments.find((c) => c.id === id);
  }

  async getCommentsForArticle(articleId: string): Promise<Comment[]> {
    return this._comments.filter((c) => c.articleId === articleId);
  }

  async createComment(input: Comment): Promise<Comment> {
    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<ReturnType<typeof presenter>>`. 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<typeof getCommentsController>;

export const getCommentsController =
  (getCommentsUseCase: IGetCommentsUseCase) =>
  async (input: unknown): Promise<ReturnType<typeof presenter>> => {
    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<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(
    MockCommentsRepository,
  );

  bind<IGetCommentsUseCase>(
    COMMENTS_SYMBOLS.IGetCommentsUseCase,
  ).toDynamicValue((ctx) =>
    getCommentsUseCase(
      ctx.container.get<ICommentsRepository>(
        COMMENTS_SYMBOLS.ICommentsRepository,
      ),
    ),
  );

  bind<IGetCommentsController>(
    COMMENTS_SYMBOLS.IGetCommentsController,
  ).toDynamicValue((ctx) =>
    getCommentsController(
      ctx.container.get<IGetCommentsUseCase>(
        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 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([]);
  });
});

// error mapping
describe("commentsRouter 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<IGetCommentsController>(
        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<Comment | undefined> {
    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<Comment[]> {
    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<Comment> {
    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<string, Record<string, unknown>>();
    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<string, unknown> }) => {
        const doc = { id: `stub-${store.size + 1}`, ...data };
        store.set(String(doc.id), doc);
        return doc;
      }),
    };
    const { getPayload } = await import("payload");
    (getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
    return new CommentsRepository({} as never);
  });
});
```

---

### Step 16: `bind-production.ts`

The full canonical shape includes instrumentation (ADR-014) and the
event-bus + job-queue parameters (ADR-015) — see any feature's
`bind-production.ts` for a complete reference. Minimal sketch:

```typescript
// src/di/bind-production.ts
import type { SanitizedConfig } from "payload";
import {
  withSpan,
  withCapture,
  type ITracer,
  type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IEventBus } from "@repo/core-events";
import type { IJobQueue } from "@repo/core-shared/jobs";
import { commentsContainer } from "./container";
import { COMMENTS_SYMBOLS } from "./symbols";
import { CommentsRepository } from "../infrastructure/repositories/comments.repository";

export function bindProductionComments(
  config: SanitizedConfig,
  tracer: ITracer,
  logger: ILogger,
  bus: IEventBus,
  queue: IJobQueue,
): void {
  if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
    commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
  }
  const repo = new CommentsRepository(config, tracer, logger);
  commentsContainer
    .bind(COMMENTS_SYMBOLS.ICommentsRepository)
    .toConstantValue(repo);
  // Use cases + controllers are wrapped with withSpan(withCapture(...))
  // at bind time. bus + queue are accept-and-forward until you add an
  // event handler or job — at which point the gen event consume / gen job
  // generators inject usage at the // <gen:event-handlers> / // <gen:jobs>
  // anchors at the end of the function body.
}
```

---

### 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<ReturnType<typeof presenter>>`. 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).

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-feature-conventions.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`).
- **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 test obligations per layer.
