40 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.
Workflow ordering
For any new use case, follow these four steps in order:
- Manifest entry — declare the use case in
src/feature.manifest.tswith itsmutatesflag and (initially empty)audits/publishes/consumesarrays. - Contracts — export
xInputSchema,xOutputSchema, and theIXUseCasetype alias from the use-case file. Factory body starts asthrow new Error("not implemented"). - Tests (red) — write the failing test that exercises the contract via the factory + a mock repository.
- Implementation (green) — fill the factory body until the tests pass.
The feature-must-have-manifest ESLint rule will catch step 1 omissions; usecase-must-have-test-file catches step 3. The boot assertion (assertFeatureConformance at the tail of bindProductionX) catches forgotten wrappers at startup.
For the fast path, run pnpm turbo gen feature <name> — the generator emits the manifest + contracts + bind-production with the assertion already wired in.
1. Overview
Every feature package owns:
| Layer | What lives there |
|---|---|
entities/models/ |
Zod schemas + inferred TypeScript types |
entities/errors/ |
Domain error classes (this.name required); common.ts for InputParseError |
application/repositories/ |
Repository interface (no implementation) |
application/use-cases/ |
One factory per operation; owns xInputSchema, xOutputSchema, and xOutputSchema.parse() |
infrastructure/repositories/ |
Real (<noun>.repository.ts) and mock (<noun>.repository.mock.ts) siblings |
interface-adapters/controllers/ |
One factory per use case; accepts unknown, calls safeParse, runs presenter |
di/ |
symbols.ts + module.ts + container.ts + bind-production.ts |
integrations/api/ |
procedures.ts (feature error map) + router.ts (uses xProcedure.input(xInputSchema)) |
integrations/cms/ |
Payload collection/global configs |
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
The full canonical shape includes instrumentation (ADR-014) and the
event-bus + job-queue parameters (ADR-015) — see any feature's
bind-production.ts for a complete reference. Minimal sketch:
// src/di/bind-production.ts
import type { SanitizedConfig } from "payload";
import {
withSpan,
withCapture,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IEventBus } from "@repo/core-events";
import type { IJobQueue } from "@repo/core-shared/jobs";
import { commentsContainer } from "./container";
import { COMMENTS_SYMBOLS } from "./symbols";
import { CommentsRepository } from "../infrastructure/repositories/comments.repository";
export function bindProductionComments(
config: SanitizedConfig,
tracer: ITracer,
logger: ILogger,
bus: IEventBus,
queue: IJobQueue,
): void {
if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
}
const repo = new CommentsRepository(config, tracer, logger);
commentsContainer
.bind(COMMENTS_SYMBOLS.ICommentsRepository)
.toConstantValue(repo);
// Use cases + controllers are wrapped with withSpan(withCapture(...))
// at bind time. bus + queue are accept-and-forward until you add an
// event handler or job — at which point the gen event consume / gen job
// generators inject usage at the // <gen:event-handlers> / // <gen:jobs>
// anchors at the end of the function body.
}
Step 17: Payload collection
// 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.