Files
agentic-dev/docs/guides/adding-a-feature.md
Danijel Martinek 892f924603 docs: surface turbo gen feature in AGENTS.md and CLAUDE.md
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>
2026-05-08 11:30:10 +02:00

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 feature produces a Lazar-conformant single-entity / single-use-case package matching the navigation reference 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 — a z.ZodObject with .strict() (use z.object({}).strict() for void inputs)
  • xOutputSchema — for non-void use cases
  • XInput / XOutput types
  • IXUseCase alias (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 bare publicProcedure)
  • 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

  1. Forgetting this.name in domain error constructors. Without it, instanceof checks in defineErrorMiddleware still work, but serialized stack traces label the error as "Error" instead of "CommentNotFoundError", making debugging significantly harder. Set this.name = "CommentNotFoundError" in every domain error and InputParseError constructor (ADR-013 R6).

  2. Redefining the input schema in the controller. The controller must import { xInputSchema } from "../../application/use-cases/x.use-case". A local const 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.

  3. Typing controller input as Partial<...> or z.infer<...> instead of unknown. The controller receives unvalidated data from an external boundary; unknown is the correct type. Using a TypeScript type here bypasses the runtime safeParse guard entirely when callers are within the same TypeScript project.

  4. Skipping the presenter on a non-void controller. Every non-void controller must define function presenter(value: XOutput) and return Promise<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).

  5. Adding feature error classes to core-shared. core-shared must stay boundary-clean — it provides defineErrorMiddleware but knows nothing about CommentNotFoundError or any other feature error class. Feature packages pass their own constructors to defineErrorMiddleware in their own procedures.ts. Adding feature errors to core-shared violates the feature → core dependency direction enforced by ESLint boundaries and Turborepo.

  6. 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.

  7. Forgetting to add the ./ui subpath to package.json. If src/ui/index.ts exists but the "./ui" entry is missing from the exports map, TypeScript resolves the import in workspace mode but the build fails. Always keep the exports map in sync with tsconfig.base.json path 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 for xInputSchema + xOutputSchema; presenter pattern; per-feature procedures.ts error 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.ts additions, and ./ui subpath 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.