Wires the existing turbo gen feature generator into AGENTS.md (Adding a Feature section, Key Commands, Specification & Guides) and CLAUDE.md (Quick Start, Read First). Adds a fast-path callout at the top of the manual walkthrough in docs/guides/adding-a-feature.md pointing at scaffolding-a-feature.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
38 KiB
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 featureproduces a Lazar-conformant single-entity / single-use-case package matching thenavigationreference shape (DI, tRPC router with tests, span + capture sandwich, dev seed, contract suite). See Scaffolding a Feature. Use this guide when the generator's Phase-1 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.
1. Overview
Every feature package owns:
| Layer | What lives there |
|---|---|
entities/models/ |
Zod schemas + inferred TypeScript types |
entities/errors/ |
Domain error classes (this.name required); common.ts for InputParseError |
application/repositories/ |
Repository interface (no implementation) |
application/use-cases/ |
One factory per operation; owns xInputSchema, xOutputSchema, and xOutputSchema.parse() |
infrastructure/repositories/ |
Real (<noun>.repository.ts) and mock (<noun>.repository.mock.ts) siblings |
interface-adapters/controllers/ |
One factory per use case; accepts unknown, calls safeParse, runs presenter |
di/ |
symbols.ts + module.ts + container.ts + bind-production.ts |
integrations/api/ |
procedures.ts (feature error map) + router.ts (uses xProcedure.input(xInputSchema)) |
integrations/cms/ |
Payload collection/global configs |
ui/ |
Query builders and future React components (behind ./ui subpath) |
__factories__/ |
Test data factories |
__contracts__/ |
Contract suites shared by mock and real repository tests |
The walkthrough below builds a minimal comments feature from scratch.
All concrete code mirrors the blog package (the most fully developed
feature) — read packages/blog/src/ alongside this guide.
2. Canonical Folder Layout
packages/comments/
src/
entities/
models/
comment.ts # Zod schema + Comment type
comment.test.ts
errors/
comment.ts # CommentNotFoundError (this.name required)
common.ts # InputParseError (this.name required)
errors.test.ts
application/
repositories/
comments.repository.interface.ts
use-cases/
get-comments.use-case.ts # getCommentsInputSchema + getCommentsOutputSchema + parse
get-comments.use-case.test.ts
create-comment.use-case.ts
create-comment.use-case.test.ts
infrastructure/
repositories/
comments.repository.mock.ts # MockCommentsRepository
comments.repository.mock.test.ts # runs contract suite
comments.repository.ts # CommentsRepository (Payload-backed)
comments.repository.test.ts # runs contract suite against Payload stub
interface-adapters/
controllers/
get-comments.controller.ts # factory + presenter
get-comments.controller.test.ts
create-comment.controller.ts
create-comment.controller.test.ts
di/
symbols.ts
module.ts
container.ts
container.test.ts
bind-production.ts
integrations/
api/
procedures.ts # commentsProcedure with feature error map
router.ts # commentsProcedure.input(xInputSchema)
router.test.ts # includes R26 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
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
{
"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
{
"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
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:
// 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();
});
});
pnpm test --filter @repo/comments -- comment.test.ts # RED — module not found
Implement:
// 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>;
pnpm test --filter @repo/comments -- comment.test.ts # GREEN
Step 6: Domain errors
// 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
}
}
// 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):
// 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.
// 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
// 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— az.ZodObjectwith.strict()(usez.object({}).strict()for void inputs)xOutputSchema— for non-void use casesXInput/XOutputtypesIXUseCasealias (ReturnType<typeof xUseCase>)
The body ends with xOutputSchema.parse(result) before returning.
Write the test first (direct injection — no container):
// 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([]);
});
});
// R25 — output validation
describe("getCommentsUseCase output validation (R25)", () => {
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);
});
});
pnpm test --filter @repo/comments -- get-comments.use-case.test.ts # RED
Implement:
// 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);
};
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:
// 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:
// 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:
// 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());
});
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.
// 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);
});
});
pnpm test --filter @repo/comments -- get-comments.controller.test.ts # RED
Implement:
// 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);
};
pnpm test --filter @repo/comments -- get-comments.controller.test.ts # GREEN
Step 12: DI — symbols, module, container
// 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;
// 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),
),
);
});
// 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:
// 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.
// 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 R26 error-mapping test)
The router:
- uses
commentsProcedure(never barepublicProcedure) - calls
.input(xInputSchema)importing from the use-case file — never redefines the schema inline - resolves controllers from the container
// 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([]);
});
});
// R26 — error mapping
describe("commentsRouter (R26 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");
}
});
});
pnpm test --filter @repo/comments -- router.test.ts # RED
Implement:
// 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;
// src/integrations/api/index.ts
export { commentsRouter } from "./router";
export type { CommentsRouter } from "./router";
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.
// 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):
// 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
// src/di/bind-production.ts
import type { SanitizedConfig } from "payload";
import { commentsContainer } from "./container";
import { COMMENTS_SYMBOLS } from "./symbols";
import { CommentsRepository } from "../infrastructure/repositories/comments.repository";
export function bindProductionComments(config: SanitizedConfig): void {
if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
}
commentsContainer
.bind(COMMENTS_SYMBOLS.ICommentsRepository)
.toConstantValue(new CommentsRepository(config));
}
Step 17: Payload collection
// 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 },
],
};
// 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.
// 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";
// 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 });
}
// 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):
import { commentsRouter } from "@repo/comments/api";
export const appRouter = t.router({
comments: commentsRouter,
// ... other features
});
packages/core-cms/src/collections/index.ts (or equivalent):
import { comments } from "@repo/comments/cms";
export const collections = [...existingCollections, comments];
Add path aliases to tsconfig.base.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:
import { bindProductionComments } from "@repo/comments/di/bind-production";
// called once at startup with the resolved Payload config
await bindProductionComments(resolvedConfig);
Step 21: Final validation
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
-
Forgetting
this.namein domain error constructors. Without it,instanceofchecks indefineErrorMiddlewarestill work, but serialized stack traces label the error as"Error"instead of"CommentNotFoundError", making debugging significantly harder. Setthis.name = "CommentNotFoundError"in every domain error andInputParseErrorconstructor (ADR-013 R6). -
Redefining the input schema in the controller. The controller must
import { xInputSchema } from "../../application/use-cases/x.use-case". A localconst 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. -
Typing controller input as
Partial<...>orz.infer<...>instead ofunknown. The controller receives unvalidated data from an external boundary;unknownis the correct type. Using a TypeScript type here bypasses the runtimesafeParseguard entirely when callers are within the same TypeScript project. -
Skipping the presenter on a non-void controller. Every non-void controller must define
function presenter(value: XOutput)and returnPromise<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 R11). -
Adding feature error classes to
core-shared.core-sharedmust stay boundary-clean — it providesdefineErrorMiddlewarebut knows nothing aboutCommentNotFoundErroror any other feature error class. Feature packages pass their own constructors todefineErrorMiddlewarein their ownprocedures.ts. Adding feature errors tocore-sharedviolates thefeature → coredependency direction enforced by ESLint boundaries and Turborepo. -
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. -
Forgetting to add the
./uisubpath topackage.json. Ifsrc/ui/index.tsexists but the"./ui"entry is missing from theexportsmap, TypeScript resolves the import in workspace mode but the build fails. Always keep the exports map in sync withtsconfig.base.jsonpath aliases.
6. Cross-References
- ADR-012 (
docs/decisions/adr-012-lazar-conformance.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 forxInputSchema+xOutputSchema; presenter pattern; per-featureprocedures.tserror map; public surface split (./vs./ui). - Refactor log — Plan 8 (
docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md) — file-by-file inventory of every rename, split, and pattern change applied to all existing features. - Refactor log — Plan 9 (
docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md) — inventory of schema additions, presenter additions,procedures.tsadditions, and./uisubpath additions across all 5 features. - 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 R25/R26 test obligations.