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>
1039 lines
30 KiB
Markdown
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)
|