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>
30 KiB
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.
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 operationapplication/repositories/— interface + mock implementationinfrastructure/repositories/— Payload-backed implementation (if needed)di/— InversifyJS container + symbol tableintegrations/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
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
{
"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
{
"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
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)
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:
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:
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:
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:
export { commentSchema, type Comment } from "./comment.js";
Run — confirm GREEN:
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:
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:
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:
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:
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:
export const COMMENTS_SYMBOLS = {
ICommentsRepository: Symbol("ICommentsRepository"),
} as const;
Create packages/comments/src/application/repositories/comments-repository.interface.ts:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
pnpm test --filter @repo/comments -- router.test.ts
# Error: Cannot find module './router'
Step 12: Wire router
Create packages/comments/src/di/container.ts:
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:
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:
export { commentsRouter } from "./router.js";
Run — confirm GREEN:
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:
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:
pnpm test --filter @repo/comments -- CommentForm.test.tsx
# Error: Cannot find module './CommentForm'
Step 14: Implement component
Create packages/comments/src/ui/CommentForm.tsx:
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:
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:
export { commentSchema, type Comment } from "./entities/index.js";
export { commentsContainer } from "./di/container.js";
Create packages/comments/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 },
],
};
Create packages/comments/src/integrations/cms/index.ts:
export { comments } from "./collections/comments.collection.js";
Create packages/comments/src/di/bind-production.ts:
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:
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:
import { comments } from "@repo/comments/cms";
export const collections = [comments];
Add path aliases — edit tsconfig.base.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:
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:
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
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:
// 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:
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:
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
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 entityapplication/use-cases/has business logic tested with mock repos__contracts__/has contract suites for each repository interfaceinfrastructure/repositories/has mock and (if Payload) Payload implementations, both passing the contract suiteinterface-adapters/controllers/has input-parsing controllers with testsintegrations/api/exports a tRPC router with integration testsdi/container.tswires everything;di/bind-production.tsbinds Payload repo- Feature exported from
core-apirouter aggregator - Feature collections exported from
core-cms - Path aliases added to
tsconfig.base.json pnpm install && pnpm typecheck && pnpm test && pnpm lintall pass- ESLint boundaries pass (feature only imports
core-*and tooling)