Files
agentic-dev/docs/guides/adding-a-feature.md
Danijel Martinek 17d3488fb4 docs(guides): add TDD workflow + restructure adding-a-feature for TDD order
New: docs/guides/tdd-workflow.md — red-green-refactor cycle, AAA,
mocking decision tree, coverage targets, factory + contract usage.
Restructured: adding-a-feature.md interleaves tests with implementation;
TDD order is required, not optional. testing-strategy.md cross-links
the new guide. AGENTS.md and CLAUDE.md surface both.

Spec: §7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 19:28:00 +02:00

30 KiB

Adding a New Feature — End-to-End Guide

A feature is a vertical slice: entities, use cases, repositories, tRPC router, CMS integration, DI container, and UI components — all owned by one package.

Decide upfront: Is this a new feature or an extension of an existing one? New features get a new package (e.g., packages/comments). Extensions add to an existing feature (e.g., adding an unapprove-article procedure to packages/blog).

TDD Order Required: You may not advance to the next layer until the current layer's tests are red, then green. See TDD Workflow.

Part 1: New Feature Scaffold

Step 1: Decide shape

The smallest viable feature has:

  • entities/ — type definitions and schemas (Zod)
  • application/use-cases/ — one business operation
  • application/repositories/ — interface + mock implementation
  • infrastructure/repositories/ — Payload-backed implementation (if needed)
  • di/ — InversifyJS container + symbol table
  • integrations/api/ — tRPC router (optional if no read API)
  • integrations/cms/ — Payload collection/global (if Payload-backed)
  • ui/ — feature-specific components (atoms/molecules/organisms)

Step 2: Create the package

mkdir -p packages/<feature-name>/src/{entities,application/{use-cases,repositories},infrastructure/repositories,di,integrations/{api,cms},ui,interface-adapters/controllers,__factories__,__contracts__}

Step 3: Create package.json

{
  "name": "@repo/<feature-name>",
  "version": "0.0.1",
  "private": true,
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./api": {
      "types": "./dist/integrations/api/index.d.ts",
      "import": "./dist/integrations/api/index.js"
    },
    "./cms": {
      "types": "./dist/integrations/cms/index.d.ts",
      "import": "./dist/integrations/cms/index.js"
    },
    "./di/bind-production": {
      "types": "./dist/di/bind-production.d.ts",
      "import": "./dist/di/bind-production.js"
    }
  },
  "dependencies": {
    "@repo/core-shared": "workspace:*",
    "@repo/core-testing": "workspace:*"
  },
  "devDependencies": {
    "@repo/core-typescript": "workspace:*"
  }
}

Step 4: Create tsconfig.json

{
  "extends": "@repo/core-typescript/base.json",
  "compilerOptions": {
    "rootDir": ".",
    "outDir": "dist",
    "lib": ["ES2022", "DOM"],
    "jsx": "preserve"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Step 5: Create vitest.config.ts

import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
  test: {
    environment: "node",
    globals: true,
    include: ["src/**/*.test.ts"],
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

Step 6: Add to root pnpm-workspace.yaml (if not already included)

packages:
  - "packages/*"

Run pnpm install — the new package is now part of the workspace.


Part 2: Build the Layers (Test-First)

The steps below use comments as the example feature name. Replace comments/Comment/comment with your feature name throughout.


Step 1: Write failing test for entity schema

Create packages/comments/src/entities/comment.test.ts:

import { describe, expect, it } from "vitest";
import { commentSchema } from "./comment";

describe("commentSchema", () => {
  it("accepts a valid comment", () => {
    const result = commentSchema.parse({
      id: "c-1",
      articleId: "a-1",
      body: "Great post",
      authorId: "u-1",
      createdAt: new Date(),
    });
    expect(result.body).toBe("Great post");
  });

  it("rejects empty body", () => {
    expect(() =>
      commentSchema.parse({
        id: "c-1",
        articleId: "a-1",
        body: "",
        authorId: "u-1",
        createdAt: new Date(),
      }),
    ).toThrow();
  });
});

Run — confirm RED:

pnpm test --filter @repo/comments -- comment.test.ts
# Error: Cannot find module './comment'

Step 2: Implement entity to pass

Create packages/comments/src/entities/comment.ts:

import { z } from "zod";

export const commentSchema = z.object({
  id: z.string(),
  articleId: z.string(),
  body: z.string().min(1).max(2000),
  authorId: z.string(),
  createdAt: z.date(),
});

export type Comment = z.infer<typeof commentSchema>;

Create packages/comments/src/entities/index.ts:

export { commentSchema, type Comment } from "./comment.js";

Run — confirm GREEN:

pnpm test --filter @repo/comments -- comment.test.ts
# PASS  ✓ accepts a valid comment
# PASS  ✓ rejects empty body

Step 3: Write factory

Create packages/comments/src/__factories__/comment.factory.ts:

import { defineFactory } from "@repo/core-testing/factory";
import type { Comment } from "../entities/comment.js";

export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
  id: `comment-${sequence}`,
  articleId: "article-1",
  body: `Comment body ${sequence}`,
  authorId: "user-1",
  createdAt: new Date("2026-01-01T00:00:00Z"),
}));

Create packages/comments/src/__factories__/index.ts:

export { commentFactory } from "./comment.factory.js";

Step 4: Write failing test for use case (using factory + mock repo)

The use case depends on ICommentsRepository. Write the test before the repository exists; use a hand-rolled inline mock for now.

Create packages/comments/src/application/use-cases/create-comment.use-case.test.ts:

import { beforeEach, describe, expect, it } from "vitest";
import { commentFactory } from "../../__factories__/comment.factory";
import { createCommentUseCase } from "./create-comment.use-case";
import { commentsContainer } from "../../di/container";
import { COMMENTS_SYMBOLS } from "../../di/symbols";
import type { ICommentsRepository } from "../repositories/comments-repository.interface";
import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository";

describe("createCommentUseCase", () => {
  let repo: MockCommentsRepository;

  beforeEach(() => {
    if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
      commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
    }
    repo = new MockCommentsRepository();
    commentsContainer
      .bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository)
      .toConstantValue(repo);
    commentFactory.reset();
  });

  it("creates a comment with the correct fields", async () => {
    const result = await createCommentUseCase({
      articleId: "a-1",
      body: "Great post",
      authorId: "u-1",
    });
    expect(result.body).toBe("Great post");
    expect(result.articleId).toBe("a-1");
    expect(result.authorId).toBe("u-1");
    expect(typeof result.id).toBe("string");
  });

  it("throws when body is empty", async () => {
    await expect(
      createCommentUseCase({ articleId: "a-1", body: "", authorId: "u-1" }),
    ).rejects.toThrow();
  });
});

Run — confirm RED:

pnpm test --filter @repo/comments -- create-comment.use-case.test.ts
# Error: Cannot find module './create-comment.use-case'

Step 5: Implement use case to pass

Create packages/comments/src/di/symbols.ts:

export const COMMENTS_SYMBOLS = {
  ICommentsRepository: Symbol("ICommentsRepository"),
} as const;

Create packages/comments/src/application/repositories/comments-repository.interface.ts:

import type { Comment } from "../../entities/comment.js";

export interface ICommentsRepository {
  createComment(comment: Comment): Promise<Comment>;
  getCommentsForArticle(articleId: string): Promise<Comment[]>;
}

Create packages/comments/src/application/use-cases/create-comment.use-case.ts:

import type { Comment } from "../../entities/comment.js";
import { commentSchema } from "../../entities/comment.js";
import { commentsContainer } from "../../di/container.js";
import { COMMENTS_SYMBOLS } from "../../di/symbols.js";
import type { ICommentsRepository } from "../repositories/comments-repository.interface.js";

export async function createCommentUseCase(input: {
  articleId: string;
  body: string;
  authorId: string;
}): Promise<Comment> {
  const repo = commentsContainer.get<ICommentsRepository>(
    COMMENTS_SYMBOLS.ICommentsRepository,
  );
  const comment: Comment = {
    id: crypto.randomUUID(),
    articleId: input.articleId,
    body: input.body,
    authorId: input.authorId,
    createdAt: new Date(),
  };
  commentSchema.parse(comment); // throws on invalid body
  return repo.createComment(comment);
}

Run — confirm GREEN:

pnpm test --filter @repo/comments -- create-comment.use-case.test.ts
# PASS  ✓ creates a comment with the correct fields
# PASS  ✓ throws when body is empty

Step 6: Write contract suite

Create packages/comments/src/__contracts__/comments-repository.contract.ts:

import { it, expect, beforeEach } from "vitest";
import { defineContractSuite } from "@repo/core-testing/contract";
import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js";
import { commentFactory } from "../__factories__/comment.factory.js";

export const commentsRepositoryContract =
  defineContractSuite<ICommentsRepository>(
    "ICommentsRepository",
    ({ buildSubject }) => {
      let repo: ICommentsRepository;

      beforeEach(async () => {
        commentFactory.reset();
        repo = await buildSubject();
      });

      it("createComment returns a comment with the correct fields", async () => {
        const seed = commentFactory.build({ body: "Hello" });
        const created = await repo.createComment(seed);
        expect(typeof created.id).toBe("string");
        expect(created.body).toBe("Hello");
      });

      it("getCommentsForArticle returns comments for the given articleId", async () => {
        const seed = commentFactory.build({ articleId: "a-1" });
        await repo.createComment(seed);
        const results = await repo.getCommentsForArticle("a-1");
        expect(results).toHaveLength(1);
        expect(results[0]?.articleId).toBe("a-1");
      });

      it("getCommentsForArticle returns empty array for unknown articleId", async () => {
        const results = await repo.getCommentsForArticle("no-such-article");
        expect(results).toHaveLength(0);
      });
    },
  );

Step 7: Implement Mock repo, run contract → green

Create packages/comments/src/infrastructure/repositories/mock-comments.repository.ts:

import "reflect-metadata";
import { injectable } from "inversify";
import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface.js";
import type { Comment } from "../../entities/comment.js";

@injectable()
export class MockCommentsRepository implements ICommentsRepository {
  private _comments: Comment[] = [];

  async createComment(comment: Comment): Promise<Comment> {
    this._comments.push(comment);
    return comment;
  }

  async getCommentsForArticle(articleId: string): Promise<Comment[]> {
    return this._comments.filter((c) => c.articleId === articleId);
  }
}

Create packages/comments/src/infrastructure/repositories/mock-comments.repository.test.ts:

import { describe } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { MockCommentsRepository } from "./mock-comments.repository";

describe("MockCommentsRepository", () => {
  commentsRepositoryContract.run(async () => new MockCommentsRepository());
});

Run — confirm GREEN:

pnpm test --filter @repo/comments -- mock-comments.repository.test.ts
# PASS  Contract: ICommentsRepository
#   ✓ createComment returns a comment with the correct fields
#   ✓ getCommentsForArticle returns comments for the given articleId
#   ✓ getCommentsForArticle returns empty array for unknown articleId

Step 8: Implement Payload repo (with vi.mock), run same contract → green

Create packages/comments/src/infrastructure/repositories/payload-comments.repository.ts:

import "reflect-metadata";
import { injectable } from "inversify";
import { getPayload } from "payload";
import type { SanitizedConfig } from "payload";
import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface.js";
import type { Comment } from "../../entities/comment.js";

type PayloadCommentDoc = {
  id: string | number;
  articleId?: string | null;
  body?: string | null;
  author?: string | number | null;
  createdAt?: string | null;
};

function mapDoc(doc: PayloadCommentDoc): Comment {
  return {
    id: String(doc.id),
    articleId: doc.articleId ?? "",
    body: doc.body ?? "",
    authorId: doc.author != null ? String(doc.author) : "",
    createdAt: doc.createdAt ? new Date(doc.createdAt) : new Date(0),
  };
}

@injectable()
export class PayloadCommentsRepository implements ICommentsRepository {
  constructor(private config: SanitizedConfig) {}

  async createComment(comment: Comment): Promise<Comment> {
    const payload = await getPayload({ config: this.config });
    const created = await payload.create({
      collection: "comments",
      data: {
        articleId: comment.articleId,
        body: comment.body,
        author: comment.authorId,
      } as never,
      overrideAccess: true,
    });
    return mapDoc(created as PayloadCommentDoc);
  }

  async getCommentsForArticle(articleId: string): Promise<Comment[]> {
    const payload = await getPayload({ config: this.config });
    const result = await payload.find({
      collection: "comments",
      where: { articleId: { equals: articleId } } as never,
      overrideAccess: true,
    });
    return result.docs.map((d) => mapDoc(d as PayloadCommentDoc));
  }
}

Create packages/comments/src/infrastructure/repositories/payload-comments.repository.test.ts:

import { describe, vi } from "vitest";
import { commentsRepositoryContract } from "@/__contracts__/comments-repository.contract";
import { PayloadCommentsRepository } from "./payload-comments.repository";
import { stubPayloadConfig } from "@repo/core-testing/payload";

vi.mock("payload", () => ({ getPayload: vi.fn() }));

describe("PayloadCommentsRepository", () => {
  commentsRepositoryContract.run(async () => {
    const store = new Map<string, Record<string, unknown>>();
    const stub = {
      create: vi.fn(async ({ data }: { collection: string; data: Record<string, unknown> }) => {
        const doc = { id: `stub-${store.size + 1}`, ...data };
        store.set(String(doc.id), doc);
        return doc;
      }),
      find: vi.fn(async ({ where }: { collection: string; where?: { articleId?: { equals: string } } }) => {
        let docs = Array.from(store.values());
        if (where?.articleId) {
          docs = docs.filter((d) => d.articleId === where.articleId?.equals);
        }
        return { docs };
      }),
    };
    const { getPayload } = await import("payload");
    (getPayload as ReturnType<typeof vi.fn>).mockResolvedValue(stub);
    return new PayloadCommentsRepository(stubPayloadConfig);
  });
});

Run — confirm GREEN:

pnpm test --filter @repo/comments -- payload-comments.repository.test.ts
# PASS  PayloadCommentsRepository > Contract: ICommentsRepository
#   ✓ createComment returns a comment with the correct fields
#   ✓ getCommentsForArticle returns comments for the given articleId
#   ✓ getCommentsForArticle returns empty array for unknown articleId

Step 9: Write failing controller test

Create packages/comments/src/interface-adapters/controllers/comments.controller.test.ts:

import { beforeEach, describe, expect, it } from "vitest";
import { commentsContainer } from "../../di/container";
import { COMMENTS_SYMBOLS } from "../../di/symbols";
import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository";
import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface";
import { InputParseError } from "../../entities/errors";
import { createCommentController } from "./comments.controller";

describe("comments controller", () => {
  beforeEach(() => {
    if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
      commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
    }
    commentsContainer
      .bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository)
      .toConstantValue(new MockCommentsRepository());
  });

  describe("createCommentController", () => {
    it("creates a comment on valid input", async () => {
      const result = await createCommentController({
        articleId: "a-1",
        body: "Nice article",
        authorId: "u-1",
      });
      expect(result.body).toBe("Nice article");
    });

    it("throws InputParseError when body is missing", async () => {
      await expect(
        createCommentController({ articleId: "a-1", authorId: "u-1" }),
      ).rejects.toBeInstanceOf(InputParseError);
    });
  });
});

Run — confirm RED:

pnpm test --filter @repo/comments -- comments.controller.test.ts
# Error: Cannot find module './comments.controller'

Step 10: Implement controller

Create packages/comments/src/entities/errors.ts:

export class InputParseError extends Error {
  constructor(message: string, options?: { cause?: unknown }) {
    super(message, options);
    this.name = "InputParseError";
  }
}

Create packages/comments/src/interface-adapters/controllers/comments.controller.ts:

import { z } from "zod";
import { InputParseError } from "../../entities/errors.js";
import { createCommentUseCase } from "../../application/use-cases/create-comment.use-case.js";
import type { Comment } from "../../entities/comment.js";

const createInputSchema = z.object({
  articleId: z.string().min(1),
  body: z.string().min(1),
  authorId: z.string().min(1),
});

export async function createCommentController(
  input: Partial<z.infer<typeof createInputSchema>>,
): Promise<Comment> {
  const parsed = createInputSchema.safeParse(input);
  if (!parsed.success) {
    throw new InputParseError("Invalid create-comment input", {
      cause: parsed.error,
    });
  }
  return createCommentUseCase(parsed.data);
}

Run — confirm GREEN:

pnpm test --filter @repo/comments -- comments.controller.test.ts
# PASS  ✓ creates a comment on valid input
# PASS  ✓ throws InputParseError when body is missing

Step 11: Write failing tRPC integration test

Create packages/comments/src/integrations/api/router.test.ts:

import { beforeEach, describe, expect, it } from "vitest";
import { commentsContainer } from "../../di/container";
import { COMMENTS_SYMBOLS } from "../../di/symbols";
import { MockCommentsRepository } from "../../infrastructure/repositories/mock-comments.repository";
import type { ICommentsRepository } from "../../application/repositories/comments-repository.interface";
import { commentsRouter } from "./router";

describe("commentsRouter", () => {
  beforeEach(() => {
    if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
      commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
    }
    commentsContainer
      .bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository)
      .toConstantValue(new MockCommentsRepository());
  });

  it("exposes createComment procedure", () => {
    const procedureNames = Object.keys(commentsRouter._def.procedures);
    expect(procedureNames).toContain("createComment");
  });

  it("createComment creates and returns the comment", async () => {
    const caller = commentsRouter.createCaller({});
    const result = await caller.createComment({
      articleId: "a-1",
      body: "Hello",
      authorId: "u-1",
    });
    expect(result.body).toBe("Hello");
  });
});

Run — confirm RED:

pnpm test --filter @repo/comments -- router.test.ts
# Error: Cannot find module './router'

Step 12: Wire router

Create packages/comments/src/di/container.ts:

import "reflect-metadata";
import { Container } from "inversify";
import { MockCommentsRepository } from "../infrastructure/repositories/mock-comments.repository.js";
import { COMMENTS_SYMBOLS } from "./symbols.js";
import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js";

export const commentsContainer = new Container({ defaultScope: "Singleton" });
commentsContainer
  .bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository)
  .to(MockCommentsRepository);

Create packages/comments/src/integrations/api/router.ts:

import { z } from "zod";
import { t } from "@repo/core-shared/trpc/init";
import { createCommentController } from "../../interface-adapters/controllers/comments.controller.js";

export const commentsRouter = t.router({
  createComment: t.procedure
    .input(
      z.object({
        articleId: z.string().min(1),
        body: z.string().min(1),
        authorId: z.string().min(1),
      }),
    )
    .mutation(async ({ input }) => {
      return createCommentController(input);
    }),
});

Create packages/comments/src/integrations/api/index.ts:

export { commentsRouter } from "./router.js";

Run — confirm GREEN:

pnpm test --filter @repo/comments -- router.test.ts
# PASS  ✓ exposes createComment procedure
# PASS  ✓ createComment creates and returns the comment

Step 13: (UI optional) Write failing component test with renderWithProviders

Create packages/comments/src/ui/CommentForm.test.tsx:

import { describe, expect, it } from "vitest";
import { renderWithProviders } from "@repo/core-testing/react";
import { CommentForm } from "./CommentForm";

describe("CommentForm", () => {
  it("renders a textarea and submit button", () => {
    const screen = renderWithProviders(
      <CommentForm articleId="a-1" authorId="u-1" onSuccess={() => {}} />,
    );
    expect(screen.getByRole("textbox")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /post comment/i })).toBeInTheDocument();
  });
});

Run — confirm RED:

pnpm test --filter @repo/comments -- CommentForm.test.tsx
# Error: Cannot find module './CommentForm'

Step 14: Implement component

Create packages/comments/src/ui/CommentForm.tsx:

import { useState } from "react";

interface CommentFormProps {
  articleId: string;
  authorId: string;
  onSuccess: () => void;
}

export function CommentForm({ articleId, authorId, onSuccess }: CommentFormProps) {
  const [body, setBody] = useState("");

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    // Wire to tRPC mutation in a real implementation
    onSuccess();
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        placeholder="Write a comment..."
      />
      <button type="submit">Post comment</button>
    </form>
  );
}

Run — confirm GREEN:

pnpm test --filter @repo/comments -- CommentForm.test.tsx
# PASS  ✓ renders a textarea and submit button

Step 15: Wire into core-api / core-cms, run typecheck + lint + boundaries

Create packages/comments/src/index.ts:

export { commentSchema, type Comment } from "./entities/index.js";
export { commentsContainer } from "./di/container.js";

Create packages/comments/src/integrations/cms/collections/comments.collection.ts:

import type { CollectionConfig } from "payload";

export const comments: CollectionConfig = {
  slug: "comments",
  admin: { useAsTitle: "body" },
  fields: [
    { name: "articleId", type: "text", required: true },
    { name: "body", type: "textarea", required: true },
    { name: "author", type: "relationship", relationTo: "users", required: true },
  ],
};

Create packages/comments/src/integrations/cms/index.ts:

export { comments } from "./collections/comments.collection.js";

Create packages/comments/src/di/bind-production.ts:

import type { Container } from "inversify";
import type { SanitizedConfig } from "payload";
import { PayloadCommentsRepository } from "../infrastructure/repositories/payload-comments.repository.js";
import { COMMENTS_SYMBOLS } from "./symbols.js";
import type { ICommentsRepository } from "../application/repositories/comments-repository.interface.js";

export async function bindProductionComments(
  container: Container,
  config: SanitizedConfig,
): Promise<void> {
  const repo = new PayloadCommentsRepository(config);
  container.rebind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).toConstantValue(repo);
}

Wire into core-api — edit packages/core-api/src/routers.ts:

import { commentsRouter } from "@repo/comments/api";

export const appRouter = t.router({
  comments: commentsRouter,
  // ... other feature routers
});

Wire into core-cms — edit packages/core-cms/src/collections/index.ts:

import { comments } from "@repo/comments/cms";

export const collections = [comments];

Add path aliases — edit tsconfig.base.json:

{
  "compilerOptions": {
    "paths": {
      "@repo/comments": ["packages/comments/src/index.ts"],
      "@repo/comments/api": ["packages/comments/src/integrations/api/index.ts"],
      "@repo/comments/cms": ["packages/comments/src/integrations/cms/index.ts"],
      "@repo/comments/di/bind-production": ["packages/comments/src/di/bind-production.ts"]
    }
  }
}

Validate everything passes:

pnpm install
pnpm typecheck --filter @repo/comments
pnpm test --filter @repo/comments
pnpm lint --filter @repo/comments
pnpm turbo boundaries

Part 3: Integrate with Core

Wire into app bootstrap

In apps/web-next/src/app/layout.tsx or equivalent:

import { bindProductionComments } from "@repo/comments/di/bind-production";
import { payloadConfig } from "@repo/core-cms";

// At app boot:
await bindProductionComments(commentsContainer, payloadConfig);

Final test, typecheck, build

pnpm install
pnpm typecheck --filter @repo/comments
pnpm test --filter @repo/comments
pnpm build --filter @repo/comments

Part 4: Modifying an Existing Feature

Example: Adding an unapprove-article procedure to packages/blog.

1. Write the failing test first

Create or extend the use case test:

// packages/blog/src/application/use-cases/unapprove-article.use-case.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { blogContainer } from "../../di/container";
import { BLOG_SYMBOLS } from "../../di/symbols";
import { MockArticlesRepository } from "../../infrastructure/repositories/mock-articles.repository";
import { articleFactory } from "../../__factories__/article.factory";
import { unapproveArticleUseCase } from "./unapprove-article.use-case";

describe("unapproveArticleUseCase", () => {
  let repo: MockArticlesRepository;

  beforeEach(() => {
    if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
      blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
    }
    repo = new MockArticlesRepository();
    blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(repo);
    articleFactory.reset();
  });

  it("sets article status to draft", async () => {
    const article = articleFactory.build({ status: "published" });
    await repo.createArticle(article);
    const result = await unapproveArticleUseCase(article.id);
    expect(result?.status).toBe("draft");
  });
});

Run — confirm RED. Then implement.

2. Implement use case

Create packages/blog/src/application/use-cases/unapprove-article.use-case.ts:

import type { Article } from "../../entities/article.js";
import { blogContainer } from "../../di/container.js";
import { BLOG_SYMBOLS } from "../../di/symbols.js";
import type { IArticlesRepository } from "../repositories/articles-repository.interface.js";

export async function unapproveArticleUseCase(
  articleId: string,
): Promise<Article | undefined> {
  const repo = blogContainer.get<IArticlesRepository>(
    BLOG_SYMBOLS.IArticlesRepository,
  );
  return repo.updateArticle(articleId, { status: "draft" });
}

Run — confirm GREEN.

3. Add tRPC procedure

Edit packages/blog/src/integrations/api/router.ts:

export const blogRouter = t.router({
  // ... existing
  unapproveArticle: t.procedure
    .input(z.object({ articleId: z.string() }))
    .mutation(async ({ input }) => {
      return unapproveArticleUseCase(input.articleId);
    }),
});

4. Test and lint

pnpm test --filter @repo/blog
pnpm lint --filter @repo/blog

Done Criteria

  • Package created with correct folder structure
  • entities/ has Zod schemas with unit tests (RED then GREEN)
  • __factories__/ has a factory for each entity
  • application/use-cases/ has business logic tested with mock repos
  • __contracts__/ has contract suites for each repository interface
  • infrastructure/repositories/ has mock and (if Payload) Payload implementations, both passing the contract suite
  • interface-adapters/controllers/ has input-parsing controllers with tests
  • integrations/api/ exports a tRPC router with integration tests
  • di/container.ts wires everything; di/bind-production.ts binds Payload repo
  • Feature exported from core-api router aggregator
  • Feature collections exported from core-cms
  • Path aliases added to tsconfig.base.json
  • pnpm install && pnpm typecheck && pnpm test && pnpm lint all pass
  • ESLint boundaries pass (feature only imports core-* and tooling)