# 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: ```typescript // 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) ```typescript 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; ``` Export it from `packages/core/src/entities/models/index.ts`: ```typescript 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) ```typescript import type { Comment } from "@/entities/models/comment"; export interface ICommentsRepository { getComment(id: string): Promise; getComments(options?: { articleId?: string; limit?: number; offset?: number; }): Promise; createComment(input: Comment): Promise; } ``` Export from `packages/core/src/application/repositories/index.ts`: ```typescript export type { ICommentsRepository } from "./comments.repository.interface"; ``` ### Step 3: Create Mock Implementation (packages/core/src/infrastructure/repositories/mock-comments.repository.ts) ```typescript 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 { return this._comments.find((c) => c.id === id); } async getComments(options?: { articleId?: string; limit?: number; offset?: number; }): Promise { 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 { 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`:** ```typescript 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`:** ```typescript 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(DI_SYMBOLS.ICommentsRepository).to( MockCommentsRepository ); }; export const CommentsModule = new ContainerModule(initializeModule); ``` **4c. Load module in `container.ts`:** ```typescript 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) ```typescript import type { Comment } from "@/entities/models/comment"; import { getInjection } from "@/di/container"; export async function createCommentUseCase(input: { articleId: string; authorId: string; body: string; }): Promise { 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) ```typescript 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> ): Promise { 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) ```typescript 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: ```typescript 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) ```typescript "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: ```typescript import { forwardRef, type ButtonHTMLAttributes } from "react"; import { cn } from "../../lib/utils"; export const MyButton = forwardRef>( ({ className, ...props }, ref) => (