Files
agentic-dev/docs/guides/adding-a-feature.md
Danijel Martinek f77e6ea881 chore(template): clean-slate template snapshot from bb4a0c7
Curated, product-agnostic snapshot of the post-story-04 tree: demo
content deleted, auth-only reference feature, web-next shell, all gates
green. Product-specific docs, ADRs 027-029, PRDs/epics/archive, editor
library traces, and product naming are curated out; generic template
repairs (coverage provider devDeps, root test:coverage script, live
lint fixes, root-only release-please) are kept. See TEMPLATE.md for
provenance, curation list, and usage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
2026-07-12 20:40:54 +02:00

1264 lines
41 KiB
Markdown

# 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` / `reads` 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 |
| `integrations/readers/` | `I<Feature>Reader` interface + implementation (when feature exposes cross-feature queries) |
| `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.