Files
agentic-dev/docs/guides/adding-a-feature.md
Danijel Martinek 17d3488fb4 docs(guides): add TDD workflow + restructure adding-a-feature for TDD order
New: docs/guides/tdd-workflow.md — red-green-refactor cycle, AAA,
mocking decision tree, coverage targets, factory + contract usage.
Restructured: adding-a-feature.md interleaves tests with implementation;
TDD order is required, not optional. testing-strategy.md cross-links
the new guide. AGENTS.md and CLAUDE.md surface both.

Spec: §7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:28:00 +02:00

1039 lines
30 KiB
Markdown

# 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/<feature-name>/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/<feature-name>",
"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<typeof commentSchema>;
```
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<Comment>(({ 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<ICommentsRepository>(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<Comment>;
getCommentsForArticle(articleId: string): Promise<Comment[]>;
}
```
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<Comment> {
const repo = commentsContainer.get<ICommentsRepository>(
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>(
"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<Comment> {
this._comments.push(comment);
return comment;
}
async getCommentsForArticle(articleId: string): Promise<Comment[]> {
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<Comment> {
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<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));
}
}
```
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<string, Record<string, unknown>>();
const stub = {
create: vi.fn(async ({ data }: { collection: string; data: Record<string, unknown> }) => {
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<typeof vi.fn>).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<ICommentsRepository>(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<z.infer<typeof createInputSchema>>,
): Promise<Comment> {
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<ICommentsRepository>(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<ICommentsRepository>(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(
<CommentForm articleId="a-1" authorId="u-1" onSuccess={() => {}} />,
);
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 (
<form onSubmit={handleSubmit}>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write a comment..."
/>
<button type="submit">Post comment</button>
</form>
);
}
```
Run — confirm GREEN:
```bash
pnpm test --filter @repo/comments -- CommentForm.test.tsx
# PASS ✓ renders a textarea and submit button
```
---
### Step 15: Wire into core-api / core-cms, run typecheck + lint + boundaries
Create `packages/comments/src/index.ts`:
```typescript
export { commentSchema, type Comment } from "./entities/index.js";
export { commentsContainer } from "./di/container.js";
```
Create `packages/comments/src/integrations/cms/collections/comments.collection.ts`:
```typescript
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 },
],
};
```
Create `packages/comments/src/integrations/cms/index.ts`:
```typescript
export { comments } from "./collections/comments.collection.js";
```
Create `packages/comments/src/di/bind-production.ts`:
```typescript
import type { Container } from "inversify";
import type { SanitizedConfig } from "payload";
import { PayloadCommentsRepository } from "../infrastructure/repositories/payload-comments.repository.js";
import { COMMENTS_SYMBOLS } from "./symbols.js";
import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js";
export async function bindProductionComments(
container: Container,
config: SanitizedConfig,
): Promise<void> {
const repo = new PayloadCommentsRepository(config);
container.rebind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).toConstantValue(repo);
}
```
**Wire into `core-api`** — edit `packages/core-api/src/routers.ts`:
```typescript
import { commentsRouter } from "@repo/comments/api";
export const appRouter = t.router({
comments: commentsRouter,
// ... other feature routers
});
```
**Wire into `core-cms`** — edit `packages/core-cms/src/collections/index.ts`:
```typescript
import { comments } from "@repo/comments/cms";
export const collections = [comments];
```
**Add path aliases** — edit `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/cms": ["packages/comments/src/integrations/cms/index.ts"],
"@repo/comments/di/bind-production": ["packages/comments/src/di/bind-production.ts"]
}
}
}
```
**Validate everything passes:**
```bash
pnpm install
pnpm typecheck --filter @repo/comments
pnpm test --filter @repo/comments
pnpm lint --filter @repo/comments
pnpm turbo boundaries
```
---
## Part 3: Integrate with Core
### Wire into app bootstrap
In `apps/web-next/src/app/layout.tsx` or equivalent:
```typescript
import { bindProductionComments } from "@repo/comments/di/bind-production";
import { payloadConfig } from "@repo/core-cms";
// At app boot:
await bindProductionComments(commentsContainer, payloadConfig);
```
### Final test, typecheck, build
```bash
pnpm install
pnpm typecheck --filter @repo/comments
pnpm test --filter @repo/comments
pnpm build --filter @repo/comments
```
---
## Part 4: Modifying an Existing Feature
Example: Adding an `unapprove-article` procedure to `packages/blog`.
### 1. Write the failing test first
Create or extend the use case test:
```typescript
// packages/blog/src/application/use-cases/unapprove-article.use-case.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import { MockArticlesRepository } from "../../infrastructure/repositories/mock-articles.repository";
import { articleFactory } from "../../__factories__/article.factory";
import { unapproveArticleUseCase } from "./unapprove-article.use-case";
describe("unapproveArticleUseCase", () => {
let repo: MockArticlesRepository;
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
repo = new MockArticlesRepository();
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
articleFactory.reset();
});
it("sets article status to draft", async () => {
const article = articleFactory.build({ status: "published" });
await repo.createArticle(article);
const result = await unapproveArticleUseCase(article.id);
expect(result?.status).toBe("draft");
});
});
```
Run — confirm RED. Then implement.
### 2. Implement use case
Create `packages/blog/src/application/use-cases/unapprove-article.use-case.ts`:
```typescript
import type { Article } from "../../entities/article.js";
import { blogContainer } from "../../di/container.js";
import { BLOG_SYMBOLS } from "../../di/symbols.js";
import type { IArticlesRepository } from "../repositories/articles-repository.interface.js";
export async function unapproveArticleUseCase(
articleId: string,
): Promise<Article | undefined> {
const repo = blogContainer.get<IArticlesRepository>(
BLOG_SYMBOLS.IArticlesRepository,
);
return repo.updateArticle(articleId, { status: "draft" });
}
```
Run — confirm GREEN.
### 3. Add tRPC procedure
Edit `packages/blog/src/integrations/api/router.ts`:
```typescript
export const blogRouter = t.router({
// ... existing
unapproveArticle: t.procedure
.input(z.object({ articleId: z.string() }))
.mutation(async ({ input }) => {
return unapproveArticleUseCase(input.articleId);
}),
});
```
### 4. Test and lint
```bash
pnpm test --filter @repo/blog
pnpm lint --filter @repo/blog
```
---
## Done Criteria
- Package created with correct folder structure
- `entities/` has Zod schemas with unit tests (RED then GREEN)
- `__factories__/` has a factory for each entity
- `application/use-cases/` has business logic tested with mock repos
- `__contracts__/` has contract suites for each repository interface
- `infrastructure/repositories/` has mock and (if Payload) Payload implementations, both passing the contract suite
- `interface-adapters/controllers/` has input-parsing controllers with tests
- `integrations/api/` exports a tRPC router with integration tests
- `di/container.ts` wires everything; `di/bind-production.ts` binds Payload repo
- Feature exported from `core-api` router aggregator
- Feature collections exported from `core-cms`
- Path aliases added to `tsconfig.base.json`
- `pnpm install && pnpm typecheck && pnpm test && pnpm lint` all pass
- ESLint boundaries pass (feature only imports `core-*` and tooling)