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
- Export from
src/atoms/{name}/index.ts - Re-export from
src/atoms/index.ts - Everything flows through
src/index.tswhich 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 likebefore-change.ts - Hook types:
beforeChange,afterChange,beforeRead,afterRead,beforeDelete,afterDelete,beforeValidate,afterValidate - Hooks receive
({ data, operation, req })and must returndata(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-clientbridge 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 resolutionpackages/core/src/entities/AGENTS.md-- Entity models and errorspackages/core/src/application/AGENTS.md-- Use cases and interfacespackages/core/src/infrastructure/AGENTS.md-- Concrete implementationspackages/core/src/interface-adapters/controllers/AGENTS.md-- Controllerspackages/core/src/di/AGENTS.md-- InversifyJS container configurationpackages/core/src/application/use-cases/auth/AGENTS.md-- Auth domain rulespackages/core/src/application/use-cases/content/AGENTS.md-- Content domain rules