docs: extend all 18 AGENTS.md files with comprehensive context, code examples, and recipes

This commit is contained in:
2026-04-06 15:31:03 +02:00
parent d06b900e7c
commit 0bc3b02f70
19 changed files with 4604 additions and 374 deletions

594
AGENTS.md
View File

@@ -1,71 +1,555 @@
# Agent Instructions
# 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 | Purpose |
|---|---|
| `@repo/core` | Clean architecture: entities, use cases, interfaces, DI |
| `@repo/api` | tRPC router definitions (calls core controllers) |
| `@repo/api-client` | Shared React Query hooks + ApiProvider |
| `@repo/cms-core` | Payload CMS config, collections, hooks, globals |
| `@repo/cms-client` | Dual-mode Payload client (local + HTTP) |
| `@repo/ui` | shadcn/ui + Atomic Design component library |
| `@repo/eslint-config` | Shared ESLint flat configs |
| `@repo/typescript-config` | Shared TypeScript configs |
| 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` |
| App | Purpose |
|---|---|
| `apps/web-next` | Next.js 15 reference app |
| `apps/web-tanstack` | TanStack Start reference app |
| `apps/cms` | Thin Next.js shell for Payload admin |
| `apps/storybook` | Centralized Storybook instance |
---
## Dependency Flow (one direction only)
## Dependency Flow Diagram
```
apps/web-next, apps/web-tanstack → @repo/api-client → @repo/api → @repo/core
apps/cms → @repo/cms-core → @repo/core/application (hooks only)
@repo/core/infrastructure → @repo/cms-client (standalone)
@repo/ui (standalone)
+-----------------+ +-----------------+
| 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)
```
## HARD RULES — NEVER VIOLATE
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.
- **NEVER:** packages/core → apps/*
- **NEVER:** apps/cms → packages/core/infrastructure
- **NEVER:** packages/cms-client → apps/cms or packages/core or packages/cms-core
- **NEVER:** packages/cms-core → packages/cms-client
- **NEVER:** core/entities → anything
- **NEVER:** core/application → core/infrastructure
---
## How to Add a New Feature (end-to-end)
## Complete Data Flow
1. Define entity in `packages/core/src/entities/models/`
2. Define repository interface in `packages/core/src/application/repositories/`
3. Write use case in `packages/core/src/application/use-cases/{domain}/`
4. Write controller in `packages/core/src/interface-adapters/controllers/{domain}/`
5. Write infrastructure implementation in `packages/core/src/infrastructure/`
6. Register in DI container (`packages/core/src/di/`)
7. Add tRPC router in `packages/api/src/router/`
8. Add React Query hook in `packages/api-client/src/hooks/`
9. If CMS collection needed: add to `packages/cms-core/src/collections/`
10. Build UI component in `packages/ui/src/` (classify atomic level)
11. Write Storybook story (co-located `.stories.tsx`)
12. Write tests (unit in packages/core/tests/, E2E in tests/e2e/)
Every user interaction follows this path:
## How to Add a New UI Component
```
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)
```
1. Classify: atom (single element), molecule (2-3 atoms), organism (complex section), template (layout)
2. Create folder: `packages/ui/src/{level}/{component-name}/`
3. Create: `{name}.tsx`, `{name}.stories.tsx`, `index.ts`
4. Story title: `"{Level}/{ComponentName}"` (e.g., `"Atoms/Button"`)
5. Export from level's `index.ts` barrel file
6. Import rules: atoms ← nothing | molecules ← atoms | organisms ← atoms+molecules | templates ← all
Example -- listing articles end-to-end:
## How to Add a Payload CMS Collection
```typescript
// 1. UI: apps/web-next -- a React Server Component or client component
const trpc = useTRPC();
const articles = trpc.content.listArticles.useQuery({ status: "published" });
1. Create folder: `packages/cms-core/src/collections/{name}/`
2. Create: `index.ts` (CollectionConfig), `fields.ts`, optionally `hooks/`, `access/`
3. Import in `packages/cms-core/src/payload.config.ts` collections array
4. Export from `packages/cms-core/src/index.ts`
5. Hooks that contain business logic must delegate to use cases in `@repo/core/application`
// 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<typeof commentSchema>;
```
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<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`:
```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<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`:**
```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<ICommentsRepository>(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<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)
```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<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)
```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<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)
```typescript
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)
```typescript
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
```typescript
import { MyCollection } from "./collections/my-collection";
export default buildConfig({
collections: [Users, Articles, Media, MyCollection], // add here
// ...
});
```
### Step 4: Export from index.ts
```typescript
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:
```typescript
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
```bash
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

View File

@@ -1,18 +1,108 @@
# apps/cms Payload CMS Admin Shell
# apps/cms -- Payload CMS Admin Shell
Thin Next.js shell that imports config from `@repo/cms-core` and serves the Payload admin panel. Contains almost no custom code.
## Purpose
## Rules
**THIN SHELL ONLY** -- this app exists solely to serve the Payload CMS admin panel via Next.js. All CMS logic (collections, globals, hooks, access control, `payload.config.ts`) lives in `@repo/cms-core`. This app contains no custom CMS code beyond Next.js routing boilerplate that Payload generates automatically.
- All collections, hooks, globals, and payload.config.ts live in `@repo/cms-core`
- This app only contains Next.js routing boilerplate for the admin panel
- Import `@payload-config` which resolves to `@repo/cms-core/src/payload.config.ts`
- NEVER import from `@repo/core/infrastructure`
## Development
## Port: 3001
```bash
pnpm dev --filter @repo/cms # Starts on port 3001
pnpm dev --filter @repo/cms # http://localhost:3001/admin
```
Requires PostgreSQL running (via `docker compose up postgres`).
Requires PostgreSQL running first:
```bash
docker compose up -d postgres # Starts PostgreSQL on port 5432
```
## Hard Rules
- **NEVER** add collections, globals, hooks, or access control in this app -- put them in `@repo/cms-core`
- **NEVER** import from `@repo/core/infrastructure`
- **NEVER** modify auto-generated files (marked with "THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD")
- All CMS configuration changes go in `packages/cms-core/`
## Key Files
| File | Purpose |
|---|---|
| `next.config.mjs` | Next.js config wrapped with `withPayload()` from `@payloadcms/next` |
| `tsconfig.json` | TypeScript config with `@payload-config` path alias |
| `src/app/(payload)/layout.tsx` | Auto-generated Payload root layout (DO NOT MODIFY) |
| `src/app/(payload)/admin/[[...segments]]/page.tsx` | Auto-generated catch-all admin page (DO NOT MODIFY) |
| `src/app/(payload)/admin/[[...segments]]/not-found.tsx` | Auto-generated 404 page (DO NOT MODIFY) |
| `src/app/(payload)/importMap.js` | Auto-generated Payload import map (DO NOT MODIFY) |
| `src/app/(payload)/custom.scss` | Custom SCSS overrides for admin panel styling |
## @payload-config Alias
The `tsconfig.json` defines a path alias that points to the config in `@repo/cms-core`:
```json
{
"compilerOptions": {
"paths": {
"@payload-config": [
"../../packages/cms-core/src/payload.config.ts"
]
}
}
}
```
When Payload or auto-generated files import `@payload-config`, it resolves to `packages/cms-core/src/payload.config.ts`. This is how the thin shell delegates all configuration to `@repo/cms-core`.
## next.config.mjs
The Next.js config is minimal -- just the `withPayload` wrapper:
```javascript
import { withPayload } from "@payloadcms/next/withPayload";
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default withPayload(nextConfig);
```
`withPayload()` adds the necessary webpack aliases, module resolution, and middleware for Payload to work within Next.js.
## Auto-Generated Files
The files under `src/app/(payload)/` are generated by Payload and should NOT be manually edited:
- **`layout.tsx`** -- Wraps the admin panel with `RootLayout` from `@payloadcms/next/layouts`, injects config and importMap
- **`admin/[[...segments]]/page.tsx`** -- Catch-all route that renders `RootPage` from `@payloadcms/next/views`
- **`admin/[[...segments]]/not-found.tsx`** -- 404 handler using `NotFoundPage` from `@payloadcms/next/views`
- **`importMap.js`** -- Maps Payload component paths for the admin UI
If you need to regenerate these files, Payload will do so automatically during dev/build.
## Type Generation
To regenerate Payload TypeScript types after changing collections/globals:
```bash
cd apps/cms && pnpm generate:types
# Equivalent to: payload generate:types
# Output goes to: packages/cms-core/src/payload-types.ts
```
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/cms-core` | All Payload configuration (collections, globals, hooks, config) |
| `@payloadcms/next` | Next.js integration for Payload (withPayload, admin UI views) |
| `@payloadcms/ui` | Payload admin panel React components |
| `payload` | Payload CMS core |
| `next` | Next.js 15 framework |
| `react` / `react-dom` | React 19 runtime |
| `sharp` | Image processing for Payload uploads |
## Cross-References
- **ALL CMS configuration:** `packages/cms-core/` -- see `packages/cms-core/AGENTS.md`
- **CMS client for querying data:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`
- **Core business logic:** `packages/core/` -- see `packages/core/AGENTS.md`

View File

@@ -1,24 +1,135 @@
# apps/storybook Centralized Storybook
# apps/storybook -- Centralized Storybook
Pulls stories from `packages/ui/src/**/*.stories.tsx`. Uses `@storybook/react-vite` with `@tailwindcss/vite` plugin.
## Purpose
## Development
Centralized Storybook instance that pulls and renders all stories from `packages/ui`. Provides a visual development environment, component documentation, and MCP integration for AI agents.
## Port: 6006
```bash
pnpm dev --filter @repo/storybook # Starts on port 6006
pnpm dev --filter @repo/storybook # http://localhost:6006
```
## Configuration
- `.storybook/main.ts` — Framework config, story globs, Tailwind vite plugin
- `.storybook/preview.ts` — Global CSS import, control matchers
### `.storybook/main.ts`
```typescript
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
framework: "@storybook/react-vite",
stories: ["../../../packages/ui/src/**/*.stories.@(ts|tsx)"],
addons: ["@storybook/addon-essentials"],
docs: {
autodocs: "tag",
},
async viteFinal(config) {
const { mergeConfig } = await import("vite");
const tailwindPlugin = await import("@tailwindcss/vite");
return mergeConfig(config, {
plugins: [tailwindPlugin.default()],
});
},
};
```
Key configuration details:
- **`stories`** glob reaches into `packages/ui/src/` to find all `.stories.tsx` files
- **`viteFinal`** adds the `@tailwindcss/vite` plugin so Tailwind v4 classes render correctly in stories
- **`autodocs: "tag"`** generates documentation pages for stories tagged with `"autodocs"`
- **`@storybook/addon-essentials`** includes Controls, Actions, Backgrounds, Viewport, Docs
### `.storybook/preview.ts`
```typescript
import type { Preview } from "@storybook/react";
import "../../../packages/ui/src/styles/globals.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
};
```
Key details:
- Imports `globals.css` from `@repo/ui` so all Tailwind v4 `@theme` tokens are available
- Control matchers auto-detect color and date props for appropriate editor widgets
## Story Organization
Stories are organized by Atomic Design level via title:
- `"Atoms/Button"`, `"Molecules/FormField"`, `"Organisms/DataTable"`
Stories are organized by Atomic Design level via the `title` field in story metadata. The title determines the sidebar hierarchy in Storybook.
## MCP
### Story Title Convention
When running, Storybook MCP is available at `http://localhost:6006/mcp`.
Install `@storybook/addon-mcp` to enable.
| Level | Title format | Example | Sidebar path |
|---|---|---|---|
| Atom | `"Atoms/{ComponentName}"` | `"Atoms/Button"` | Atoms > Button |
| Molecule | `"Molecules/{ComponentName}"` | `"Molecules/FormField"` | Molecules > FormField |
| Organism | `"Organisms/{ComponentName}"` | `"Organisms/DataTable"` | Organisms > DataTable |
| Template | `"Templates/{ComponentName}"` | `"Templates/DashboardLayout"` | Templates > DashboardLayout |
### Existing Stories
| Story title | Component | Location in `@repo/ui` |
|---|---|---|
| `Atoms/Button` | Button (5 variants: Default, Secondary, Destructive, Outline, Ghost) | `src/atoms/button/button.stories.tsx` |
| `Atoms/Input` | Input (Default, Disabled) | `src/atoms/input/input.stories.tsx` |
| `Molecules/FormField` | FormField (Default, WithDescription, WithError) | `src/molecules/form-field/form-field.stories.tsx` |
## MCP Integration
When Storybook is running, the MCP (Model Context Protocol) endpoint is available at:
```
http://localhost:6006/mcp
```
### Available MCP tools:
- **`list-all-documentation`** -- Lists all documented components and their stories
- **`get-documentation`** -- Gets detailed documentation for a specific component (props, variants, usage)
- **`run-story-tests`** -- Runs visual tests on stories to validate rendering
### Installing addon-mcp
If `@storybook/addon-mcp` is not already installed:
```bash
npx storybook add @storybook/addon-mcp
```
This adds the addon to `.storybook/main.ts` and enables the MCP endpoint.
### Using MCP in workflows
Always query MCP before creating new UI components:
1. **Before creating:** `list-all-documentation` to check if a similar component exists
2. **Before extending:** `get-documentation` to understand existing props and variants
3. **After creating:** `run-story-tests` to validate the new story renders correctly
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/ui` | Source of all component stories |
| `@storybook/react-vite` | Storybook framework using Vite bundler |
| `@storybook/addon-essentials` | Controls, Actions, Docs, Backgrounds, Viewport |
| `@tailwindcss/vite` | Vite plugin for Tailwind CSS v4 |
| `storybook` | Storybook core CLI and dev server |
| `tailwindcss` | Tailwind CSS v4 engine |
| `vite` | Build tool / dev server |
| `react` / `react-dom` | React 19 runtime |
## Cross-References
- **Component source:** `packages/ui/` -- see `packages/ui/AGENTS.md`
- **Tailwind tokens:** `packages/ui/src/styles/globals.css`

View File

@@ -1,16 +1,203 @@
# apps/web-next Next.js 15 Reference App
# apps/web-next -- Next.js 15 Reference App
Thin Next.js app consuming `@repo/api-client` for data and `@repo/ui` for components.
## Purpose
Next.js 15 reference application using App Router. Demonstrates how to consume `@repo/api-client` for tRPC data fetching, `@repo/ui` for components, and `@repo/api` for the tRPC HTTP endpoint. This is a thin app -- business logic lives in `@repo/core`, UI components live in `@repo/ui`.
## Port: 3000
```bash
pnpm dev --filter @repo/web-next # http://localhost:3000
```
## Key Files
- `src/app/api/trpc/[trpc]/route.ts` — tRPC HTTP endpoint (fetch adapter)
- `src/app/providers.tsx` — Wraps with `<ApiProvider>`
- `src/app/layout.tsx` Root layout
| File | Purpose |
|---|---|
| `src/app/layout.tsx` | Root layout -- wraps children with `<Providers>`, sets HTML metadata |
| `src/app/providers.tsx` | Client component that wraps the app with `<ApiProvider trpcUrl="/api/trpc">` |
| `src/app/page.tsx` | Home page (server component by default) |
| `src/app/api/trpc/[trpc]/route.ts` | tRPC HTTP endpoint using the Next.js fetch adapter |
## Rules
## tRPC Endpoint Setup
- Use `@repo/api-client` hooks for all data fetching
- Use `@repo/ui` components for all UI
- tRPC endpoint imports `appRouter` from `@repo/api`
- Payload instance initialization goes in `src/lib/payload.ts`
The file `src/app/api/trpc/[trpc]/route.ts` creates a catch-all API route that handles all tRPC requests:
```typescript
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@repo/api";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };
```
How it works:
1. Next.js catch-all route `[trpc]` matches any path under `/api/trpc/`
2. `fetchRequestHandler` from tRPC's fetch adapter processes the request
3. `appRouter` from `@repo/api` contains all registered routers
4. `createContext` provides the context object to all procedures (currently empty `{}`)
5. Both GET (for queries) and POST (for mutations/batched queries) are exported
## Provider Setup
The `<ApiProvider>` from `@repo/api-client` must wrap the entire app. Since it uses React hooks, it lives in a `"use client"` component:
```tsx
// src/app/providers.tsx
"use client";
import { ApiProvider } from "@repo/api-client";
export function Providers({ children }: { children: React.ReactNode }) {
return <ApiProvider trpcUrl="/api/trpc">{children}</ApiProvider>;
}
```
```tsx
// src/app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
```
## Recipe: Adding a New Page with Data Fetching
This example adds an `/articles` page that lists published articles.
### Step 1: Create the page route
Create `src/app/articles/page.tsx`:
```tsx
import { ArticleList } from "./article-list";
export default function ArticlesPage() {
return (
<main>
<h1>Articles</h1>
<ArticleList />
</main>
);
}
```
### Step 2: Create the client component with data fetching
Create `src/app/articles/article-list.tsx`:
```tsx
"use client";
import { useTRPC } from "@repo/api-client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@repo/ui";
export function ArticleList() {
const trpc = useTRPC();
const { data, isLoading, error } = useQuery(
trpc.content.listArticles.queryOptions({ status: "published", limit: 20 })
);
if (isLoading) return <p>Loading articles...</p>;
if (error) return <p>Error loading articles: {error.message}</p>;
return (
<ul>
{data?.map((article) => (
<li key={article.id}>
<h2>{article.title}</h2>
<Button variant="outline" size="sm">
Read more
</Button>
</li>
))}
</ul>
);
}
```
Key patterns:
- The page component (`page.tsx`) is a server component by default -- no `"use client"` needed
- Data-fetching components that use `useTRPC()` must be client components (`"use client"`)
- Import UI components from `@repo/ui`, never recreate them locally
## Payload Initialization Pattern (Server-Side Local API)
For server-side access to Payload CMS data (e.g., in server components, API routes, or server actions), create a Payload client initializer:
```typescript
// src/lib/payload.ts
import { getPayload } from "payload";
import config from "@repo/cms-core/src/payload.config";
import { createPayloadClient, type PayloadClient } from "@repo/cms-client";
let cachedClient: PayloadClient | null = null;
export async function getPayloadClient(): Promise<PayloadClient> {
if (cachedClient) return cachedClient;
const payload = await getPayload({ config });
cachedClient = createPayloadClient({ mode: "local", payload });
return cachedClient;
}
```
Usage in a server component:
```tsx
// src/app/articles/page.tsx (server component)
import { getPayloadClient } from "@/lib/payload";
export default async function ArticlesPage() {
const client = await getPayloadClient();
const result = await client.find("articles", {
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 20,
});
return (
<main>
<h1>Articles</h1>
<ul>
{result.docs.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
</main>
);
}
```
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/api` | `appRouter` for the tRPC HTTP endpoint |
| `@repo/api-client` | `ApiProvider` + `useTRPC()` for client-side data fetching |
| `@repo/ui` | Shared UI components (Button, Input, Label, FormField, etc.) |
| `next` | Next.js 15 framework with App Router |
| `react` / `react-dom` | React 19 runtime |
## Cross-References
- **tRPC routers:** `packages/api/` -- see `packages/api/AGENTS.md`
- **tRPC client/hooks:** `packages/api-client/` -- see `packages/api-client/AGENTS.md`
- **UI components:** `packages/ui/` -- see `packages/ui/AGENTS.md`
- **CMS client:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`
- **CMS config:** `packages/cms-core/` -- see `packages/cms-core/AGENTS.md`

View File

@@ -1,15 +1,158 @@
# apps/web-tanstack TanStack Start Reference App
# apps/web-tanstack -- TanStack Start Reference App
TanStack Start app consuming `@repo/api-client` for data and `@repo/ui` for components.
## Purpose
TanStack Start reference application using TanStack Router with file-based routing. Demonstrates how to consume `@repo/api-client` for tRPC data fetching and `@repo/ui` for components. Like `apps/web-next`, this is a thin app -- business logic lives in `@repo/core`, UI components live in `@repo/ui`.
## Port: 3002
```bash
pnpm dev --filter @repo/web-tanstack # http://localhost:3002
```
## Key Files
- `src/routes/__root.tsx` — Root layout with `<ApiProvider>`
- `src/routes/index.tsx` — Home page
| File | Purpose |
|---|---|
| `src/routes/__root.tsx` | Root layout -- creates the root route, wraps with `<ApiProvider>` and `<Outlet>` |
| `src/routes/index.tsx` | Home page route (`/`) |
## Rules
## File-Based Routing
- Use `@repo/api-client` hooks for all data fetching
- Use `@repo/ui` components for all UI
- File-based routing via TanStack Router (`src/routes/`)
- Payload instance initialization goes in `src/lib/payload.ts`
TanStack Router uses file-based routing where file paths in `src/routes/` map directly to URL paths:
| File | URL | Description |
|---|---|---|
| `src/routes/__root.tsx` | (all routes) | Root layout, wraps all child routes |
| `src/routes/index.tsx` | `/` | Home page |
| `src/routes/about.tsx` | `/about` | Static page |
| `src/routes/articles/index.tsx` | `/articles` | Article listing |
| `src/routes/articles/$id.tsx` | `/articles/:id` | Single article (dynamic param) |
### Naming conventions:
- `__root.tsx` -- special root layout file, always wraps all routes
- `index.tsx` -- index route for its directory (e.g., `/articles/index.tsx` matches `/articles`)
- `$paramName.tsx` -- dynamic route segment (e.g., `$id.tsx` captures `:id`)
- Nested folders create nested URL segments
## Provider Setup
The `<ApiProvider>` wraps the entire app in `__root.tsx`:
```tsx
// src/routes/__root.tsx
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { ApiProvider } from "@repo/api-client";
export const Route = createRootRoute({
component: () => (
<ApiProvider trpcUrl="http://localhost:3000/api/trpc">
<Outlet />
</ApiProvider>
),
});
```
Note: The `trpcUrl` points to the Next.js app's tRPC endpoint at `http://localhost:3000/api/trpc`. In production, this should be configured via environment variables.
## Recipe: Adding a New Route with Data Fetching
This example adds an `/articles` route that lists published articles.
### Step 1: Create the route file
Create `src/routes/articles/index.tsx`:
```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useTRPC } from "@repo/api-client";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@repo/ui";
export const Route = createFileRoute("/articles/")({
component: ArticlesPage,
});
function ArticlesPage() {
const trpc = useTRPC();
const { data, isLoading, error } = useQuery(
trpc.content.listArticles.queryOptions({ status: "published", limit: 20 })
);
if (isLoading) return <p>Loading articles...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<main>
<h1>Articles</h1>
<ul>
{data?.map((article) => (
<li key={article.id}>
<h2>{article.title}</h2>
<Button variant="outline" size="sm">
Read more
</Button>
</li>
))}
</ul>
</main>
);
}
```
### Step 2: Add a dynamic route for individual articles
Create `src/routes/articles/$id.tsx`:
```tsx
import { createFileRoute } from "@tanstack/react-router";
import { useTRPC } from "@repo/api-client";
import { useQuery } from "@tanstack/react-query";
export const Route = createFileRoute("/articles/$id")({
component: ArticlePage,
});
function ArticlePage() {
const { id } = Route.useParams();
const trpc = useTRPC();
// Use the article ID from the URL parameter
// (Assuming a getArticle procedure exists on the content router)
const { data, isLoading } = useQuery(
trpc.content.listArticles.queryOptions({ limit: 1 })
);
if (isLoading) return <p>Loading...</p>;
return (
<main>
<h1>Article {id}</h1>
</main>
);
}
```
Key patterns:
- Every route file exports a `Route` created via `createFileRoute(path)(...)`
- The `component` property defines the React component for that route
- Use `Route.useParams()` to access dynamic parameters (e.g., `$id`)
- Data fetching uses the same `useTRPC()` + `useQuery()` pattern as Next.js
- Import UI components from `@repo/ui`, never recreate them locally
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/api` | `AppRouter` type (transitive via `@repo/api-client`) |
| `@repo/api-client` | `ApiProvider` + `useTRPC()` for client-side data fetching |
| `@repo/ui` | Shared UI components |
| `@tanstack/react-router` | TanStack Router for file-based routing |
| `react` / `react-dom` | React 19 runtime |
## Cross-References
- **tRPC routers:** `packages/api/` -- see `packages/api/AGENTS.md`
- **tRPC client/hooks:** `packages/api-client/` -- see `packages/api-client/AGENTS.md`
- **UI components:** `packages/ui/` -- see `packages/ui/AGENTS.md`
- **Next.js app (serves the tRPC endpoint):** `apps/web-next/` -- see `apps/web-next/AGENTS.md`

View File

@@ -1,27 +1,166 @@
# @repo/api-client — Shared React Query Hooks
# @repo/api-client -- Framework-Agnostic tRPC + React Query Provider
Framework-agnostic tRPC + React Query provider consumed by all apps.
## Purpose
## Rules
This package provides a framework-agnostic tRPC client and React Query provider that any frontend app (Next.js, TanStack Start, or future frameworks) can consume. It exposes `<ApiProvider>` for initialization and `useTRPC()` for fully typed data fetching. It contains zero business logic.
- NEVER import framework-specific code (no next/, no tanstack/)
- NEVER put business logic in hooks
- Hooks use `useTRPC()` from `./trpc.ts`
- Both Next.js and TanStack Start apps use the same `<ApiProvider>`
## Hard Rules
## Usage in Apps
- **NEVER** import framework-specific code (no `next/`, no `@tanstack/start`, no `vinxi/`)
- **NEVER** put business logic in this package
- **NEVER** create custom hooks that duplicate what `useTRPC()` already provides
- Both Next.js and TanStack Start apps use the same `<ApiProvider>` and `useTRPC()`
- This package depends on `@repo/api` for the `AppRouter` type only (no runtime import)
```tsx
import { ApiProvider, useTRPC } from "@repo/api-client";
## File Structure
// Root layout:
<ApiProvider trpcUrl="/api/trpc">{children}</ApiProvider>
// In components:
const trpc = useTRPC();
const articles = trpc.content.listArticles.useQuery({});
```
packages/api-client/
src/
trpc.ts # Creates TRPCProvider + useTRPC via createTRPCContext<AppRouter>()
query-client.ts # Singleton QueryClient factory (SSR-safe)
provider.tsx # <ApiProvider> component: wires tRPC client + QueryClientProvider
index.ts # Package entry: re-exports ApiProvider, useTRPC, getQueryClient
package.json
AGENTS.md
```
## Adding a New Hook (optional wrapper)
## How Apps Consume This Package
Custom hooks are optional — `useTRPC()` provides typed access to all procedures directly. Only create wrapper hooks if you need shared query logic across multiple components.
### Step 1: Wrap your app with `<ApiProvider>`
The provider needs a `trpcUrl` pointing to the tRPC HTTP endpoint:
```tsx
// apps/web-next/src/app/providers.tsx
"use client";
import { ApiProvider } from "@repo/api-client";
export function Providers({ children }: { children: React.ReactNode }) {
return <ApiProvider trpcUrl="/api/trpc">{children}</ApiProvider>;
}
```
```tsx
// apps/web-next/src/app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
```
For TanStack Start, the provider goes in the root route:
```tsx
// apps/web-tanstack/src/routes/__root.tsx
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { ApiProvider } from "@repo/api-client";
export const Route = createRootRoute({
component: () => (
<ApiProvider trpcUrl="http://localhost:3000/api/trpc">
<Outlet />
</ApiProvider>
),
});
```
### Step 2: Use `useTRPC()` in components
```tsx
"use client";
import { useTRPC } from "@repo/api-client";
import { useQuery, useMutation } from "@tanstack/react-query";
export function ArticleList() {
const trpc = useTRPC();
// Query -- reads data
const { data, isLoading, error } = useQuery(
trpc.content.listArticles.queryOptions({ status: "published", limit: 10 })
);
// Mutation -- writes data
const createArticle = useMutation(
trpc.content.createArticle.mutationOptions()
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<ul>
{data?.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
<button
onClick={() =>
createArticle.mutate({
title: "New Article",
content: "Hello world",
authorId: "user-1",
})
}
>
Create Article
</button>
</div>
);
}
```
## The `useTRPC()` Pattern
`useTRPC()` is created by `createTRPCContext<AppRouter>()` from `@trpc/tanstack-react-query`. It returns a proxy object that mirrors the router structure:
```
useTRPC()
.auth
.signIn.mutationOptions()
.signUp.mutationOptions()
.signOut.mutationOptions()
.content
.listArticles.queryOptions({ ... })
.createArticle.mutationOptions()
```
You pass `.queryOptions()` to `useQuery()` and `.mutationOptions()` to `useMutation()` from `@tanstack/react-query`. This gives you full control over caching, refetching, optimistic updates, and all React Query features.
## Custom Hook Wrappers Are Optional
Since `useTRPC()` gives fully typed access to every procedure, you do **not** need to create wrapper hooks like `useArticles()`. Only create a custom hook if you have shared logic (e.g., combining multiple queries, adding retry logic, or transforming results) that would otherwise be duplicated across multiple components.
## QueryClient Configuration
The `getQueryClient()` factory in `query-client.ts` handles SSR correctly:
- **Server-side:** Creates a new `QueryClient` per request (avoids cross-request data leaks)
- **Client-side:** Returns a singleton `QueryClient` (reused across renders)
- Default `staleTime` is 30 seconds
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/api` | `AppRouter` type for end-to-end type safety (type-only import) |
| `@trpc/client` | tRPC client with `httpBatchLink` |
| `@trpc/tanstack-react-query` | `createTRPCContext` for React Query integration |
| `@tanstack/react-query` | `QueryClient`, `QueryClientProvider` |
| `react` | JSX runtime for provider component |
## Cross-References
- **Router types come from:** `packages/api/` -- see `packages/api/AGENTS.md`
- **tRPC HTTP endpoint:** `apps/web-next/src/app/api/trpc/[trpc]/route.ts`
- **Provider usage in Next.js:** `apps/web-next/src/app/providers.tsx`
- **Provider usage in TanStack:** `apps/web-tanstack/src/routes/__root.tsx`

View File

@@ -1,17 +1,144 @@
# @repo/api tRPC Router Definitions
# @repo/api -- tRPC v11 Router Definitions
tRPC routers that call controllers from `@repo/core`. Each procedure validates input and delegates to a controller.
## Purpose
## Rules
This package defines all tRPC v11 routers for the monorepo. Each router validates input with Zod and delegates execution to controllers in `@repo/core`. Routers are the **only** entry point for client-side RPC calls. They contain zero business logic.
- NEVER put business logic in routers — delegate to controllers
- Input validation uses Zod schemas
- Each domain gets its own router file
## Hard Rules
## Adding a New tRPC Router
- **NEVER** put business logic in routers -- always delegate to a controller from `@repo/core`
- **NEVER** import from `@repo/core/infrastructure` or any `apps/*` package
- Input validation uses Zod schemas inline on each procedure
- Each domain gets its own `{domain}.router.ts` file
- Use `.query()` for reads (GET-like), `.mutation()` for writes (POST/PUT/DELETE-like)
- All procedures call exactly one controller function from `@repo/core`
1. Create `src/router/{domain}.router.ts`
2. Import `router` and `publicProcedure` from `../trpc.js`
3. Define procedures (`.query()` for reads, `.mutation()` for writes)
4. Each procedure calls a controller from `@repo/core`
5. Add router to root `appRouter` in `src/router/index.ts`
## File Structure
```
packages/api/
src/
trpc.ts # tRPC initialization, exports router + publicProcedure
router/
index.ts # appRouter composition, exports AppRouter type
auth.router.ts # Auth domain: signIn, signUp, signOut
content.router.ts # Content domain: listArticles, createArticle
index.ts # Package entry: re-exports appRouter + AppRouter type
package.json
AGENTS.md
```
## How Procedures Map to Controllers
| Procedure type | HTTP equivalent | When to use | Example |
|---|---|---|---|
| `.query()` | GET | Fetching/reading data | `content.listArticles` |
| `.mutation()` | POST/PATCH/DELETE | Creating, updating, deleting data | `auth.signIn`, `content.createArticle` |
Every procedure follows the same pattern:
```typescript
myProcedure: publicProcedure
.input(z.object({ /* Zod schema */ }))
.query(async ({ input }) => { // or .mutation()
return await myController(input); // delegate to @repo/core controller
}),
```
## Existing Routers
### auth.router.ts
| Procedure | Type | Input | Controller |
|---|---|---|---|
| `signIn` | mutation | `{ username: string, password: string }` | `signInController` |
| `signUp` | mutation | `{ username: string, password: string, confirmPassword: string }` | `signUpController` |
| `signOut` | mutation | `{ sessionId: string }` | `signOutController` |
### content.router.ts
| Procedure | Type | Input | Controller |
|---|---|---|---|
| `listArticles` | query | `{ status?, authorId?, limit?, offset? }` (optional) | `getArticlesController` |
| `createArticle` | mutation | `{ title: string, content: string, authorId: string, slug?: string }` | `createArticleController` |
## Recipe: Adding a New tRPC Router
This example adds a `comments` domain router with `listComments` (query) and `createComment` (mutation).
### Step 1: Create the router file
Create `src/router/comments.router.ts`:
```typescript
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import {
getCommentsController,
createCommentController,
} from "@repo/core";
export const commentsRouter = router({
listComments: publicProcedure
.input(
z
.object({
articleId: z.string(),
limit: z.number().optional(),
offset: z.number().optional(),
})
)
.query(async ({ input }) => {
return await getCommentsController(input);
}),
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 2: Register in the appRouter
Edit `src/router/index.ts`:
```typescript
import { router } from "../trpc";
import { authRouter } from "./auth.router";
import { contentRouter } from "./content.router";
import { commentsRouter } from "./comments.router"; // <-- add import
export const appRouter = router({
auth: authRouter,
content: contentRouter,
comments: commentsRouter, // <-- register here
});
export type AppRouter = typeof appRouter;
```
### Step 3: Verify
The `AppRouter` type is automatically inferred. Any app using `@repo/api-client` will immediately see `trpc.comments.listComments.useQuery(...)` and `trpc.comments.createComment.useMutation(...)` with full type safety -- no code generation step needed.
## Dependencies
| Dependency | Purpose |
|---|---|
| `@repo/core` | Controllers that contain business logic |
| `@trpc/server` | tRPC v11 server-side primitives |
| `zod` | Runtime input validation schemas |
## Cross-References
- **Controllers live in:** `packages/core/src/interface-adapters/controllers/` -- see `packages/core/AGENTS.md`
- **Client consumption:** `packages/api-client/` -- see `packages/api-client/AGENTS.md`
- **HTTP endpoint:** `apps/web-next/src/app/api/trpc/[trpc]/route.ts` uses the fetch adapter to serve `appRouter`

View File

@@ -1,39 +1,217 @@
# @repo/cms-client Dual-Mode Payload Client
# @repo/cms-client -- Dual-Mode Payload Client
Provides typed access to Payload CMS via Local API (primary) or HTTP REST (fallback).
## Purpose
## THIS PACKAGE IS STANDALONE
Provides a typed, uniform interface for accessing Payload CMS data via either the Local API (direct in-process calls) or the HTTP REST API (network requests). The consuming app chooses the mode at startup and injects the Payload instance -- this package never imports it.
- NEVER import from: `@repo/cms-core`, `@repo/core`, `apps/*`
- The Payload instance is INJECTED, not imported
- Types are generated via `payload generate:types`
## **NEVER import from `@repo/cms-core`, `@repo/core`, or any `apps/*` package.**
## Initialization
This package is completely standalone. The Payload instance is INJECTED at app startup, never imported by this package.
| Context | Mode | How |
|---|---|---|
| apps/cms server | Local | `getPayload({config})` from @repo/cms-core |
| apps/web-next server | Local | `getPayload({config})` from @repo/cms-core |
| apps/web-tanstack server | Local | `getPayload({config})` from @repo/cms-core |
| Client-side (browser) | N/A | Goes through tRPC — server handles it |
| External services | HTTP | `createPayloadClient({mode:"http",baseURL})` |
## File Structure
```
packages/cms-client/
src/
types.ts # FindOptions, PayloadClientResult, PayloadClient interface
client.ts # createPayloadClient() factory -- returns Local or HTTP client
local-client.ts # LocalPayloadClient class -- wraps Payload Local API
http-client.ts # HTTPPayloadClient class -- wraps Payload REST API via fetch
index.ts # Package entry: re-exports factory, classes, and types
package.json
AGENTS.md
```
## Dual-Mode Initialization
### Local Mode (primary -- used for server-side code with direct DB access)
Local mode wraps the Payload Local API. It requires a live `Payload` instance, which is obtained via `getPayload()` in the consuming app:
```typescript
// In app startup (e.g., apps/web-next/src/lib/payload.ts):
// Example: apps/web-next/src/lib/payload.ts
import { getPayload } from "payload";
import { config } from "@repo/cms-core";
import config from "@repo/cms-core/src/payload.config";
import { createPayloadClient } from "@repo/cms-client";
const payload = await getPayload({ config });
const client = createPayloadClient({ mode: "local", payload });
// Now use client.find(), client.create(), etc.
const articles = await client.find("articles", {
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 10,
});
```
## Available Methods
### HTTP Mode (fallback -- used for external services without direct DB access)
All methods support full Payload query capabilities (where, sort, limit, depth, page, populate):
HTTP mode makes REST calls to the Payload API. It only needs the base URL:
- `find(collection, options)` — paginated query
- `findByID(collection, id, options)` — single document
- `create(collection, data, options)` — create document
- `update(collection, id, data, options)` — update document
- `delete(collection, id)` — delete document
```typescript
import { createPayloadClient } from "@repo/cms-client";
const client = createPayloadClient({
mode: "http",
baseURL: "http://localhost:3001",
});
// Same API surface as local mode
const articles = await client.find("articles", {
where: { status: { equals: "published" } },
limit: 10,
});
```
## Initialization Table
| App / Context | Mode | How initialized | Why this mode |
|---|---|---|---|
| `apps/cms` (server-side) | Local | `getPayload({ config })` from `@repo/cms-core` | Same process as Payload, direct DB access |
| `apps/web-next` (server components/actions) | Local | `getPayload({ config })` from `@repo/cms-core` | Server-side rendering needs fast DB access |
| `apps/web-tanstack` (server loaders) | Local | `getPayload({ config })` from `@repo/cms-core` | Server-side data loading needs fast DB access |
| Client-side (browser) | N/A | Does not use this package directly | Browser goes through tRPC, server handles CMS access |
| External services / microservices | HTTP | `createPayloadClient({ mode: "http", baseURL })` | No access to Payload instance, only REST API |
## PayloadClient API Reference
All methods are available on both Local and HTTP clients via the `PayloadClient` interface.
### `find<T>(collection, options?): Promise<PayloadClientResult<T>>`
Paginated query for documents in a collection.
```typescript
const result = await client.find<Article>("articles", {
where: { status: { equals: "published" } },
sort: "-publishedAt",
limit: 10,
page: 1,
depth: 2,
locale: "en",
});
// result.docs, result.totalDocs, result.totalPages, etc.
```
### `findByID<T>(collection, id, options?): Promise<T>`
Fetch a single document by ID.
```typescript
const article = await client.findByID<Article>("articles", "abc123", {
depth: 2,
});
```
### `create<T>(collection, data, options?): Promise<T>`
Create a new document.
```typescript
const newArticle = await client.create<Article>("articles", {
title: "My Article",
content: "...",
author: "user-id-123",
status: "draft",
}, { depth: 1 });
```
### `update<T>(collection, id, data, options?): Promise<T>`
Update an existing document (partial update).
```typescript
const updated = await client.update<Article>("articles", "abc123", {
status: "published",
publishedAt: new Date().toISOString(),
});
```
### `delete<T>(collection, id): Promise<T>`
Delete a document by ID.
```typescript
const deleted = await client.delete<Article>("articles", "abc123");
```
## FindOptions Interface
```typescript
interface FindOptions {
where?: Record<string, unknown>; // Payload query operators ({ field: { equals: value } })
sort?: string; // Field name, prefix with "-" for descending
limit?: number; // Max documents per page (default: 10)
page?: number; // Page number (1-based)
depth?: number; // Relationship population depth (default: 1)
locale?: string; // Locale for localized fields
}
```
## PayloadClientResult Interface
```typescript
interface PayloadClientResult<T> {
docs: T[]; // Array of documents for current page
totalDocs: number; // Total matching documents across all pages
limit: number; // Max docs per page (as requested)
totalPages: number; // Total number of pages
page: number; // Current page number (1-based)
pagingCounter: number; // Index of first doc on current page
hasPrevPage: boolean; // Whether a previous page exists
hasNextPage: boolean; // Whether a next page exists
prevPage: number | null; // Previous page number, or null
nextPage: number | null; // Next page number, or null
}
```
## App Startup Pattern
The Payload instance is always created in the consuming app, then injected into the client:
```typescript
// apps/web-next/src/lib/payload.ts
import { getPayload } from "payload";
import config from "@repo/cms-core/src/payload.config";
import { createPayloadClient, type PayloadClient } from "@repo/cms-client";
let cachedClient: PayloadClient | null = null;
export async function getPayloadClient(): Promise<PayloadClient> {
if (cachedClient) return cachedClient;
const payload = await getPayload({ config });
cachedClient = createPayloadClient({ mode: "local", payload });
return cachedClient;
}
```
This pattern ensures:
1. The Payload instance is created once and reused
2. The cms-client package never imports config or Payload itself
3. Each app controls its own initialization
## Type Generation
Payload generates TypeScript types from your collection/global definitions:
```bash
cd apps/cms && pnpm generate:types
# Runs: payload generate:types
# Outputs to: packages/cms-core/src/payload-types.ts (configured in payload.config.ts)
```
After adding or modifying collections/globals in `@repo/cms-core`, re-run type generation to keep types in sync.
## Dependencies
| Dependency | Purpose |
|---|---|
| `payload` | `Payload` type for the Local API client constructor (type-only at build time) |
## Cross-References
- **CMS configuration:** `packages/cms-core/` -- see `packages/cms-core/AGENTS.md`
- **CMS app (where getPayload is called):** `apps/cms/` -- see `apps/cms/AGENTS.md`
- **Core use cases (consumers of this client):** `packages/core/` -- see `packages/core/AGENTS.md`

View File

@@ -1,40 +1,323 @@
# @repo/cms-core Payload CMS Definition
# @repo/cms-core -- ALL Payload CMS Configuration
All Payload configuration lives here: payload.config.ts, collections, globals, hooks, access control. The `apps/cms` app is a thin shell that imports this config.
## Purpose
This package contains **all** Payload CMS configuration: `payload.config.ts`, collections, globals, hooks, and access control. The `apps/cms` application is a thin shell that imports from this package -- it contains no CMS logic of its own.
## Hard Rules
- **ALL** collections, globals, hooks, and access patterns live here, **never** in `apps/cms`
- Can import from `@repo/core/application` (for use case delegation in hooks)
- **NEVER** import from `@repo/core/infrastructure`
- **NEVER** import from any `apps/*` package
- Keep hooks thin (5-10 lines max) -- delegate to use cases for business logic
## File Structure
```
packages/cms-core/
src/
payload.config.ts # Main Payload config (db, editor, collections, globals)
collections/
users/
index.ts # Users CollectionConfig (auth: true)
articles/
index.ts # Articles CollectionConfig (versions, hooks)
fields.ts # Article field definitions
hooks/
before-change.ts # Auto-generate slug from title
media/
index.ts # Media CollectionConfig (upload)
globals/
site-settings.ts # SiteSettings GlobalConfig
index.ts # Package entry: exports all configs
package.json
AGENTS.md
```
## Existing Collections
### Users (auth collection)
- **Slug:** `users`
- **Auth:** `true` (provides login, password hashing, session management)
- **Fields:** `displayName` (text), `role` (select: admin/editor/author, default: author)
- **Admin title:** email
### Articles (content with versioning)
- **Slug:** `articles`
- **Versions:** `{ drafts: true }` -- enables draft/published workflow
- **Hooks:** `beforeChange: [autoGenerateSlug]` -- generates slug from title if slug is empty
- **Fields:** title (text, required), slug (text, unique, sidebar), content (richText), status (select: draft/published, default: draft), author (relationship to users, required), featuredImage (upload to media), publishedAt (date)
- **Admin title:** title, default columns: title, status, author, updatedAt
### Media (file uploads)
- **Slug:** `media`
- **Upload:** Accepts `image/*` and `application/pdf`
- **Fields:** `alt` (text, required)
- **Admin title:** filename
## Existing Globals
### SiteSettings
- **Slug:** `site-settings`
- **Admin group:** Settings
- **Fields:** `siteName` (text, required, default: "My App"), `siteDescription` (textarea)
## Hook Rules
| Category | Location | Examples |
|---|---|---|
| CMS-operational | Stay in hook | Slugify, image resize, default values |
| Business logic | Delegate to use case | Notifications, validation, cross-domain updates |
| Category | Where it lives | Description | Examples |
|---|---|---|---|
| CMS-operational | Stays in the hook file | Logic tied to CMS data shaping, not business rules | Slug generation, image resizing, setting default values, formatting fields |
| Business logic | Delegated to `@repo/core` use case | Logic that enforces a business rule or triggers side effects | Sending notifications, validating against external data, cross-domain updates |
### DO
- Keep hooks thin (max 5-10 lines)
- Import use cases from `@repo/core/application`
- Map Payload hook args to use case input types
- Keep hooks to 5-10 lines max
- Import use cases from `@repo/core/application` (application layer only)
- Map Payload hook arguments (`data`, `operation`, `req`) to use case input types
- Return `data` from `beforeChange` / `beforeValidate` hooks
### DON'T
- Import from `@repo/core/infrastructure`
- Put business validation in hooks
- Call external services directly from hooks
- Duplicate logic that exists in a use case
- Import from `@repo/core/infrastructure` -- violates Clean Architecture
- Put business validation logic directly in hooks
- Call external services (email, analytics, APIs) directly from hooks
- Duplicate logic that already exists in a use case
- Access `req.payload` for cross-collection operations (delegate to use case instead)
## Adding a New Collection
**Rule of thumb:** If deleting the hook would break a business requirement, the logic must live in a use case in `@repo/core`.
1. Create folder: `src/collections/{name}/`
2. Create: `index.ts` (CollectionConfig), `fields.ts`
3. Optionally: `hooks/`, `access/`
4. Import in `src/payload.config.ts` collections array
5. Export from `src/index.ts`
## Recipe: Adding a New Collection
## Adding a Hook That Calls a Use Case
This example adds a `Tags` collection.
1. Create `src/collections/{name}/hooks/{hook-name}.ts`
2. Import use case from `@repo/core` (application layer only)
3. Map Payload's hook args to use case input
4. Call use case, return data
### Step 1: Create the collection folder and fields
**Rule of thumb:** If deleting the hook would break a business requirement, the logic must be in a use case.
Create `src/collections/tags/fields.ts`:
```typescript
import type { Field } from "payload";
export const tagFields: Field[] = [
{
name: "name",
type: "text",
required: true,
unique: true,
maxLength: 100,
},
{
name: "slug",
type: "text",
unique: true,
admin: {
position: "sidebar",
description: "Auto-generated from name if left empty",
},
},
{
name: "description",
type: "textarea",
},
];
```
### Step 2: Create the CollectionConfig
Create `src/collections/tags/index.ts`:
```typescript
import type { CollectionConfig } from "payload";
import { tagFields } from "./fields";
import { autoGenerateSlug } from "./hooks/before-change";
export const Tags: CollectionConfig = {
slug: "tags",
admin: {
useAsTitle: "name",
defaultColumns: ["name", "slug", "updatedAt"],
},
hooks: {
beforeChange: [autoGenerateSlug],
},
fields: tagFields,
};
```
### Step 3: Add a CMS-operational hook (slug generation)
Create `src/collections/tags/hooks/before-change.ts`:
```typescript
import type { CollectionBeforeChangeHook } from "payload";
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export const autoGenerateSlug: CollectionBeforeChangeHook = ({
data,
operation,
}) => {
if (operation === "create" || operation === "update") {
if (data && data.name && !data.slug) {
data.slug = generateSlug(data.name);
}
}
return data;
};
```
### Step 4: (Optional) Add a business-logic hook delegating to a use case
Create `src/collections/tags/hooks/after-change.ts`:
```typescript
import type { CollectionAfterChangeHook } from "payload";
import { syncTagToSearchIndex } from "@repo/core/application";
export const syncTagAfterChange: CollectionAfterChangeHook = async ({
doc,
operation,
}) => {
// Delegate to use case -- this hook is just a thin bridge
await syncTagToSearchIndex({
id: doc.id,
name: doc.name,
slug: doc.slug,
operation,
});
return doc;
};
```
### Step 5: (Optional) Add access control
Create `src/collections/tags/access/is-admin.ts`:
```typescript
import type { Access } from "payload";
export const isAdmin: Access = ({ req: { user } }) => {
return user?.role === "admin";
};
```
Then reference it in the CollectionConfig:
```typescript
export const Tags: CollectionConfig = {
slug: "tags",
access: {
create: isAdmin,
update: isAdmin,
delete: isAdmin,
// read is open by default
},
// ...rest
};
```
### Step 6: Register in payload.config.ts
Edit `src/payload.config.ts`:
```typescript
import { Tags } from "./collections/tags";
export default buildConfig({
collections: [Users, Articles, Media, Tags], // <-- add Tags
// ...rest
});
```
### Step 7: Export from package entry
Edit `src/index.ts`:
```typescript
export { Tags } from "./collections/tags"; // <-- add export
```
## Recipe: Adding a New Global
This example adds a `Navigation` global.
### Step 1: Create the GlobalConfig
Create `src/globals/navigation.ts`:
```typescript
import type { GlobalConfig } from "payload";
export const Navigation: GlobalConfig = {
slug: "navigation",
admin: {
group: "Settings",
},
fields: [
{
name: "mainMenu",
type: "array",
fields: [
{
name: "label",
type: "text",
required: true,
},
{
name: "url",
type: "text",
required: true,
},
],
},
],
};
```
### Step 2: Register in payload.config.ts
```typescript
import { Navigation } from "./globals/navigation";
export default buildConfig({
globals: [SiteSettings, Navigation], // <-- add Navigation
// ...rest
});
```
### Step 3: Export from package entry
```typescript
export { Navigation } from "./globals/navigation"; // <-- add export
```
## Payload Config Overview
The `payload.config.ts` uses:
- **Database:** `@payloadcms/db-postgres` (PostgreSQL via `DATABASE_URL` env var)
- **Editor:** `@payloadcms/richtext-lexical` (Lexical rich text editor)
- **Secret:** `PAYLOAD_SECRET` env var (required for production)
- **TypeScript output:** Generates `payload-types.ts` in this package's `src/` directory
## Dependencies
| Dependency | Purpose |
|---|---|
| `payload` | Payload CMS core |
| `@payloadcms/db-postgres` | PostgreSQL database adapter |
| `@payloadcms/richtext-lexical` | Lexical rich text editor |
## Cross-References
- **CMS app shell:** `apps/cms/` -- see `apps/cms/AGENTS.md`
- **Use cases for hooks:** `packages/core/src/application/use-cases/` -- see `packages/core/AGENTS.md`
- **CMS client for querying:** `packages/cms-client/` -- see `packages/cms-client/AGENTS.md`

View File

@@ -0,0 +1,425 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
articles: Article;
media: Media;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
articles: ArticlesSelect<false> | ArticlesSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {
'site-settings': SiteSetting;
};
globalsSelect: {
'site-settings': SiteSettingsSelect<false> | SiteSettingsSelect<true>;
};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
displayName?: string | null;
role: 'admin' | 'editor' | 'author';
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "articles".
*/
export interface Article {
id: number;
title: string;
/**
* Auto-generated from title if left empty
*/
slug?: string | null;
content?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
status: 'draft' | 'published';
author: number | User;
featuredImage?: (number | null) | Media;
publishedAt?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'articles';
value: number | Article;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
displayName?: T;
role?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "articles_select".
*/
export interface ArticlesSelect<T extends boolean = true> {
title?: T;
slug?: T;
content?: T;
status?: T;
author?: T;
featuredImage?: T;
publishedAt?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings".
*/
export interface SiteSetting {
id: number;
siteName: string;
siteDescription?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "site-settings_select".
*/
export interface SiteSettingsSelect<T extends boolean = true> {
siteName?: T;
siteDescription?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@@ -1,48 +1,249 @@
# @repo/core Clean Architecture Core
# @repo/core -- Clean Architecture Core Package
Business logic package. All use cases, entities, interfaces, and DI live here.
This is the central business logic package. All domain entities, use cases, repository/service interfaces, controllers, and the InversifyJS DI container live here. Nothing in this package depends on any web framework (Next.js, TanStack, Payload). It is portable and testable in isolation.
## Layers (dependencies point inward only)
**Package location:** `packages/core`
**Entry point:** `src/index.ts`
**Test runner:** Vitest (`pnpm vitest run` from this directory)
---
## Layer Diagram
Dependencies point inward (right to left). Outer layers depend on inner layers, never the reverse.
```
entities/ → NOTHING (innermost, zero deps)
application/ → entities/ only
interface-adapters/→ application/, entities/
infrastructure/ → application/, entities/, @repo/cms-client, external libs
di/ → all internal layers
+------------------------------------------------------------------+
| di/ |
| (wires everything together -- imports ALL internal layers) |
+------------------------------------------------------------------+
| | | |
v v v v
+----------------+ +----------------+ +----------------+ +-----------+
| interface- | | infrastructure/| | application/ | | entities/ |
| adapters/ | | (implements | | (use cases, | | (models, |
| controllers/ | | interfaces | | repo/service | | errors) |
| (validates | | with concrete | | interfaces) | | |
| input, calls | | code) | | | | INNERMOST |
| use cases) | | | | imports: | | zero deps |
| | | imports: | | entities/ | | |
| imports: | | application/ | | ONLY | | |
| application/ | | entities/ | | | | |
| entities/ | | @repo/ | | | | |
| | | cms-client | | | | |
+----------------+ +----------------+ +----------------+ +-----------+
```
---
## Import Rules
| Layer | Can import from | NEVER import from |
| Layer | Can Import From | NEVER Import From |
|---|---|---|
| entities/ | NOTHING | Everything else |
| application/ | entities/ only | infrastructure/, interface-adapters/ |
| interface-adapters/ | application/, entities/ | infrastructure/ |
| infrastructure/ | application/, entities/, @repo/cms-client | interface-adapters/, apps/* |
| di/ | All internal layers | apps/* |
| `entities/` | Nothing (Zod is the only external dependency) | `application/`, `infrastructure/`, `interface-adapters/`, `di/`, any `@repo/*`, any framework |
| `application/` | `entities/` only | `infrastructure/`, `interface-adapters/`, `di/` (except `getInjection` from `di/container` in use cases) |
| `interface-adapters/` | `application/` (use cases), `entities/` (types, errors) | `infrastructure/`, any `@repo/*` except via DI |
| `infrastructure/` | `application/` (interfaces to implement), `entities/` (types), `@repo/cms-client` | `interface-adapters/`, apps/*, Next.js, TanStack |
| `di/` | All internal layers (it wires them together) | apps/*, any framework package |
Note: Use cases in `application/` import `getInjection` from `di/container` to resolve dependencies at runtime. This is the one controlled exception to the "application never imports di" rule -- `getInjection` is a lookup function, not a concrete implementation.
---
## DI Resolution Table
| Symbol | Interface | Production | Mock |
| Symbol Key | Interface | Production Implementation | Mock Implementation |
|---|---|---|---|
| IUsersRepository | IUsersRepository | PayloadUsersRepository (Plan 3) | MockUsersRepository |
| IArticlesRepository | IArticlesRepository | PayloadArticlesRepository (Plan 3) | MockArticlesRepository |
| IAuthenticationService | IAuthenticationService | BetterAuthService (future) | MockAuthenticationService |
| ITelemetryService | ITelemetryService | OTelSentryService (future) | MockTelemetryService |
| `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future -- will use `@repo/cms-client`) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) |
| `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future -- will use `@repo/cms-client`) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) |
| `IAuthenticationService` | `IAuthenticationService` (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) | BetterAuthService (future) | `MockAuthenticationService` (`infrastructure/services/mock-auth.service.ts`) |
| `ITelemetryService` | `ITelemetryService` (startSpan) | OTelSentryService (future) | `MockTelemetryService` (`infrastructure/services/mock-telemetry.service.ts`) |
Currently all modules bind mock implementations. When production implementations are added, the modules will conditionally bind based on environment or configuration.
---
## Naming Conventions
- Models: `{name}.ts` with Zod schema + type export
- Errors: `{domain}.ts` with Error subclasses
- Interfaces: `{name}.repository.interface.ts` or `{name}.service.interface.ts`
- Use cases: `{verb}-{noun}.use-case.ts`
- Controllers: `{noun}.controller.ts`
- Infra: `{provider}-{name}.repository.ts` or `mock-{name}.repository.ts`
- DI modules: `{domain}.module.ts`
| Type | Pattern | Example |
|---|---|---|
| Entity model | `{name}.ts` | `article.ts`, `user.ts` |
| Entity error | `{domain}.ts` | `auth.ts`, `common.ts` |
| Repository interface | `{name}.repository.interface.ts` | `users.repository.interface.ts` |
| Service interface | `{name}.service.interface.ts` | `auth.service.interface.ts` |
| Use case | `{verb}-{noun}.use-case.ts` | `sign-in.use-case.ts`, `create-article.use-case.ts` |
| Controller | `{noun}.controller.ts` (may export multiple functions) | `articles.controller.ts` |
| Production impl | `{provider}-{name}.repository.ts` | `payload-users.repository.ts` |
| Mock impl | `mock-{name}.repository.ts` or `mock-{name}.service.ts` | `mock-users.repository.ts` |
| DI module | `{domain}.module.ts` | `auth.module.ts`, `content.module.ts` |
| Test file | `{name}.test.ts` matching the source file name | `sign-in.use-case.test.ts` |
---
## How to Add a New Dependency (5-Step Recipe)
This recipe adds a new repository or service interface to the DI container. For a complete feature, also follow the root `AGENTS.md` end-to-end recipe.
### Step 1: Define the interface in `application/`
Create `src/application/repositories/{name}.repository.interface.ts` or `src/application/services/{name}.service.interface.ts`:
```typescript
import type { MyEntity } from "@/entities/models/my-entity";
export interface IMyRepository {
findById(id: string): Promise<MyEntity | undefined>;
findAll(): Promise<MyEntity[]>;
create(input: MyEntity): Promise<MyEntity>;
}
```
Export from the appropriate barrel: `src/application/repositories/index.ts` or `src/application/services/index.ts`.
### Step 2: Add the DI symbol to `di/types.ts`
```typescript
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
export const DI_SYMBOLS = {
// ...existing symbols...
IMyRepository: Symbol.for("IMyRepository"),
};
export interface DI_RETURN_TYPES {
// ...existing types...
IMyRepository: IMyRepository;
}
```
### Step 3: Create mock implementation in `infrastructure/`
```typescript
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class MockMyRepository implements IMyRepository {
private _items: MyEntity[] = [];
async findById(id: string): Promise<MyEntity | undefined> {
return this._items.find((item) => item.id === id);
}
async findAll(): Promise<MyEntity[]> {
return [...this._items];
}
async create(input: MyEntity): Promise<MyEntity> {
this._items.push(input);
return input;
}
}
```
### Step 4: Create or update DI module in `di/modules/`
```typescript
import { ContainerModule, interfaces } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import { MockMyRepository } from "@/infrastructure/repositories/mock-my.repository";
import { DI_SYMBOLS } from "../types";
const initializeModule = (bind: interfaces.Bind) => {
bind<IMyRepository>(DI_SYMBOLS.IMyRepository).to(MockMyRepository);
};
export const MyModule = new ContainerModule(initializeModule);
```
### Step 5: Load module in `di/container.ts`
```typescript
import { MyModule } from "./modules/my.module";
export const initializeContainer = () => {
// ...existing modules...
ApplicationContainer.load(MyModule);
};
export const destroyContainer = () => {
// ...existing modules...
ApplicationContainer.unload(MyModule);
};
```
---
## Testing Pattern
All tests follow this lifecycle pattern:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { destroyContainer, initializeContainer } from "@/di/container";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("myUseCase", () => {
it("does something", async () => {
const result = await myUseCase(/* ... */);
expect(result).toBeDefined();
});
});
```
Critical requirements:
- `import "reflect-metadata"` MUST be the first import in every test file. InversifyJS decorators rely on runtime metadata reflection.
- `initializeContainer()` loads all DI modules (binding mock implementations).
- `destroyContainer()` unloads all modules, ensuring clean state between tests. Without this, Singleton-scoped mocks retain state across tests.
Test file locations mirror source structure:
- `tests/unit/use-cases/{domain}/{name}.use-case.test.ts`
- `tests/unit/controllers/{domain}/{name}.controller.test.ts`
---
## tsconfig Requirements
These settings in `tsconfig.json` are MANDATORY. Do not remove them:
| Setting | Why |
|---|---|
| `experimentalDecorators: true` | InversifyJS uses TypeScript decorators (`@injectable()`, `@inject()`) |
| `emitDecoratorMetadata: true` | InversifyJS reads parameter type metadata at runtime for constructor injection |
| `types: ["reflect-metadata", "node"]` | `reflect-metadata` polyfill must be globally available for decorator metadata |
| Path alias `@/*` -> `./src/*` | All internal imports use `@/` prefix (e.g., `@/entities/models/user`) |
The base config comes from `@repo/typescript-config/base.json` which already includes `experimentalDecorators` and `emitDecoratorMetadata`. The core `tsconfig.json` extends it and adds the path alias and types.
---
## Running Tests
```bash
cd packages/core && pnpm vitest run
cd packages/core
pnpm vitest run # Run all tests once
pnpm vitest # Run in watch mode
pnpm vitest run --reporter=verbose # Verbose output
```
---
## Cross-References
- `src/entities/AGENTS.md` -- Entity models and error classes
- `src/application/AGENTS.md` -- Use cases, repository/service interfaces
- `src/infrastructure/AGENTS.md` -- Concrete implementations with `@injectable()`
- `src/interface-adapters/controllers/AGENTS.md` -- Controller patterns
- `src/di/AGENTS.md` -- DI container configuration and lifecycle
- `src/application/use-cases/auth/AGENTS.md` -- Auth domain business rules
- `src/application/use-cases/content/AGENTS.md` -- Content domain business rules
- Root `AGENTS.md` -- Full end-to-end feature recipe and monorepo map

View File

@@ -1,28 +1,340 @@
# Application Layer Use Cases + Interfaces
# Application Layer -- Use Cases + Interfaces
**Path:** `packages/core/src/application/`
**Role:** Define the business logic (use cases) and the abstract contracts (repository and service interfaces) that the infrastructure layer implements. This is the second-innermost layer of Clean Architecture.
---
## Rules
- Imports from entities/ ONLY
- NEVER imports from infrastructure/ or interface-adapters/
- Repository interfaces define data access contracts
- Service interfaces define external service contracts
- Use cases get dependencies via `getInjection()` — never direct import of implementations
1. Imports from `entities/` ONLY (for types, schemas, and error classes).
2. **NEVER** imports from `infrastructure/` -- use cases depend on interfaces, not implementations.
3. **NEVER** imports from `interface-adapters/` -- controllers call use cases, not the reverse.
4. Use cases obtain dependencies via `getInjection()` from `di/container` at runtime. This is the single allowed cross-layer dependency.
5. All repository and service interfaces are pure TypeScript interfaces (no decorators, no classes).
6. All methods on interfaces return Promises (data access is always async).
## Adding a New Use Case
---
1. Create `src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts`
2. Get dependencies via DI: `const repo = getInjection("IMyRepository")`
3. Implement business logic using entities and interfaces only
4. Write test in `tests/unit/use-cases/{domain}/` using `initializeContainer()`/`destroyContainer()` pattern
## Existing Repository Interfaces
## Adding a New Repository Interface
| Interface | File | Methods |
|---|---|---|
| `IUsersRepository` | `repositories/users.repository.interface.ts` | `getUser(id)`, `getUserByUsername(username)`, `createUser(input)` |
| `IArticlesRepository` | `repositories/articles.repository.interface.ts` | `getArticle(id)`, `getArticles(options?)`, `createArticle(input)`, `updateArticle(id, input)` |
1. Create `src/application/repositories/{name}.repository.interface.ts`
2. Define interface methods returning entity types
3. Export from `src/application/repositories/index.ts`
4. Create mock implementation in infrastructure/
5. Register in DI container (add symbol, module binding)
## Existing Service Interfaces
## Adding a New Service Interface
| Interface | File | Methods |
|---|---|---|
| `IAuthenticationService` | `services/auth.service.interface.ts` | `generateUserId()`, `hashPassword(password)`, `verifyPassword(hash, password)`, `validateSession(sessionId)`, `createSession(user)`, `invalidateSession(sessionId)` |
| `ITelemetryService` | `services/telemetry.service.interface.ts` | `startSpan(name, fn)` |
Same as repository, but in `src/application/services/`
## Existing Use Cases
| Use Case | File | Domain | What It Does |
|---|---|---|---|
| `signInUseCase` | `use-cases/auth/sign-in.use-case.ts` | Auth | Looks up user by username, verifies password, creates session |
| `signUpUseCase` | `use-cases/auth/sign-up.use-case.ts` | Auth | Checks username uniqueness, hashes password, creates user + session |
| `signOutUseCase` | `use-cases/auth/sign-out.use-case.ts` | Auth | Invalidates session, returns blank cookie |
| `createArticleUseCase` | `use-cases/content/create-article.use-case.ts` | Content | Generates slug from title, creates draft article |
| `getArticlesUseCase` | `use-cases/content/get-articles.use-case.ts` | Content | Retrieves articles with optional filtering and pagination |
---
## Repository Interface Template
```typescript
// src/application/repositories/{name}.repository.interface.ts
import type { MyEntity } from "@/entities/models/my-entity";
export interface IMyRepository {
/**
* Find a single entity by ID.
* Returns undefined if not found (do NOT throw -- let the use case decide).
*/
findById(id: string): Promise<MyEntity | undefined>;
/**
* Find all entities matching optional filters.
*/
findAll(options?: {
status?: string;
limit?: number;
offset?: number;
}): Promise<MyEntity[]>;
/**
* Create a new entity. Returns the created entity.
*/
create(input: MyEntity): Promise<MyEntity>;
/**
* Update an existing entity. Returns the updated entity or undefined if not found.
*/
update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined>;
}
```
Key conventions:
- Return `undefined` for "not found" cases, not `null`, and do not throw.
- The use case decides what to do when something is not found (throw `NotFoundError`, return default, etc.).
- Always return the entity after create/update so the caller has the final state.
---
## Service Interface Template
```typescript
// src/application/services/{name}.service.interface.ts
import type { SomeEntity } from "@/entities/models/some-entity";
export interface IMyService {
/**
* Service methods define external capabilities the domain needs
* but does not implement itself (email, auth, telemetry, etc.)
*/
doSomething(input: string): Promise<SomeEntity>;
}
```
The distinction between repositories and services:
- **Repositories** abstract data storage (CRUD operations on entities).
- **Services** abstract external capabilities (authentication, email, telemetry, file storage).
---
## Use Case Template
```typescript
// src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts
import type { MyEntity } from "@/entities/models/my-entity";
import { NotFoundError } from "@/entities/errors/common";
import { getInjection } from "@/di/container";
export async function myUseCase(input: {
id: string;
// ... other input fields
}): Promise<MyEntity> {
// 1. Get dependencies from DI container
const myRepository = getInjection("IMyRepository");
// 2. Execute business logic using entities and interfaces
const existing = await myRepository.findById(input.id);
if (!existing) {
throw new NotFoundError("Entity not found");
}
// 3. Return result (entity types from entities/ layer)
return existing;
}
```
Critical pattern: `getInjection("IMyRepository")` returns a fully typed instance. The string key must match a key in `DI_SYMBOLS` (see `di/types.ts`). TypeScript will enforce the return type via the `DI_RETURN_TYPES` mapping.
---
## Adding a New Repository Interface (5-Step Recipe)
### Step 1: Create the interface file
Create `src/application/repositories/{name}.repository.interface.ts` following the template above.
### Step 2: Export from the barrel
Add to `src/application/repositories/index.ts`:
```typescript
export type { IMyRepository } from "./{name}.repository.interface";
```
### Step 3: Create mock implementation
In `src/infrastructure/repositories/mock-{name}.repository.ts`:
```typescript
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/{name}.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class MockMyRepository implements IMyRepository {
private _items: MyEntity[] = [];
async findById(id: string): Promise<MyEntity | undefined> {
return this._items.find((item) => item.id === id);
}
async findAll(): Promise<MyEntity[]> {
return [...this._items];
}
async create(input: MyEntity): Promise<MyEntity> {
this._items.push(input);
return input;
}
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
const index = this._items.findIndex((item) => item.id === id);
if (index === -1) return undefined;
this._items[index] = { ...this._items[index]!, ...input };
return this._items[index];
}
}
```
### Step 4: Register in DI
Add symbol to `di/types.ts`, create or update module in `di/modules/`, load in `di/container.ts`. See `di/AGENTS.md` for full details.
### Step 5: Verify
Write a use case that calls `getInjection("IMyRepository")` and a test that exercises it with the mock.
---
## Adding a New Service Interface (Recipe)
Same as repository interface, but files go in `src/application/services/` and `src/infrastructure/services/`. The naming convention is `{name}.service.interface.ts` for the interface and `mock-{name}.service.ts` for the mock.
---
## Adding a New Use Case (TDD Recipe)
### Step 1: Write the test FIRST
Create `tests/unit/use-cases/{domain}/{verb}-{noun}.use-case.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { myNewUseCase } from "@/application/use-cases/{domain}/{verb}-{noun}.use-case";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("myNewUseCase", () => {
it("succeeds with valid input", async () => {
const result = await myNewUseCase({ /* valid input */ });
expect(result).toBeDefined();
// ... assert specific properties
});
it("throws correct error for invalid state", async () => {
await expect(
myNewUseCase({ /* input that triggers error */ })
).rejects.toBeInstanceOf(SomeError);
});
});
```
### Step 2: Implement the use case
Create `src/application/use-cases/{domain}/{verb}-{noun}.use-case.ts` following the template above. Run the test to verify.
### Step 3: Export from core
Add to `src/index.ts`:
```typescript
export { myNewUseCase } from "./application/use-cases/{domain}/{verb}-{noun}.use-case";
```
### Step 4: Create a controller (if needed)
Controllers go in `interface-adapters/controllers/`. See `interface-adapters/controllers/AGENTS.md`.
---
## Test Template (Full Example)
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("createArticleUseCase", () => {
it("creates an article with generated slug and draft status", async () => {
const result = await createArticleUseCase({
title: "My First Article",
content: "Hello world",
authorId: "1",
});
expect(result.title).toBe("My First Article");
expect(result.slug).toBe("my-first-article");
expect(result.status).toBe("draft");
expect(result.authorId).toBe("1");
expect(result.id).toBeDefined();
});
it("uses provided slug if given", async () => {
const result = await createArticleUseCase({
title: "Another Article",
content: "Content here",
authorId: "1",
slug: "custom-slug",
});
expect(result.slug).toBe("custom-slug");
});
});
```
---
## File Structure
```
application/
AGENTS.md
repositories/
index.ts <-- barrel: exports all repository interfaces
users.repository.interface.ts
articles.repository.interface.ts
services/
index.ts <-- barrel: exports all service interfaces
auth.service.interface.ts
telemetry.service.interface.ts
use-cases/
auth/
AGENTS.md <-- auth domain rules and recipes
sign-in.use-case.ts
sign-up.use-case.ts
sign-out.use-case.ts
content/
AGENTS.md <-- content domain rules and recipes
create-article.use-case.ts
get-articles.use-case.ts
```
---
## Cross-References
- `entities/AGENTS.md` -- The only layer this layer can import from
- `infrastructure/AGENTS.md` -- Implements the interfaces defined here
- `di/AGENTS.md` -- Where interfaces are bound to implementations
- `use-cases/auth/AGENTS.md` -- Auth domain business rules
- `use-cases/content/AGENTS.md` -- Content domain business rules

View File

@@ -1,30 +1,208 @@
# Auth Domain — Business Rules
# Auth Domain -- Use Cases
## Responsibility
**Path:** `packages/core/src/application/use-cases/auth/`
**Domain Responsibility:** Authentication and authorization -- sign-in, sign-up, sign-out, session management. This domain owns the user identity lifecycle from account creation through session invalidation.
Authentication and authorization: sign-in, sign-up, sign-out, session management.
---
## Business Rules
## Complete Business Rules
- Passwords are hashed via IAuthenticationService (never stored plain)
- Sessions expire after 7 days (configured in mock, real impl may differ)
- Sign-up requires unique username
- Sign-in verifies password via IAuthenticationService.verifyPassword()
- Sign-out invalidates session and returns blank cookie
1. **Passwords are always hashed.** Plain-text passwords never enter the repository. The `IAuthenticationService.hashPassword()` method handles hashing before storage.
2. **Usernames must be unique.** Sign-up checks for existing username via `IUsersRepository.getUserByUsername()` before creating a new user.
3. **Sign-in requires valid credentials.** The user must exist AND the password must verify against the stored hash.
4. **Sessions are created on successful sign-in or sign-up.** `IAuthenticationService.createSession()` returns both a `Session` object and a `Cookie` for the client.
5. **Sessions expire after 7 days.** This is the current mock implementation default (`Date.now() + 86400000 * 7`). Production implementations may use different expiry logic.
6. **Sign-out invalidates the session.** `IAuthenticationService.invalidateSession()` removes the session and returns a blank cookie (empty value) to clear the client-side cookie.
7. **User IDs are generated by the auth service.** `IAuthenticationService.generateUserId()` produces the ID, not the repository. This allows the auth provider (e.g., Better Auth) to control ID format.
---
## Error Cases
- `AuthenticationError` — wrong credentials (sign-in) or username taken (sign-up)
- `UnauthenticatedError` — invalid/expired session
| Error Class | When Thrown | Use Case |
|---|---|---|
| `AuthenticationError` | User does not exist (sign-in) | `signInUseCase` |
| `AuthenticationError` | Incorrect password (sign-in) | `signInUseCase` |
| `AuthenticationError` | Username already taken (sign-up) | `signUpUseCase` |
| `UnauthenticatedError` | Session ID not found or expired (session validation) | `IAuthenticationService.validateSession()` |
| `InputParseError` | Invalid input fields (handled by controller, not use case) | `signInController`, `signUpController` |
Note: Use cases throw `AuthenticationError` for credential failures. They intentionally do NOT distinguish between "user not found" and "wrong password" in the error message exposed to callers -- this prevents username enumeration attacks. The sign-in use case throws `"Incorrect username or password"` for wrong passwords and `"User does not exist"` internally, but callers should treat both as authentication failures.
---
## Dependencies
- `IUsersRepository` — user lookup and creation
- `IAuthenticationService` — password hashing, session management
### IUsersRepository
## Adding a New Auth Use Case
Provides user data access. Methods used by auth use cases:
1. Create `{verb}-{noun}.use-case.ts` in this folder
2. Get deps via `getInjection("IUsersRepository")`, `getInjection("IAuthenticationService")`
3. Write test first in `tests/unit/use-cases/auth/`
4. Use `initializeContainer()`/`destroyContainer()` pattern in tests
| Method | Signature | Used By |
|---|---|---|
| `getUser` | `(id: string) => Promise<User \| undefined>` | `MockAuthenticationService.validateSession()` (indirectly) |
| `getUserByUsername` | `(username: string) => Promise<User \| undefined>` | `signInUseCase`, `signUpUseCase` |
| `createUser` | `(input: User) => Promise<User>` | `signUpUseCase` |
### IAuthenticationService
Provides authentication operations. All methods:
| Method | Signature | Used By |
|---|---|---|
| `generateUserId` | `() => string` | `signUpUseCase` |
| `hashPassword` | `(password: string) => Promise<string>` | `signUpUseCase` |
| `verifyPassword` | `(hash: string, password: string) => Promise<boolean>` | `signInUseCase` |
| `validateSession` | `(sessionId: string) => Promise<{ user: User; session: Session }>` | (future use cases needing auth context) |
| `createSession` | `(user: User) => Promise<{ session: Session; cookie: Cookie }>` | `signInUseCase`, `signUpUseCase` |
| `invalidateSession` | `(sessionId: string) => Promise<{ blankCookie: Cookie }>` | `signOutUseCase` |
---
## Existing Use Cases
### signInUseCase
**File:** `sign-in.use-case.ts`
**Input:** `{ username: string; password: string }`
**Output:** `{ session: Session; cookie: Cookie }`
**Logic:**
1. Look up user by username via `IUsersRepository.getUserByUsername()`.
2. If not found, throw `AuthenticationError("User does not exist")`.
3. Verify password via `IAuthenticationService.verifyPassword()`.
4. If invalid, throw `AuthenticationError("Incorrect username or password")`.
5. Create session via `IAuthenticationService.createSession()`.
6. Return session and cookie.
### signUpUseCase
**File:** `sign-up.use-case.ts`
**Input:** `{ username: string; password: string }`
**Output:** `{ session: Session; cookie: Cookie; user: Pick<User, "id" | "username"> }`
**Logic:**
1. Check if username exists via `IUsersRepository.getUserByUsername()`.
2. If exists, throw `AuthenticationError("Username taken")`.
3. Hash password via `IAuthenticationService.hashPassword()`.
4. Generate user ID via `IAuthenticationService.generateUserId()`.
5. Create user via `IUsersRepository.createUser()`.
6. Create session via `IAuthenticationService.createSession()`.
7. Return session, cookie, and safe user info (id + username, no passwordHash).
### signOutUseCase
**File:** `sign-out.use-case.ts`
**Input:** `sessionId: string`
**Output:** `{ blankCookie: Cookie }`
**Logic:**
1. Invalidate session via `IAuthenticationService.invalidateSession()`.
2. Return blank cookie (empty value clears the client-side session cookie).
---
## Adding a New Auth Use Case (Complete Recipe with Test-First Example)
Example: adding a `validateSessionUseCase` that checks if a session is valid and returns the authenticated user.
### Step 1: Write the test FIRST
Create `tests/unit/use-cases/auth/validate-session.use-case.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { signUpUseCase } from "@/application/use-cases/auth/sign-up.use-case";
import { validateSessionUseCase } from "@/application/use-cases/auth/validate-session.use-case";
import { UnauthenticatedError } from "@/entities/errors/auth";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("validateSessionUseCase", () => {
it("returns user and session for valid session", async () => {
// First create a user and get a session
const signUpResult = await signUpUseCase({
username: "testuser",
password: "testpassword",
});
const result = await validateSessionUseCase(
signUpResult.session.id
);
expect(result.user.username).toBe("testuser");
expect(result.session.id).toBe(signUpResult.session.id);
});
it("throws UnauthenticatedError for invalid session", async () => {
await expect(
validateSessionUseCase("nonexistent-session-id")
).rejects.toBeInstanceOf(UnauthenticatedError);
});
});
```
### Step 2: Implement the use case
Create `src/application/use-cases/auth/validate-session.use-case.ts`:
```typescript
import type { Session } from "@/entities/models/session";
import type { User } from "@/entities/models/user";
import { getInjection } from "@/di/container";
export async function validateSessionUseCase(
sessionId: string
): Promise<{ user: User; session: Session }> {
const authService = getInjection("IAuthenticationService");
return await authService.validateSession(sessionId);
}
```
### Step 3: Export from core
Add to `packages/core/src/index.ts`:
```typescript
export { validateSessionUseCase } from "./application/use-cases/auth/validate-session.use-case";
```
### Step 4: Run tests
```bash
cd packages/core && pnpm vitest run
```
### Step 5: Create controller and tRPC procedure (if needed)
Follow the controller recipe in `interface-adapters/controllers/AGENTS.md`.
---
## Security Considerations
1. **Passwords are always hashed before storage.** The `signUpUseCase` calls `authService.hashPassword()` and stores only the hash. The mock uses a simple `hashed_` prefix; production implementations must use bcrypt, argon2, or similar.
2. **Sessions expire.** The mock sets a 7-day expiry. Production implementations should enforce this with database TTLs or cleanup jobs.
3. **Blank cookie on sign-out.** Setting the cookie value to `""` tells the browser to clear it. The cookie name is defined in `config.ts` as `SESSION_COOKIE = "session"`.
4. **No password in responses.** `signUpUseCase` returns `Pick<User, "id" | "username">`, explicitly excluding `passwordHash`.
5. **Error messages should not leak information.** In production, consider using a generic "Invalid credentials" message for both "user not found" and "wrong password" scenarios to prevent username enumeration.
---
## Cross-References
- `application/AGENTS.md` -- General use case patterns and interfaces
- `entities/errors/auth.ts` -- `AuthenticationError`, `UnauthenticatedError`, `UnauthorizedError`
- `entities/models/user.ts` -- `User` type
- `entities/models/session.ts` -- `Session` type
- `entities/models/cookie.ts` -- `Cookie` type
- `di/AGENTS.md` -- How `IUsersRepository` and `IAuthenticationService` are resolved
- `infrastructure/services/mock-auth.service.ts` -- Mock auth implementation details

View File

@@ -1,29 +1,240 @@
# Content Domain — Business Rules
# Content Domain -- Use Cases
## Responsibility
**Path:** `packages/core/src/application/use-cases/content/`
**Domain Responsibility:** Article management -- creation, retrieval, filtering, and publishing workflow. This domain owns the content lifecycle from draft creation through publication.
Article management: creation, retrieval, publishing workflow.
---
## Business Rules
## Complete Business Rules
- Articles must have a title and content
- Slugs auto-generated from title if not provided
- New articles default to "draft" status
- Filtering by status, authorId supported
- Pagination via limit/offset
1. **Articles must have a title and content.** These are required fields validated at the controller level via Zod schemas.
2. **Slugs are auto-generated from the title if not provided.** The `generateSlug()` function in `createArticleUseCase` handles this.
3. **New articles default to "draft" status.** The `status` field is set to `"draft"` on creation. There is no way to create a published article directly.
4. **Articles have an author.** The `authorId` field links to a user. The use case does not verify the author exists (that responsibility belongs to a future authorization layer).
5. **Article IDs are UUIDs.** Generated via `crypto.randomUUID()` at creation time.
6. **Timestamps are set at creation.** Both `createdAt` and `updatedAt` are set to `new Date()` when the article is created.
7. **Filtering is supported.** `getArticlesUseCase` accepts optional `status`, `authorId`, `limit`, and `offset` parameters.
8. **Default pagination is 50 items.** The mock repository defaults `limit` to 50 if not specified.
---
## Error Cases
- `NotFoundError` — article doesn't exist (future: update/delete)
- `UnauthorizedError` — user can't edit article (future)
- `InputParseError` — missing required fields (handled by controller)
| Error Class | When Thrown | Use Case |
|---|---|---|
| `InputParseError` | Missing or invalid input fields (title, content, authorId) | `createArticleController` (controller level) |
| `InputParseError` | Invalid filter parameters | `getArticlesController` (controller level) |
| `NotFoundError` | Article not found by ID (future: update/delete operations) | Future use cases |
| `UnauthorizedError` | User cannot edit/delete another user's article (future) | Future use cases |
Note: Current use cases do not throw domain errors directly. Validation happens in controllers. Future use cases (update, delete, publish) will introduce `NotFoundError` and `UnauthorizedError`.
---
## Dependencies
- `IArticlesRepository` — article CRUD operations
### IArticlesRepository
## Adding a New Content Use Case
Provides article data access. All methods:
1. Create `{verb}-{noun}.use-case.ts` in this folder
2. Get deps via `getInjection("IArticlesRepository")`
3. Write test first in `tests/unit/use-cases/content/`
| Method | Signature | Used By |
|---|---|---|
| `getArticle` | `(id: string) => Promise<Article \| undefined>` | (future use cases) |
| `getArticles` | `(options?: { status?, authorId?, limit?, offset? }) => Promise<Article[]>` | `getArticlesUseCase` |
| `createArticle` | `(input: Article) => Promise<Article>` | `createArticleUseCase` |
| `updateArticle` | `(id: string, input: Partial<Article>) => Promise<Article \| undefined>` | (future use cases) |
---
## Existing Use Cases
### createArticleUseCase
**File:** `create-article.use-case.ts`
**Input:** `{ title: string; content: string; authorId: string; slug?: string }`
**Output:** `Article`
**Logic:**
1. Get `IArticlesRepository` via `getInjection("IArticlesRepository")`.
2. Generate UUID for article ID via `crypto.randomUUID()`.
3. Generate slug from title if not provided (see Slug Generation below).
4. Set `status` to `"draft"`.
5. Set `createdAt` and `updatedAt` to current time.
6. Call `articlesRepository.createArticle()` with the complete article object.
7. Return the created article.
### getArticlesUseCase
**File:** `get-articles.use-case.ts`
**Input:** `{ status?: string; authorId?: string; limit?: number; offset?: number }` (all optional)
**Output:** `Article[]`
**Logic:**
1. Get `IArticlesRepository` via `getInjection("IArticlesRepository")`.
2. Pass options directly to `articlesRepository.getArticles()`.
3. Return the result array.
---
## Slug Generation Logic
The `generateSlug()` function lives inside `create-article.use-case.ts` (private to the module, not exported):
```typescript
function generateSlug(title: string): string {
return title
.toLowerCase() // "My First Article" -> "my first article"
.replace(/[^a-z0-9]+/g, "-") // "my first article" -> "my-first-article"
.replace(/^-|-$/g, ""); // trim leading/trailing hyphens
}
```
Behavior:
- `"My First Article"` -> `"my-first-article"`
- `"Hello, World! #1"` -> `"hello-world-1"`
- `" Leading Spaces "` -> `"leading-spaces"`
- `"UPPER CASE"` -> `"upper-case"`
The same slug generation logic is duplicated in `packages/cms-core/src/collections/articles/hooks/before-change.ts` for the Payload CMS side. If you change the algorithm, update BOTH locations.
If a `slug` is explicitly provided in the input, it is used as-is without any transformation.
---
## Adding a New Content Use Case (Recipe)
Example: adding a `publishArticleUseCase` that changes an article's status from "draft" to "published".
### Step 1: Write the test FIRST
Create `tests/unit/use-cases/content/publish-article.use-case.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { createArticleUseCase } from "@/application/use-cases/content/create-article.use-case";
import { publishArticleUseCase } from "@/application/use-cases/content/publish-article.use-case";
import { NotFoundError } from "@/entities/errors/common";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("publishArticleUseCase", () => {
it("changes article status to published", async () => {
const article = await createArticleUseCase({
title: "Draft Article",
content: "Content",
authorId: "1",
});
expect(article.status).toBe("draft");
const published = await publishArticleUseCase(article.id);
expect(published.status).toBe("published");
expect(published.id).toBe(article.id);
});
it("throws NotFoundError for non-existent article", async () => {
await expect(
publishArticleUseCase("non-existent-id")
).rejects.toBeInstanceOf(NotFoundError);
});
});
```
### Step 2: Implement the use case
Create `src/application/use-cases/content/publish-article.use-case.ts`:
```typescript
import type { Article } from "@/entities/models/article";
import { NotFoundError } from "@/entities/errors/common";
import { getInjection } from "@/di/container";
export async function publishArticleUseCase(
articleId: string
): Promise<Article> {
const articlesRepository = getInjection("IArticlesRepository");
const updated = await articlesRepository.updateArticle(articleId, {
status: "published",
updatedAt: new Date(),
});
if (!updated) {
throw new NotFoundError("Article not found");
}
return updated;
}
```
### Step 3: Export from core
Add to `packages/core/src/index.ts`:
```typescript
export { publishArticleUseCase } from "./application/use-cases/content/publish-article.use-case";
```
### Step 4: Create controller
Create `src/interface-adapters/controllers/content/` (add to `articles.controller.ts` or create a new file):
```typescript
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import { publishArticleUseCase } from "@/application/use-cases/content/publish-article.use-case";
const publishInputSchema = z.object({
articleId: z.string(),
});
export async function publishArticleController(
input: Partial<z.infer<typeof publishInputSchema>>
) {
const { data, error: inputParseError } = publishInputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
return await publishArticleUseCase(data.articleId);
}
```
### Step 5: Add tRPC procedure
In `packages/api/src/router/content.router.ts`:
```typescript
import { publishArticleController } from "@repo/core";
publishArticle: publicProcedure
.input(z.object({ articleId: z.string() }))
.mutation(async ({ input }) => {
return await publishArticleController(input);
}),
```
### Step 6: Run tests
```bash
cd packages/core && pnpm vitest run
```
---
## Cross-References
- `application/AGENTS.md` -- General use case patterns and the `IArticlesRepository` interface
- `entities/models/article.ts` -- `Article` type, `articleSchema`, `articleStatusSchema`
- `entities/errors/common.ts` -- `NotFoundError`, `InputParseError`
- `infrastructure/repositories/mock-articles.repository.ts` -- Mock implementation with in-memory storage
- `di/AGENTS.md` -- How `IArticlesRepository` is resolved (currently via `content.module.ts`)
- `packages/cms-core/src/collections/articles/` -- Payload CMS Articles collection (parallel data model)

View File

@@ -1,37 +1,246 @@
# DI InversifyJS Container
# DI -- InversifyJS Dependency Injection Container
## Resolution Table
**Path:** `packages/core/src/di/`
**Role:** Wire together all abstract interfaces and their concrete implementations using InversifyJS. The container is the root composition point -- it knows about every layer and resolves dependencies at runtime. All other code accesses dependencies through `getInjection()`, never by importing implementations directly.
| Symbol Key | Interface | Production | Mock |
|---|---|---|---|
| IUsersRepository | IUsersRepository | (future) | MockUsersRepository |
| IArticlesRepository | IArticlesRepository | (future) | MockArticlesRepository |
| IAuthenticationService | IAuthenticationService | (future) | MockAuthenticationService |
| ITelemetryService | ITelemetryService | (future) | MockTelemetryService |
---
## How to Register a New Dependency
## Complete Resolution Table
1. Add Symbol to `types.ts``DI_SYMBOLS`
2. Add return type to `DI_RETURN_TYPES` interface
3. Create module in `modules/{domain}.module.ts`
4. Bind interface to implementation (production) and mock (test)
5. Load module in `container.ts` `initializeContainer()` and `destroyContainer()`
| Symbol Key | Interface | Production Implementation | Mock Implementation | DI Module |
|---|---|---|---|---|
| `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) | `auth.module.ts` |
| `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) | `content.module.ts` |
| `IAuthenticationService` | `IAuthenticationService` (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) | BetterAuthService (future) | `MockAuthenticationService` (`infrastructure/services/mock-auth.service.ts`) | `auth.module.ts` |
| `ITelemetryService` | `ITelemetryService` (startSpan) | OTelSentryService (future) | `MockTelemetryService` (`infrastructure/services/mock-telemetry.service.ts`) | `auth.module.ts` |
---
## How to Register a New Dependency (Full Recipe)
### Step 1: Add symbol and return type to `types.ts`
```typescript
// di/types.ts
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
export const DI_SYMBOLS = {
IAuthenticationService: Symbol.for("IAuthenticationService"),
ITelemetryService: Symbol.for("ITelemetryService"),
IUsersRepository: Symbol.for("IUsersRepository"),
IArticlesRepository: Symbol.for("IArticlesRepository"),
IMyRepository: Symbol.for("IMyRepository"), // <-- ADD THIS
};
export interface DI_RETURN_TYPES {
IAuthenticationService: IAuthenticationService;
ITelemetryService: ITelemetryService;
IUsersRepository: IUsersRepository;
IArticlesRepository: IArticlesRepository;
IMyRepository: IMyRepository; // <-- ADD THIS
}
```
The `DI_SYMBOLS` object maps string keys to unique `Symbol` values (used by InversifyJS for binding). The `DI_RETURN_TYPES` interface provides TypeScript type safety for `getInjection()`.
### Step 2: Create the DI module file
Create `di/modules/{domain}.module.ts` (or add to an existing one):
```typescript
// di/modules/my-domain.module.ts
import { ContainerModule, interfaces } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import { MockMyRepository } from "@/infrastructure/repositories/mock-my.repository";
import { DI_SYMBOLS } from "../types";
const initializeModule = (bind: interfaces.Bind) => {
bind<IMyRepository>(DI_SYMBOLS.IMyRepository).to(MockMyRepository);
};
export const MyDomainModule = new ContainerModule(initializeModule);
```
For modules with multiple bindings (like `auth.module.ts`):
```typescript
const initializeModule = (bind: interfaces.Bind) => {
bind<IUsersRepository>(DI_SYMBOLS.IUsersRepository).to(MockUsersRepository);
bind<IAuthenticationService>(DI_SYMBOLS.IAuthenticationService).to(
MockAuthenticationService
);
};
```
### Step 3: Load and unload the module in `container.ts`
```typescript
// di/container.ts
import { MyDomainModule } from "./modules/my-domain.module";
export const initializeContainer = () => {
ApplicationContainer.load(AuthModule);
ApplicationContainer.load(ContentModule);
ApplicationContainer.load(MyDomainModule); // <-- ADD THIS
};
export const destroyContainer = () => {
ApplicationContainer.unload(AuthModule);
ApplicationContainer.unload(ContentModule);
ApplicationContainer.unload(MyDomainModule); // <-- ADD THIS
};
```
Both `load` and `unload` must be updated. Forgetting `unload` causes test isolation failures.
---
## Container Lifecycle
- Production: `initializeContainer()` runs automatically (not in test env)
- Tests: Call `initializeContainer()` in `beforeEach`, `destroyContainer()` in `afterEach`
- Test environments swap to mock implementations via module bindings
### Production (non-test environments)
```typescript
if (process.env.NODE_ENV !== "test") {
initializeContainer();
}
```
The container auto-initializes when the module is first imported. All bindings are available immediately.
### Test environments
Tests manually control the container lifecycle:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach } from "vitest";
import { destroyContainer, initializeContainer } from "@/di/container";
beforeEach(() => {
initializeContainer(); // Load all modules, bind all implementations
});
afterEach(() => {
destroyContainer(); // Unload all modules, clear all bindings
});
```
Why manual control in tests:
- Each test gets a fresh container with fresh Singleton instances.
- In-memory mock data does not leak between tests.
- Forgetting `destroyContainer` in `afterEach` causes state pollution: the mock repositories retain data from previous tests, causing flaky tests.
---
## Scoping
The container defaults to **Singleton** scope:
```typescript
const ApplicationContainer = new Container({
defaultScope: "Singleton",
});
```
This means each call to `getInjection("IUsersRepository")` within a container lifecycle returns the same instance. This is correct for:
- Repositories (stateful mocks, connection pools in production)
- Services (auth sessions, telemetry clients)
When to use **Transient** scope (a new instance per resolution):
- Stateless utility services
- Per-request scoped objects
To override for a specific binding:
```typescript
bind<IMyService>(DI_SYMBOLS.IMyService)
.to(MyService)
.inTransientScope();
```
---
## `getInjection()` Usage in Use Cases
```typescript
// In a use case file:
import { getInjection } from "@/di/container";
export async function myUseCase(input: { id: string }) {
// The string key is type-safe: it must match a key in DI_SYMBOLS.
// The return type is automatically inferred from DI_RETURN_TYPES.
const usersRepository = getInjection("IUsersRepository");
// ^-- TypeScript infers: IUsersRepository
const authService = getInjection("IAuthenticationService");
// ^-- TypeScript infers: IAuthenticationService
const user = await usersRepository.getUser(input.id);
// ...
}
```
The `getInjection` function signature:
```typescript
export function getInjection<K extends keyof typeof DI_SYMBOLS>(
symbol: K
): DI_RETURN_TYPES[K] {
return ApplicationContainer.get(DI_SYMBOLS[symbol]);
}
```
This provides full type safety: if you pass `"IUsersRepository"`, the return type is `IUsersRepository`. If you pass an invalid key, TypeScript reports a compile error.
---
## DO NOT
- Import from apps/*
- Import framework-specific code (Next.js, TanStack, etc.)
- Use the container outside of this package — expose via `getInjection()` only
| Do Not | Why |
|---|---|
| Import from `apps/*` or framework packages (Next.js, TanStack) | DI is framework-agnostic. Framework code lives in apps. |
| Use the container outside of `@repo/core` | All external access goes through `getInjection()` or exported use case / controller functions. |
| Call `ApplicationContainer.get()` directly from use cases | Use `getInjection()` instead -- it provides type safety and a consistent API. |
| Forget to unload modules in `destroyContainer()` | Causes test state pollution (mock data leaks between tests). |
| Remove `import "reflect-metadata"` from `container.ts` | InversifyJS uses runtime reflection to read constructor parameter types. Without this import, `@inject()` decorators silently fail. |
## tsconfig Requirements (DO NOT REMOVE)
---
- `experimentalDecorators: true`
- `emitDecoratorMetadata: true`
- `types: ["reflect-metadata"]`
- `import "reflect-metadata"` at top of container.ts
## tsconfig Requirements
These settings are required for InversifyJS and MUST NOT be removed:
| Setting | Where | Why |
|---|---|---|
| `experimentalDecorators: true` | `@repo/typescript-config/base.json` | Enables `@injectable()` and `@inject()` decorator syntax used by InversifyJS |
| `emitDecoratorMetadata: true` | `@repo/typescript-config/base.json` | Emits runtime type metadata that InversifyJS reads to auto-resolve constructor parameter types |
| `types: ["reflect-metadata", "node"]` | `packages/core/tsconfig.json` | Makes `reflect-metadata` type definitions globally available. Required for `emitDecoratorMetadata` to function |
| `import "reflect-metadata"` | Top of `container.ts` and every test file | Polyfills the `Reflect.metadata` API at runtime. Without this, decorator metadata is not stored and `@inject()` silently injects `undefined` |
If any of these are removed, InversifyJS will throw errors like:
- `"No matching bindings found"` (metadata not emitted)
- `"Missing required @injectable annotation"` (decorators not enabled)
- `"Cannot read properties of undefined"` (reflect-metadata not imported)
---
## File Structure
```
di/
AGENTS.md
types.ts <-- DI_SYMBOLS + DI_RETURN_TYPES
container.ts <-- ApplicationContainer, initializeContainer, destroyContainer, getInjection
modules/
auth.module.ts <-- Binds IUsersRepository, IAuthenticationService, ITelemetryService
content.module.ts <-- Binds IArticlesRepository
```
---
## Cross-References
- `application/AGENTS.md` -- Defines the interfaces that are bound here
- `infrastructure/AGENTS.md` -- Provides the implementations that are bound here
- Root `AGENTS.md` -- Shows how DI fits into the full feature recipe

View File

@@ -1,33 +1,212 @@
# Entities Layer Innermost, Zero Dependencies
# Entities Layer -- Innermost, Zero Dependencies
**Path:** `packages/core/src/entities/`
**Role:** Define the pure domain types (models) and domain error classes. This is the innermost layer of Clean Architecture. Nothing here has side effects, I/O, or async behavior.
---
## Rules
- NEVER import from application/, infrastructure/, interface-adapters/, or di/
- NEVER import external libraries except Zod (for schema validation)
- Everything here is pure — no side effects, no I/O, no async
- Models are Zod schemas with inferred TypeScript types
- Errors are custom Error subclasses with domain-specific semantics
1. **NEVER** import from `application/`, `infrastructure/`, `interface-adapters/`, or `di/`.
2. **NEVER** import external libraries except `zod` (for schema validation in models).
3. Everything is **pure** -- no side effects, no I/O, no `async`, no `fetch`, no database calls.
4. Models are Zod schemas with inferred TypeScript types.
5. Errors are custom `Error` subclasses with domain-specific semantics.
6. Error classes always accept `(message: string, options?: ErrorOptions)` to support error chaining via `cause`.
## Adding a New Model
---
1. Create `src/entities/models/{name}.ts`
2. Define Zod schema and export inferred type:
```typescript
import { z } from "zod";
export const {name}Schema = z.object({ ... });
export type {Name} = z.infer<typeof {name}Schema>;
```
3. Export from `src/entities/models/index.ts`
## Existing Models
## Adding a New Error
| Model | File | Schema | Fields |
|---|---|---|---|
| User | `models/user.ts` | `userSchema` | `id`, `username`, `passwordHash` |
| Article | `models/article.ts` | `articleSchema` | `id`, `title`, `slug`, `content`, `status` ("draft"/"published"), `authorId`, `createdAt`, `updatedAt` |
| Session | `models/session.ts` | `sessionSchema` | `id`, `userId`, `expiresAt` |
| Cookie | `models/cookie.ts` | (plain type, no Zod) | `name`, `value`, `attributes` (secure, path, domain, sameSite, httpOnly, maxAge, expires) |
1. Create or edit `src/entities/errors/{domain}.ts`
2. Extend Error with constructor accepting message + options:
```typescript
export class {Name}Error extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
```
3. Export from `src/entities/errors/index.ts`
---
## Existing Errors
| Error Class | File | When Thrown |
|---|---|---|
| `AuthenticationError` | `errors/auth.ts` | Wrong credentials during sign-in, or username already taken during sign-up |
| `UnauthenticatedError` | `errors/auth.ts` | Invalid or expired session when validating authentication |
| `UnauthorizedError` | `errors/auth.ts` | User lacks permission for the requested operation (future use) |
| `NotFoundError` | `errors/common.ts` | Requested resource does not exist (future use in update/delete operations) |
| `InputParseError` | `errors/common.ts` | Controller Zod validation fails. The `cause` property contains the `ZodError` for detailed field-level messages |
---
## Complete Model Template
Use this template when creating a new entity model:
```typescript
// src/entities/models/{name}.ts
import { z } from "zod";
// 1. Define the Zod schema with all validation rules
export const {name}Schema = z.object({
id: z.string(),
// ... add fields with Zod validators
createdAt: z.date(),
updatedAt: z.date(),
});
// 2. Infer the TypeScript type from the schema
export type {Name} = z.infer<typeof {name}Schema>;
// 3. If you have enum-like fields, define them separately for reuse:
// export const {name}StatusSchema = z.enum(["active", "inactive"]);
// export type {Name}Status = z.infer<typeof {name}StatusSchema>;
```
Real example from `article.ts`:
```typescript
import { z } from "zod";
export const articleStatusSchema = z.enum(["draft", "published"]);
export const articleSchema = z.object({
id: z.string(),
title: z.string().min(1).max(255),
slug: z.string().min(1).max(255),
content: z.string(),
status: articleStatusSchema.default("draft"),
authorId: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export type Article = z.infer<typeof articleSchema>;
export type ArticleStatus = z.infer<typeof articleStatusSchema>;
```
---
## Complete Error Template
Use this template when creating a new error class:
```typescript
// src/entities/errors/{domain}.ts
export class {Name}Error extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
```
Real example from `auth.ts`:
```typescript
export class AuthenticationError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export class UnauthenticatedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
export class UnauthorizedError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
}
}
```
The `options?: ErrorOptions` parameter is important -- it enables error chaining. Controllers pass Zod errors as `cause`:
```typescript
throw new InputParseError("Invalid data", { cause: zodError });
```
---
## Adding a New Model (Step-by-Step)
### Step 1: Create the model file
Create `src/entities/models/{name}.ts` following the template above.
### Step 2: Export from the models barrel
Edit `src/entities/models/index.ts` and add:
```typescript
export { {name}Schema, type {Name} } from "./{name}";
```
### Step 3: Verify the export chain
The chain `models/index.ts` -> `entities/index.ts` -> `core/src/index.ts` uses `export *` at each level, so you only need to update `models/index.ts`. The exports will automatically flow through:
- `src/entities/index.ts` contains: `export * from "./models/index";`
- `src/index.ts` contains: `export * from "./entities/index";`
### Step 4: Use in application layer
The model type is now available in repository interfaces, use cases, and controllers via:
```typescript
import type { {Name} } from "@/entities/models/{name}";
```
---
## Adding a New Error (Step-by-Step)
### Step 1: Create or edit the error file
If adding to an existing domain (e.g., auth), edit `src/entities/errors/auth.ts`.
If creating a new domain, create `src/entities/errors/{domain}.ts`.
### Step 2: Export from the errors barrel
Edit `src/entities/errors/index.ts`:
```typescript
export { {Name}Error } from "./{domain}";
```
### Step 3: Verify the export chain
Same chain as models: `errors/index.ts` -> `entities/index.ts` -> `core/src/index.ts`. You only need to update `errors/index.ts`.
### Step 4: Use in use cases and controllers
```typescript
import { {Name}Error } from "@/entities/errors/{domain}";
// In a use case:
if (somethingWrong) {
throw new {Name}Error("Descriptive message");
}
```
---
## File Structure
```
entities/
AGENTS.md
index.ts <-- re-exports models/* and errors/*
models/
index.ts <-- barrel: exports all models
user.ts
article.ts
session.ts
cookie.ts
errors/
index.ts <-- barrel: exports all errors
auth.ts <-- AuthenticationError, UnauthenticatedError, UnauthorizedError
common.ts <-- NotFoundError, InputParseError
```

View File

@@ -1,24 +1,261 @@
# Infrastructure Layer Implementations
# Infrastructure Layer -- Concrete Implementations
**Path:** `packages/core/src/infrastructure/`
**Role:** Provide concrete implementations of the abstract interfaces defined in `application/`. Every repository interface and service interface gets at least one implementation here. All implementations use the `@injectable()` decorator for InversifyJS DI.
---
## Rules
- Implements interfaces from application/
- Imports from application/ and entities/
- NEVER imported by application/ or entities/
- Can import external libraries (Drizzle, Better Auth, Sentry, etc.)
- Can import @repo/cms-client
- Always provide a mock implementation for every real implementation
1. Implements interfaces from `application/` (repository or service interfaces).
2. Imports from `application/` (interfaces) and `entities/` (types, errors).
3. **NEVER** imported by `application/` or `entities/`. The dependency arrow points inward: infrastructure depends on application, not the reverse.
4. **NEVER** imported by `interface-adapters/` (controllers). Controllers use interfaces via DI.
5. Can import external libraries (Payload client, Better Auth, Sentry, etc.).
6. Can import `@repo/cms-client` -- this is the bridge to Payload CMS data. Note: `@repo/cms-client` is standalone with zero monorepo dependencies.
7. Every implementation class MUST have the `@injectable()` decorator. Without it, InversifyJS cannot construct the class.
8. Always provide a **mock implementation** for every real implementation. Tests run with mocks; production runs with real implementations.
## Naming
---
- Real: `{provider}-{name}.repository.ts` (e.g., `payload-users.repository.ts`)
- Mock: `mock-{name}.repository.ts`
- All implementations must use `@injectable()` decorator for InversifyJS
## Existing Implementations
## Adding a New Implementation
| Implementation | File | Implements | Type |
|---|---|---|---|
| `MockUsersRepository` | `repositories/mock-users.repository.ts` | `IUsersRepository` | Mock (in-memory array, pre-seeded with alice + bob) |
| `MockArticlesRepository` | `repositories/mock-articles.repository.ts` | `IArticlesRepository` | Mock (in-memory array, starts empty) |
| `MockAuthenticationService` | `services/mock-auth.service.ts` | `IAuthenticationService` | Mock (hashed_password prefix, in-memory session store) |
| `MockTelemetryService` | `services/mock-telemetry.service.ts` | `ITelemetryService` | Mock (no-op, just calls the wrapped function) |
1. Create `src/infrastructure/repositories/{provider}-{name}.repository.ts`
2. Implement the interface from application/
3. Add `@injectable()` decorator
4. Create corresponding mock: `mock-{name}.repository.ts`
5. Register both in DI module (production binds real, test binds mock)
The `MockAuthenticationService` is notable because it uses constructor injection:
```typescript
@injectable()
export class MockAuthenticationService implements IAuthenticationService {
constructor(
@inject(DI_SYMBOLS.IUsersRepository)
private _usersRepository: IUsersRepository
) {}
// ...
}
```
This demonstrates how InversifyJS resolves nested dependencies automatically. When the container creates `MockAuthenticationService`, it first resolves `IUsersRepository` and injects it.
---
## Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Production repository | `{provider}-{name}.repository.ts` | `payload-users.repository.ts` |
| Mock repository | `mock-{name}.repository.ts` | `mock-users.repository.ts` |
| Production service | `{provider}-{name}.service.ts` | `better-auth.service.ts`, `otel-telemetry.service.ts` |
| Mock service | `mock-{name}.service.ts` | `mock-auth.service.ts` |
The `{provider}` prefix identifies the external system: `payload`, `better`, `otel`, `sentry`, etc.
---
## Implementation Template (Repository)
```typescript
// src/infrastructure/repositories/{provider}-{name}.repository.ts
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class ProviderMyRepository implements IMyRepository {
// Constructor can accept injected dependencies if needed:
// constructor(
// @inject(DI_SYMBOLS.ISomeDep) private someDep: ISomeDep
// ) {}
async findById(id: string): Promise<MyEntity | undefined> {
// Call external system (database, API, etc.)
throw new Error("Not implemented");
}
async findAll(): Promise<MyEntity[]> {
throw new Error("Not implemented");
}
async create(input: MyEntity): Promise<MyEntity> {
throw new Error("Not implemented");
}
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
throw new Error("Not implemented");
}
}
```
---
## Mock Implementation Template
```typescript
// src/infrastructure/repositories/mock-{name}.repository.ts
import { injectable } from "inversify";
import type { IMyRepository } from "@/application/repositories/my.repository.interface";
import type { MyEntity } from "@/entities/models/my-entity";
@injectable()
export class MockMyRepository implements IMyRepository {
private _items: MyEntity[] = [];
async findById(id: string): Promise<MyEntity | undefined> {
return this._items.find((item) => item.id === id);
}
async findAll(): Promise<MyEntity[]> {
return [...this._items];
}
async create(input: MyEntity): Promise<MyEntity> {
this._items.push(input);
return input;
}
async update(id: string, input: Partial<MyEntity>): Promise<MyEntity | undefined> {
const index = this._items.findIndex((item) => item.id === id);
if (index === -1) return undefined;
this._items[index] = { ...this._items[index]!, ...input };
return this._items[index];
}
}
```
Mock pattern notes:
- Use `private _items: MyEntity[] = []` for in-memory storage.
- Spread arrays (`[...this._items]`) to avoid returning mutable references.
- Use `Array.find` for single lookups, `Array.filter` for queries.
- Use `Array.findIndex` + splice/spread for updates.
- Pre-seed data in the array initializer if tests need existing records (see `MockUsersRepository` which pre-seeds alice and bob).
---
## Adding a New Implementation (Recipe)
### Step 1: Create the implementation file
Follow the implementation template above. Place it in:
- `repositories/` for data access implementations
- `services/` for external service implementations
### Step 2: Ensure `@injectable()` is present
```typescript
import { injectable } from "inversify";
@injectable()
export class MyImplementation implements IMyInterface {
// ...
}
```
If the class needs another DI dependency injected via constructor, use `@inject()`:
```typescript
import { inject, injectable } from "inversify";
import { DI_SYMBOLS } from "@/di/types";
@injectable()
export class MyImplementation implements IMyInterface {
constructor(
@inject(DI_SYMBOLS.IOtherDependency)
private _otherDep: IOtherDependency
) {}
}
```
### Step 3: Create or verify the mock implementation
If this is a production implementation, verify that a corresponding mock already exists. If adding a new interface, create the mock at the same time.
### Step 4: Register in the DI module
Update the appropriate module in `di/modules/` to bind the implementation:
```typescript
// For development/mock:
bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(MockMyImplementation);
// For production (when adding real implementations):
// if (process.env.NODE_ENV === "production") {
// bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(RealMyImplementation);
// } else {
// bind<IMyInterface>(DI_SYMBOLS.IMyInterface).to(MockMyImplementation);
// }
```
### Step 5: Test via use cases
Infrastructure implementations are tested indirectly through use case tests. The DI container wires mock implementations in test mode. Run `pnpm vitest run` to verify.
---
## Note on @repo/cms-client
The `@repo/cms-client` package is standalone. It provides a `PayloadClient` interface with two modes:
- **Local mode**: Direct Payload SDK access (for server-side code co-located with Payload)
- **HTTP mode**: REST API calls (for code running separately from Payload)
Infrastructure implementations can import and use `@repo/cms-client` to bridge Payload CMS data into Clean Architecture:
```typescript
import { injectable } from "inversify";
import { createPayloadClient, type PayloadClient } from "@repo/cms-client";
import type { IArticlesRepository } from "@/application/repositories/articles.repository.interface";
import type { Article } from "@/entities/models/article";
@injectable()
export class PayloadArticlesRepository implements IArticlesRepository {
private client: PayloadClient;
constructor() {
this.client = createPayloadClient({
mode: "http",
baseURL: process.env.CMS_URL ?? "http://localhost:3001",
});
}
async getArticles(options?: { status?: string }): Promise<Article[]> {
const result = await this.client.find<Article>("articles", {
where: options?.status ? { status: { equals: options.status } } : undefined,
});
return result.docs;
}
// ... other methods
}
```
This is the intended pattern for production implementations. The infrastructure layer owns the translation between Payload's data format and the core entity types.
---
## File Structure
```
infrastructure/
AGENTS.md
repositories/
mock-users.repository.ts
mock-articles.repository.ts
services/
mock-auth.service.ts
mock-telemetry.service.ts
```
---
## Cross-References
- `application/AGENTS.md` -- Defines the interfaces that implementations here must satisfy
- `entities/AGENTS.md` -- Types used in implementation method signatures
- `di/AGENTS.md` -- Where implementations are bound to their interface symbols

View File

@@ -1,29 +1,279 @@
# Controllers Interface Adapters
# Controllers -- Interface Adapters Layer
**Path:** `packages/core/src/interface-adapters/controllers/`
**Role:** Controllers are the outermost layer within `@repo/core`. They validate external input using Zod schemas, delegate to use cases for business logic, and return results. Controllers are called by tRPC router procedures in `@repo/api`.
---
## Rules
- Controllers validate input using Zod schemas from entities/
- Controllers call use cases NEVER contain business logic themselves
- Controllers handle error mapping (domain errors to appropriate responses)
- Import from application/ and entities/ only
- NEVER import from infrastructure/
1. Controllers validate input with Zod `safeParse` and throw `InputParseError` on failure.
2. Controllers call use cases -- they **NEVER** contain business logic themselves.
3. Controllers import from `application/` (use cases) and `entities/` (types, errors) only.
4. Controllers **NEVER** import from `infrastructure/`. They must not know about concrete data access.
5. Controllers **NEVER** import from `di/` directly. They call use case functions, which internally use `getInjection()`.
6. Each controller function is a plain `async function`, not a class. This keeps them lightweight and easy to call from any transport layer (tRPC, REST, GraphQL).
7. Input parameters use `Partial<z.infer<typeof schema>>` to accept potentially incomplete input, then validate with `safeParse`.
## Pattern
---
## Existing Controllers
| Controller | File | Functions | Calls |
|---|---|---|---|
| Sign In | `auth/sign-in.controller.ts` | `signInController(input)` | `signInUseCase` |
| Sign Up | `auth/sign-up.controller.ts` | `signUpController(input)` | `signUpUseCase` |
| Sign Out | `auth/sign-out.controller.ts` | `signOutController(sessionId)` | `signOutUseCase` |
| Articles | `content/articles.controller.ts` | `createArticleController(input)`, `getArticlesController(input)` | `createArticleUseCase`, `getArticlesUseCase` |
---
## Complete Controller Template
```typescript
const inputSchema = z.object({ ... });
// src/interface-adapters/controllers/{domain}/{name}.controller.ts
import { z } from "zod";
export async function myController(input: Partial<z.infer<typeof inputSchema>>) {
const { data, error } = inputSchema.safeParse(input);
if (error) throw new InputParseError("Invalid data", { cause: error });
import { InputParseError } from "@/entities/errors/common";
import type { MyEntity } from "@/entities/models/my-entity";
import { myUseCase } from "@/application/use-cases/{domain}/my.use-case";
// 1. Define input validation schema
const inputSchema = z.object({
field1: z.string().min(1).max(255),
field2: z.number().positive(),
optionalField: z.string().optional(),
});
// 2. Export the controller function
export async function myController(
input: Partial<z.infer<typeof inputSchema>>
): Promise<MyEntity> {
// 3. Validate input with safeParse
const { data, error: inputParseError } = inputSchema.safeParse(input);
// 4. Throw InputParseError if validation fails
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
// 5. Delegate to use case and return result
return await myUseCase(data);
}
```
## Adding a New Controller
---
1. Create `src/interface-adapters/controllers/{domain}/{name}.controller.ts`
2. Define Zod input schema
3. Validate input with `safeParse`, throw `InputParseError` on failure
4. Call use case and return result
5. Write test in `tests/unit/controllers/{domain}/`
## Error Mapping Pattern
Controllers handle two categories of errors:
### 1. Input Validation Errors (thrown by the controller itself)
When Zod `safeParse` fails, the controller throws `InputParseError` with the `ZodError` as `cause`:
```typescript
const { data, error: inputParseError } = inputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
```
The tRPC router (or any other caller) can inspect `error.cause` to extract field-level validation messages.
### 2. Business Logic Errors (thrown by use cases, pass through the controller)
Use cases throw domain errors (`AuthenticationError`, `NotFoundError`, etc.). Controllers do NOT catch these -- they propagate to the caller. The tRPC error handler maps them to appropriate HTTP status codes.
### Error Flow
```
Controller
|
+--> InputParseError (Zod validation failed) -> 400 Bad Request
|
+--> Use Case
|
+--> AuthenticationError -> 401 Unauthorized
+--> UnauthenticatedError -> 401 Unauthorized
+--> UnauthorizedError -> 403 Forbidden
+--> NotFoundError -> 404 Not Found
+--> (unexpected) -> 500 Internal Server Error
```
---
## Real Examples
### Simple validation (sign-in.controller.ts)
```typescript
import { z } from "zod";
import { InputParseError } from "@/entities/errors/common";
import type { Cookie } from "@/entities/models/cookie";
import { signInUseCase } from "@/application/use-cases/auth/sign-in.use-case";
const inputSchema = z.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
});
export async function signInController(
input: Partial<z.infer<typeof inputSchema>>
): Promise<Cookie> {
const { data, error: inputParseError } = inputSchema.safeParse(input);
if (inputParseError) {
throw new InputParseError("Invalid data", { cause: inputParseError });
}
const { cookie } = await signInUseCase(data);
return cookie;
}
```
### Cross-field validation (sign-up.controller.ts)
```typescript
const inputSchema = z
.object({
username: z.string().min(3).max(31),
password: z.string().min(6).max(255),
confirmPassword: z.string().min(6).max(255),
})
.superRefine(({ password, confirmPassword }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["password"],
});
ctx.addIssue({
code: "custom",
message: "The passwords did not match",
path: ["confirmPassword"],
});
}
});
```
### Multiple functions in one file (articles.controller.ts)
When a domain has related operations, group them in one controller file:
```typescript
const createInputSchema = z.object({ title: z.string().min(1), /* ... */ });
const getInputSchema = z.object({ status: z.string().optional(), /* ... */ });
export async function createArticleController(input) { /* ... */ }
export async function getArticlesController(input) { /* ... */ }
```
---
## Adding a New Controller (Recipe)
### Step 1: Create the controller file
Create `src/interface-adapters/controllers/{domain}/{name}.controller.ts` following the template.
### Step 2: Define the Zod input schema
Define validation rules that match what the tRPC router will pass in. Use `.safeParse()`, not `.parse()`.
### Step 3: Implement the controller function
Validate -> delegate to use case -> return result. No business logic.
### Step 4: Export from core
Add to `src/index.ts`:
```typescript
export { myController } from "./interface-adapters/controllers/{domain}/{name}.controller";
```
### Step 5: Create tRPC router procedure
In `packages/api/src/router/{domain}.router.ts`, import the controller from `@repo/core` and wire it:
```typescript
import { myController } from "@repo/core";
myProcedure: publicProcedure
.input(z.object({ /* same shape as controller schema */ }))
.mutation(async ({ input }) => {
return await myController(input);
}),
```
### Step 6: Write tests
Create `tests/unit/controllers/{domain}/{name}.controller.test.ts`:
```typescript
import "reflect-metadata";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
destroyContainer,
initializeContainer,
} from "@/di/container";
import { myController } from "@/interface-adapters/controllers/{domain}/{name}.controller";
import { InputParseError } from "@/entities/errors/common";
beforeEach(() => {
initializeContainer();
});
afterEach(() => {
destroyContainer();
});
describe("myController", () => {
it("succeeds with valid input", async () => {
const result = await myController({
field1: "valid",
field2: 42,
});
expect(result).toBeDefined();
});
it("throws InputParseError for invalid input", async () => {
await expect(
myController({ field1: "" }) // missing required fields
).rejects.toBeInstanceOf(InputParseError);
});
it("propagates domain errors from use case", async () => {
await expect(
myController({ /* input that triggers domain error */ })
).rejects.toBeInstanceOf(SomeDomainError);
});
});
```
---
## File Structure
```
interface-adapters/
controllers/
AGENTS.md
auth/
sign-in.controller.ts
sign-up.controller.ts
sign-out.controller.ts
content/
articles.controller.ts
```
---
## Cross-References
- `application/AGENTS.md` -- Use cases that controllers delegate to
- `entities/AGENTS.md` -- Error classes and entity types used in controllers
- Root `AGENTS.md` -- How controllers fit into the full data flow
- `packages/api/AGENTS.md` -- tRPC routers that call controllers

View File

@@ -1,59 +1,345 @@
# @repo/ui Atomic Design Component Library
# @repo/ui -- Atomic Design Component Library
shadcn/ui + Tailwind CSS v4 + Atomic Design. Components organized by level with co-located stories.
## Purpose
## Atomic Classification Guide
Shared UI component library built with shadcn/ui patterns, Tailwind CSS v4, and organized by Atomic Design levels. All components have co-located Storybook stories. This package is consumed by all frontend apps (`apps/web-next`, `apps/web-tanstack`).
| Level | Definition | Examples |
|---|---|---|
| Atom | Single element, can't break down further | Button, Input, Label, Badge, Separator |
| Molecule | 2-3 atoms, single responsibility | FormField, SearchBar, Tooltip, Select |
| Organism | Complex section, self-contained | DataTable, Dialog, Header, Sidebar, Card |
| Template | Page layout, content-agnostic | DashboardLayout, AuthLayout |
| Page | Template + real data | **LIVES IN apps/, NOT HERE** |
## Atomic Design Classification Guide
## Import Rules
| Level | Definition | Examples | Import Rules |
|---|---|---|---|
| Atom | Single HTML element, cannot be broken down further | Button, Input, Label, Badge, Separator, Icon | Can import: `lib/`, `styles/`. NEVER import: molecules, organisms, templates |
| Molecule | 2-3 atoms composed together, single responsibility | FormField, SearchBar, Tooltip, Select, Dropdown | Can import: `atoms/`, `lib/`. NEVER import: organisms, templates |
| Organism | Complex self-contained section with multiple molecules/atoms | DataTable, Dialog, Header, Sidebar, Card, Navbar | Can import: `atoms/`, `molecules/`, `lib/`. NEVER import: templates |
| Template | Page-level layout shell, content-agnostic (uses children/slots) | DashboardLayout, AuthLayout, MarketingLayout | Can import: `atoms/`, `molecules/`, `organisms/`, `lib/` |
| Page | Template filled with real data | **LIVES IN `apps/`, NOT IN THIS PACKAGE** | N/A |
## Import Rules Table
| Level | Can import from | NEVER import from |
|---|---|---|
| Atoms | lib/, hooks/, styles/ | molecules/, organisms/, templates/ |
| Molecules | atoms/, lib/, hooks/ | organisms/, templates/ |
| Organisms | atoms/, molecules/, lib/, hooks/ | templates/ |
| Templates | atoms/, molecules/, organisms/, lib/, hooks/ | (top level) |
| Atoms | `lib/`, `hooks/`, `styles/` | `molecules/`, `organisms/`, `templates/` |
| Molecules | `atoms/`, `lib/`, `hooks/` | `organisms/`, `templates/` |
| Organisms | `atoms/`, `molecules/`, `lib/`, `hooks/` | `templates/` |
| Templates | `atoms/`, `molecules/`, `organisms/`, `lib/`, `hooks/` | (nothing above -- top level) |
## Component Rules
## Component Rules Per Level
- **Atoms:** No margins/positioning, no state, no business logic
- **Molecules:** Single responsibility, minimal controlled state
- **Organisms:** Can have internal state and sub-components
- **Templates:** Use children/slots, NEVER hard-code content
- **All:** Co-locate `.stories.tsx` next to component
- **Atoms:** No margins or positioning (consumer controls layout). No internal state. No business logic. Accept `className` prop for composition. Use `forwardRef` for DOM elements.
- **Molecules:** Single responsibility. Minimal controlled state (e.g., open/closed). Compose atoms only. Accept `className` for outer container.
- **Organisms:** Can have internal state and sub-components. Can fetch context. Self-contained sections of a page.
- **Templates:** Use `children` or named slots (`sidebar`, `header`, etc.) for content injection. NEVER hard-code content or data.
## File Structure
```
packages/ui/
src/
lib/
utils.ts # cn() utility (clsx + twMerge)
styles/
globals.css # Tailwind v4 @theme tokens, @import "tailwindcss"
atoms/
button/
button.tsx # Button component (5 variants, 3 sizes)
button.stories.tsx # Storybook stories
index.ts # Barrel export
input/
input.tsx # Input component
input.stories.tsx
index.ts
label/
label.tsx # Label component
index.ts
index.ts # Barrel: re-exports all atoms
molecules/
form-field/
form-field.tsx # FormField = Label + Input + error/description
form-field.stories.tsx
index.ts
index.ts # Barrel: re-exports all molecules
organisms/
index.ts # Barrel (empty -- no organisms yet)
templates/
index.ts # Barrel (empty -- no templates yet)
index.ts # Package entry: re-exports cn + all levels
package.json
AGENTS.md
```
## Existing Components
### Button (Atom)
- **Variants:** `default`, `secondary`, `destructive`, `outline`, `ghost`
- **Sizes:** `sm` (h-9), `default` (h-10), `lg` (h-11)
- **Props:** Extends `ButtonHTMLAttributes<HTMLButtonElement>` plus `variant` and `size`
- **Uses:** `forwardRef`, `cn()` for class merging
### Input (Atom)
- **Props:** Extends `InputHTMLAttributes<HTMLInputElement>`
- **Uses:** `forwardRef`, `cn()` for class merging
- Full styling: border, focus ring, disabled state, file input support
### Label (Atom)
- **Props:** Extends `LabelHTMLAttributes<HTMLLabelElement>`
- **Uses:** `forwardRef`, `cn()` for class merging
- Handles `peer-disabled` state
### FormField (Molecule)
- **Props:** Extends `InputProps` plus `label` (string), `error?` (string), `description?` (string)
- **Composes:** Label + Input
- Auto-generates `id` from label text if not provided
## cn() Utility
The `cn()` function merges Tailwind classes safely using `clsx` + `tailwind-merge`:
```typescript
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
Usage in components:
```tsx
<button
className={cn(
"inline-flex items-center justify-center rounded-md", // base classes
variantStyles[variant], // variant classes
sizeStyles[size], // size classes
className // consumer override
)}
/>
```
`cn()` ensures that consumer-provided classes properly override component defaults (e.g., `cn("bg-red-500", "bg-blue-500")` yields `"bg-blue-500"`).
## Tailwind v4 CSS-First Configuration
This project uses **Tailwind CSS v4**, which replaces `tailwind.config.ts` with CSS-first configuration. There is no `tailwind.config.ts` file. All design tokens are defined in `src/styles/globals.css` using the `@theme` directive:
```css
@import "tailwindcss";
@theme {
--color-background: hsl(0 0% 100%);
--color-foreground: hsl(240 10% 3.9%);
--color-primary: hsl(240 5.9% 10%);
--color-primary-foreground: hsl(0 0% 98%);
--color-secondary: hsl(240 4.8% 95.9%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-border: hsl(240 5.9% 90%);
--color-input: hsl(240 5.9% 90%);
--color-ring: hsl(240 5.9% 10%);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}
```
To add new tokens, add them inside the `@theme { }` block in `globals.css`. Classes like `bg-primary`, `text-destructive`, `rounded-lg` reference these tokens automatically.
## shadcn/ui Workflow
1. `pnpm dlx shadcn@latest add [component]` — lands in atoms/ by default
2. Check classification guide above
3. If not atom → move to correct directory
4. Create `.stories.tsx` with title: `"{Level}/{ComponentName}"`
5. Update level's `index.ts` barrel
When adding a new shadcn/ui component:
## Story Template
1. **Install:** `pnpm dlx shadcn@latest add [component]` -- it lands in `atoms/` by default
2. **Classify:** Check the Atomic Design classification table above
3. **Relocate:** If the component is not an atom, move it to the correct level directory
4. **Story:** Create a `.stories.tsx` file next to the component
5. **Export:** Add to the level's `index.ts` barrel file
## Storybook MCP Integration
Before creating any new UI component, query the Storybook MCP to check for existing components:
- **`list-all-documentation`** -- discover all existing components and their stories
- **`get-documentation`** -- understand existing component props, variants, and usage
- **`run-story-tests`** -- validate your new story renders correctly after creation
Storybook MCP is available at `http://localhost:6006/mcp` when Storybook is running.
## Complete Story File Template
```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { MyComponent } from "./my-component.js";
import { MyComponent } from "./my-component";
const meta = {
title: "{Level}/{ComponentName}",
title: "{Level}/{ComponentName}", // e.g., "Atoms/Button", "Molecules/FormField"
component: MyComponent,
tags: ["autodocs"],
argTypes: {
// Define controls for interactive props
variant: {
control: "select",
options: ["default", "secondary"],
},
size: {
control: "select",
options: ["sm", "default", "lg"],
},
disabled: { control: "boolean" },
},
} satisfies Meta<typeof MyComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: "Default",
},
};
export const AnotherVariant: Story = {
args: {
children: "Another Variant",
variant: "secondary",
},
};
```
## Storybook MCP
## Recipe: Adding a New Component (8 Steps)
Before creating UI components, query Storybook MCP:
- `list-all-documentation` — check for existing components
- `get-documentation` — understand props/variants
- After creating: `run-story-tests` to validate
This example adds a `Badge` atom component.
### Step 1: Check Storybook MCP for existing components
Query `list-all-documentation` to confirm no Badge component exists.
### Step 2: Classify the component
Badge is a single HTML element displaying a short label -- it is an **Atom**.
### Step 3: Create the component directory
```
src/atoms/badge/
badge.tsx
badge.stories.tsx
index.ts
```
### Step 4: Write the component
`src/atoms/badge/badge.tsx`:
```tsx
import { type HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: "default" | "secondary" | "destructive" | "outline";
}
const variantStyles: Record<NonNullable<BadgeProps["variant"]>, string> = {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
destructive: "bg-destructive text-destructive-foreground",
outline: "border border-input bg-background text-foreground",
};
export function Badge({
className,
variant = "default",
...props
}: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
variantStyles[variant],
className
)}
{...props}
/>
);
}
```
### Step 5: Create the barrel export
`src/atoms/badge/index.ts`:
```typescript
export { Badge, type BadgeProps } from "./badge";
```
### Step 6: Write the story
`src/atoms/badge/badge.stories.tsx`:
```tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "./badge";
const meta = {
title: "Atoms/Badge",
component: Badge,
tags: ["autodocs"],
argTypes: {
variant: {
control: "select",
options: ["default", "secondary", "destructive", "outline"],
},
},
} satisfies Meta<typeof Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { children: "Badge" },
};
export const Secondary: Story = {
args: { children: "Secondary", variant: "secondary" },
};
export const Destructive: Story = {
args: { children: "Error", variant: "destructive" },
};
export const Outline: Story = {
args: { children: "Outline", variant: "outline" },
};
```
### Step 7: Add to atoms barrel
Edit `src/atoms/index.ts`:
```typescript
export { Button, type ButtonProps } from "./button/index";
export { Input, type InputProps } from "./input/index";
export { Label, type LabelProps } from "./label/index";
export { Badge, type BadgeProps } from "./badge/index"; // <-- add
```
### Step 8: Validate
Run `run-story-tests` via Storybook MCP to confirm the stories render correctly.
## Dependencies
| Dependency | Purpose |
|---|---|
| `react` | JSX runtime |
| `clsx` | Conditional class string builder |
| `tailwind-merge` | Intelligent Tailwind class merging (deduplication) |
| `tailwindcss` (devDep) | Tailwind CSS v4 engine |
## Cross-References
- **Storybook app:** `apps/storybook/` -- see `apps/storybook/AGENTS.md`
- **Consumed by Next.js:** `apps/web-next/` -- see `apps/web-next/AGENTS.md`
- **Consumed by TanStack:** `apps/web-tanstack/` -- see `apps/web-tanstack/AGENTS.md`