Files
agentic-dev-template/AGENTS.md

20 KiB

AGENTS.md -- Root Monorepo

This is a Turborepo + pnpm monorepo implementing Clean Architecture (Uncle Bob / Lazar Nikolov). It supports Next.js 15 and TanStack Start as frontend frameworks, Payload CMS v3 for content management, and tRPC v11 for type-safe API communication.


Monorepo Package Map

Package Path Purpose Depends On
@repo/core packages/core Clean Architecture business logic: entities, use cases, repository/service interfaces, controllers, InversifyJS DI container zod, inversify, reflect-metadata
@repo/api packages/api tRPC v11 routers that call @repo/core controllers @repo/core, @trpc/server, zod
@repo/api-client packages/api-client Shared React Query hooks, ApiProvider, and useTRPC for frontend apps @repo/api, @trpc/client, @trpc/tanstack-react-query, @tanstack/react-query
@repo/cms-core packages/cms-core Payload CMS config, collections (Users, Articles, Media), globals (SiteSettings), hooks payload, @payloadcms/db-postgres, @payloadcms/richtext-lexical
@repo/cms-client packages/cms-client Dual-mode Payload client (local via Payload instance, HTTP via REST API). STANDALONE -- no monorepo deps payload (types only)
@repo/ui packages/ui Atomic Design component library (atoms/molecules/organisms/templates) built with shadcn/ui patterns + Tailwind v4 clsx, tailwind-merge, react
@repo/eslint-config packages/eslint-config Shared ESLint 9 flat configs for the entire monorepo (tooling)
@repo/typescript-config packages/typescript-config Shared tsconfig base configs (base.json) with experimentalDecorators + emitDecoratorMetadata (tooling)
@repo/web-next apps/web-next Next.js 15 App Router frontend (port 3000) @repo/api, @repo/api-client, @repo/ui
@repo/web-tanstack apps/web-tanstack TanStack Start frontend (port 3002) @repo/api, @repo/api-client, @repo/ui
@repo/cms apps/cms Thin Next.js shell hosting the Payload Admin UI (port 3001) @repo/cms-core, @payloadcms/next, @payloadcms/ui
@repo/storybook apps/storybook Storybook 8 for @repo/ui components (port 6006) @repo/ui

Dependency Flow Diagram

                     +-----------------+     +-----------------+
                     | apps/web-next   |     | apps/web-tanstack|
                     +--------+--------+     +--------+--------+
                              |                       |
                    +---------+-----------+-----------+
                    |                     |
              +-----v------+      +------v-------+
              | @repo/api- |      |  @repo/ui    |
              | client     |      | (Atomic      |
              +-----+------+      |  Design)     |
                    |             +--------------+
              +-----v------+
              | @repo/api  |         +----------+     +------------+
              | (tRPC v11) |         | apps/cms |     | apps/      |
              +-----+------+         +----+-----+     | storybook  |
                    |                     |           +------+-----+
              +-----v------+      +------v-------+          |
              | @repo/core |      | @repo/       |    +-----v------+
              | (Clean     |      | cms-core     |    | @repo/ui   |
              | Arch)      |      | (Payload     |    +-----------+
              +-----+------+      | collections) |
                    |             +--------------+
                    |
              +-----v----------+
              | @repo/          |
              | cms-client      |
              | (optional,      |
              |  standalone)    |
              +----------------+

  @repo/eslint-config ---------> used by all packages (devDependency)
  @repo/typescript-config -----> used by all packages (devDependency)

Key rule: arrows point DOWN. A package may only depend on packages below it in this diagram. Apps sit at the top; @repo/core and @repo/cms-client sit at the bottom.


Complete Data Flow

Every user interaction follows this path:

UI Component (React)
  |
  v
useTRPC().content.listArticles.useQuery()     <-- @repo/api-client hook
  |
  v
tRPC Router Procedure (.query / .mutation)     <-- @repo/api router
  |
  v
Controller (Zod safeParse -> InputParseError)  <-- @repo/core interface-adapters
  |
  v
Use Case (business logic + getInjection())    <-- @repo/core application
  |
  v
Repository / Service Interface                 <-- @repo/core application (abstract)
  |
  v
Implementation (@injectable class)             <-- @repo/core infrastructure (concrete)
  |
  v
Data Store (Payload CMS / in-memory mock)

Example -- listing articles end-to-end:

// 1. UI: apps/web-next -- a React Server Component or client component
const trpc = useTRPC();
const articles = trpc.content.listArticles.useQuery({ status: "published" });

// 2. tRPC router: packages/api/src/router/content.router.ts
contentRouter = router({
  listArticles: publicProcedure
    .input(z.object({ status: z.string().optional(), /* ... */ }).optional())
    .query(async ({ input }) => {
      return await getArticlesController(input ?? {});
    }),
});

// 3. Controller: packages/core/src/interface-adapters/controllers/content/articles.controller.ts
export async function getArticlesController(input) {
  const { data, error } = getInputSchema.safeParse(input);
  if (error) throw new InputParseError("Invalid data", { cause: error });
  return await getArticlesUseCase(data);
}

// 4. Use Case: packages/core/src/application/use-cases/content/get-articles.use-case.ts
export async function getArticlesUseCase(options) {
  const articlesRepository = getInjection("IArticlesRepository");
  return await articlesRepository.getArticles(options);
}

// 5. Repository: resolved at runtime via InversifyJS DI container
//    Mock: packages/core/src/infrastructure/repositories/mock-articles.repository.ts
//    Production: a PayloadArticlesRepository using @repo/cms-client (future)

Hard Rules

# Rule Reason
1 @repo/core NEVER imports from apps/* or framework packages (Next.js, TanStack) Core business logic must be framework-agnostic. It must be portable across any UI framework or transport layer.
2 @repo/cms-core NEVER imports from @repo/core or @repo/infrastructure CMS config is Payload-native. The bridge between Payload and Clean Architecture is @repo/cms-client, consumed in @repo/core's infrastructure layer.
3 @repo/cms-client NEVER imports from any other @repo/* package cms-client is standalone. It defines a PayloadClient interface with local and http modes. It has zero monorepo dependencies so it can be used anywhere.
4 @repo/core's entities/ layer NEVER imports from application/, infrastructure/, interface-adapters/, or di/ Entities are the innermost layer of Clean Architecture. They define pure domain types and errors with zero dependencies.
5 @repo/core's application/ layer NEVER imports from infrastructure/ Use cases and interfaces depend on abstractions (interfaces), never on concrete implementations. The DI container resolves implementations at runtime.
6 @repo/core's interface-adapters/ layer NEVER imports from infrastructure/ Controllers validate input and delegate to use cases. They must not know about concrete data access or external services.

How to Add a New Feature (End-to-End Recipe)

This recipe walks through adding a "comments" feature. Follow every step in order.

Step 1: Define the Entity (packages/core/src/entities/models/comment.ts)

import { z } from "zod";

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

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

Export it from packages/core/src/entities/models/index.ts:

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

The barrel export chain is: models/index.ts -> entities/index.ts -> core/src/index.ts. Only models/index.ts needs updating; the other two already re-export with *.

Step 2: Define the Repository Interface (packages/core/src/application/repositories/comments.repository.interface.ts)

import type { Comment } from "@/entities/models/comment";

export interface ICommentsRepository {
  getComment(id: string): Promise<Comment | undefined>;
  getComments(options?: {
    articleId?: string;
    limit?: number;
    offset?: number;
  }): Promise<Comment[]>;
  createComment(input: Comment): Promise<Comment>;
}

Export from packages/core/src/application/repositories/index.ts:

export type { ICommentsRepository } from "./comments.repository.interface";

Step 3: Create Mock Implementation (packages/core/src/infrastructure/repositories/mock-comments.repository.ts)

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 {
  private _comments: Comment[] = [];

  async getComment(id: string): Promise<Comment | undefined> {
    return this._comments.find((c) => c.id === id);
  }

  async getComments(options?: {
    articleId?: string;
    limit?: number;
    offset?: number;
  }): Promise<Comment[]> {
    let result = [...this._comments];
    if (options?.articleId) {
      result = result.filter((c) => c.articleId === options.articleId);
    }
    const offset = options?.offset ?? 0;
    const limit = options?.limit ?? 50;
    return result.slice(offset, offset + limit);
  }

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

Critical: the @injectable() decorator is required for InversifyJS. Without it, the container cannot resolve this class.

Step 4: Register in DI (packages/core/src/di/)

4a. Add symbol to types.ts:

import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";

// Add to DI_SYMBOLS:
export const DI_SYMBOLS = {
  // ...existing...
  ICommentsRepository: Symbol.for("ICommentsRepository"),
};

// Add to DI_RETURN_TYPES:
export interface DI_RETURN_TYPES {
  // ...existing...
  ICommentsRepository: ICommentsRepository;
}

4b. Create module modules/comments.module.ts:

import { ContainerModule, interfaces } from "inversify";

import type { ICommentsRepository } from "@/application/repositories/comments.repository.interface";
import { MockCommentsRepository } from "@/infrastructure/repositories/mock-comments.repository";
import { DI_SYMBOLS } from "../types";

const initializeModule = (bind: interfaces.Bind) => {
  bind<ICommentsRepository>(DI_SYMBOLS.ICommentsRepository).to(
    MockCommentsRepository
  );
};

export const CommentsModule = new ContainerModule(initializeModule);

4c. Load module in container.ts:

import { CommentsModule } from "./modules/comments.module";

export const initializeContainer = () => {
  ApplicationContainer.load(AuthModule);
  ApplicationContainer.load(ContentModule);
  ApplicationContainer.load(CommentsModule); // <-- add
};

export const destroyContainer = () => {
  ApplicationContainer.unload(AuthModule);
  ApplicationContainer.unload(ContentModule);
  ApplicationContainer.unload(CommentsModule); // <-- add
};

Step 5: Create Use Case (packages/core/src/application/use-cases/content/create-comment.use-case.ts)

import type { Comment } from "@/entities/models/comment";
import { getInjection } from "@/di/container";

export async function createCommentUseCase(input: {
  articleId: string;
  authorId: string;
  body: string;
}): Promise<Comment> {
  const commentsRepository = getInjection("ICommentsRepository");

  const now = new Date();
  const comment: Comment = {
    id: crypto.randomUUID(),
    articleId: input.articleId,
    authorId: input.authorId,
    body: input.body,
    createdAt: now,
  };

  return await commentsRepository.createComment(comment);
}

Step 6: Create Controller (packages/core/src/interface-adapters/controllers/content/comments.controller.ts)

import { z } from "zod";

import { InputParseError } from "@/entities/errors/common";
import type { Comment } from "@/entities/models/comment";
import { createCommentUseCase } from "@/application/use-cases/content/create-comment.use-case";

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

export async function createCommentController(
  input: Partial<z.infer<typeof createInputSchema>>
): Promise<Comment> {
  const { data, error: inputParseError } = createInputSchema.safeParse(input);

  if (inputParseError) {
    throw new InputParseError("Invalid data", { cause: inputParseError });
  }

  return await createCommentUseCase(data);
}

Step 7: Export from core (packages/core/src/index.ts)

export { createCommentController } from "./interface-adapters/controllers/content/comments.controller";
export { createCommentUseCase } from "./application/use-cases/content/create-comment.use-case";

Step 8: Create tRPC Router Procedure (packages/api/src/router/content.router.ts)

Add to the existing content router:

import { createCommentController } from "@repo/core";

// Inside contentRouter:
createComment: publicProcedure
  .input(
    z.object({
      articleId: z.string(),
      authorId: z.string(),
      body: z.string().min(1).max(2000),
    })
  )
  .mutation(async ({ input }) => {
    return await createCommentController(input);
  }),

Step 9: Use from UI (apps/web-next)

"use client";
import { useTRPC } from "@repo/api-client";
import { useMutation } from "@tanstack/react-query";

export function AddCommentForm({ articleId }: { articleId: string }) {
  const trpc = useTRPC();
  const mutation = trpc.content.createComment.useMutation();

  const handleSubmit = (body: string) => {
    mutation.mutate({ articleId, authorId: "current-user-id", body });
  };

  // ... render form using @repo/ui components
}

Step 10: Add Payload Collection (optional -- packages/cms-core)

If the data is CMS-managed, create a collection. See "How to Add a Payload Collection" below.


How to Add a UI Component

Classification Guide

Components live in packages/ui/src/ organized by Atomic Design:

Level Directory Description Examples
Atoms src/atoms/{name}/ Smallest building blocks. Single HTML element wrappers. No composition of other atoms. Button, Input, Label
Molecules src/molecules/{name}/ Combine 2+ atoms into a reusable unit. FormField (Label + Input + error text)
Organisms src/organisms/{name}/ Complex UI sections combining molecules/atoms. May contain local state. LoginForm, ArticleCard
Templates src/templates/{name}/ Page-level layout structures with slots for organisms/molecules. No data fetching. DashboardLayout, AuthLayout

File Structure for Each Component

src/atoms/my-component/
  my-component.tsx          # Component implementation
  my-component.stories.tsx  # Storybook story
  index.ts                  # Barrel export

Import Rules

From Can Import NEVER Import
Atoms lib/utils only Other atoms, molecules, organisms, templates
Molecules Atoms, lib/utils Other molecules, organisms, templates
Organisms Atoms, Molecules, lib/utils Other organisms, templates
Templates Atoms, Molecules, Organisms, lib/utils Other templates
Apps Any @repo/ui export Internal @repo/ui paths (always use package export)

Export Chain

  1. Export from src/atoms/{name}/index.ts
  2. Re-export from src/atoms/index.ts
  3. Everything flows through src/index.ts which re-exports all levels

Component Pattern

Use cn() from lib/utils for className merging. Use forwardRef for atoms wrapping native elements:

import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils";

export const MyButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
  ({ className, ...props }, ref) => (
    <button className={cn("base-classes", className)} ref={ref} {...props} />
  )
);
MyButton.displayName = "MyButton";

How to Add a Payload Collection

Step 1: Create the collection (packages/cms-core/src/collections/{name}/index.ts)

import type { CollectionConfig } from "payload";
import { myFields } from "./fields";

export const MyCollection: CollectionConfig = {
  slug: "my-collection",
  admin: {
    useAsTitle: "title",
  },
  fields: myFields,
};

Step 2: Define fields (packages/cms-core/src/collections/{name}/fields.ts)

import type { Field } from "payload";

export const myFields: Field[] = [
  { name: "title", type: "text", required: true },
  { name: "content", type: "richText" },
];

Step 3: Register in payload.config.ts

import { MyCollection } from "./collections/my-collection";

export default buildConfig({
  collections: [Users, Articles, Media, MyCollection], // add here
  // ...
});

Step 4: Export from index.ts

export { MyCollection } from "./collections/my-collection";

Hook Rules

  • Hooks live in collections/{name}/hooks/ with descriptive names like before-change.ts
  • Hook types: beforeChange, afterChange, beforeRead, afterRead, beforeDelete, afterDelete, beforeValidate, afterValidate
  • Hooks receive ({ data, operation, req }) and must return data (for before hooks)
  • Hooks MUST NOT import from @repo/core. The CMS is independent. If you need to sync with core business logic, use the @repo/cms-client bridge in the infrastructure layer, not hooks calling core directly.
  • Example hook:
import type { CollectionBeforeChangeHook } from "payload";

export const myBeforeChangeHook: CollectionBeforeChangeHook = ({ data, operation }) => {
  if (operation === "create" && data && !data.slug) {
    data.slug = data.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
  }
  return data;
};

Key Commands

pnpm install              # Install all dependencies
pnpm dev                  # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
pnpm build                # Build all packages (via Turborepo)
pnpm test                 # Run all tests (via Turborepo)
pnpm typecheck            # Type-check all packages
pnpm lint                 # Lint all packages
pnpm format               # Format all files with Prettier
pnpm format:check         # Check formatting without writing
docker compose up -d      # Start PostgreSQL (required for CMS)

# Filtered commands
pnpm dev --filter @repo/web-next       # Only start Next.js app
pnpm test --filter @repo/core          # Only test core package
pnpm dev --filter @repo/storybook      # Only start Storybook

# Core package direct commands
cd packages/core && pnpm vitest run    # Run core unit tests
cd packages/core && pnpm vitest --ui   # Run tests with UI

Cross-References

Each package and key directory has its own AGENTS.md with domain-specific rules and recipes:

  • packages/core/AGENTS.md -- Clean Architecture layers, import rules, DI resolution
  • packages/core/src/entities/AGENTS.md -- Entity models and errors
  • packages/core/src/application/AGENTS.md -- Use cases and interfaces
  • packages/core/src/infrastructure/AGENTS.md -- Concrete implementations
  • packages/core/src/interface-adapters/controllers/AGENTS.md -- Controllers
  • packages/core/src/di/AGENTS.md -- InversifyJS container configuration
  • packages/core/src/application/use-cases/auth/AGENTS.md -- Auth domain rules
  • packages/core/src/application/use-cases/content/AGENTS.md -- Content domain rules