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 ## Monorepo Package Map
| Package | Purpose | | Package | Path | Purpose | Depends On |
|---|---| |---|---|---|---|
| `@repo/core` | Clean architecture: entities, use cases, interfaces, DI | | `@repo/core` | `packages/core` | Clean Architecture business logic: entities, use cases, repository/service interfaces, controllers, InversifyJS DI container | `zod`, `inversify`, `reflect-metadata` |
| `@repo/api` | tRPC router definitions (calls core controllers) | | `@repo/api` | `packages/api` | tRPC v11 routers that call `@repo/core` controllers | `@repo/core`, `@trpc/server`, `zod` |
| `@repo/api-client` | Shared React Query hooks + ApiProvider | | `@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` | Payload CMS config, collections, hooks, globals | | `@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` | Dual-mode Payload client (local + HTTP) | | `@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` | shadcn/ui + Atomic Design component library | | `@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` | Shared ESLint flat configs | | `@repo/eslint-config` | `packages/eslint-config` | Shared ESLint 9 flat configs for the entire monorepo | (tooling) |
| `@repo/typescript-config` | Shared TypeScript configs | | `@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) | apps/web-next | | apps/web-tanstack|
@repo/core/infrastructure → @repo/cms-client (standalone) +--------+--------+ +--------+--------+
@repo/ui (standalone) | |
+---------+-----------+-----------+
| |
+-----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/` Every user interaction follows this path:
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/)
## 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) Example -- listing articles end-to-end:
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
## 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. tRPC router: packages/api/src/router/content.router.ts
2. Create: `index.ts` (CollectionConfig), `fields.ts`, optionally `hooks/`, `access/` contentRouter = router({
3. Import in `packages/cms-core/src/payload.config.ts` collections array listArticles: publicProcedure
4. Export from `packages/cms-core/src/index.ts` .input(z.object({ status: z.string().optional(), /* ... */ }).optional())
5. Hooks that contain business logic must delegate to use cases in `@repo/core/application` .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` ## Port: 3001
- 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
```bash ```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 ```bash
pnpm dev --filter @repo/storybook # Starts on port 6006 pnpm dev --filter @repo/storybook # http://localhost:6006
``` ```
## Configuration ## Configuration
- `.storybook/main.ts` — Framework config, story globs, Tailwind vite plugin ### `.storybook/main.ts`
- `.storybook/preview.ts` — Global CSS import, control matchers
```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 ## Story Organization
Stories are organized by Atomic Design level via title: Stories are organized by Atomic Design level via the `title` field in story metadata. The title determines the sidebar hierarchy in Storybook.
- `"Atoms/Button"`, `"Molecules/FormField"`, `"Organisms/DataTable"`
## MCP ### Story Title Convention
When running, Storybook MCP is available at `http://localhost:6006/mcp`. | Level | Title format | Example | Sidebar path |
Install `@storybook/addon-mcp` to enable. |---|---|---|---|
| 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 ## Key Files
- `src/app/api/trpc/[trpc]/route.ts` — tRPC HTTP endpoint (fetch adapter) | File | Purpose |
- `src/app/providers.tsx` — Wraps with `<ApiProvider>` |---|---|
- `src/app/layout.tsx` Root layout | `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 The file `src/app/api/trpc/[trpc]/route.ts` creates a catch-all API route that handles all tRPC requests:
- Use `@repo/ui` components for all UI
- tRPC endpoint imports `appRouter` from `@repo/api` ```typescript
- Payload instance initialization goes in `src/lib/payload.ts` 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 ## Key Files
- `src/routes/__root.tsx` — Root layout with `<ApiProvider>` | File | Purpose |
- `src/routes/index.tsx` — Home page |---|---|
| `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 TanStack Router uses file-based routing where file paths in `src/routes/` map directly to URL paths:
- Use `@repo/ui` components for all UI
- File-based routing via TanStack Router (`src/routes/`) | File | URL | Description |
- Payload instance initialization goes in `src/lib/payload.ts` |---|---|---|
| `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/) ## Hard Rules
- NEVER put business logic in hooks
- Hooks use `useTRPC()` from `./trpc.ts`
- Both Next.js and TanStack Start apps use the same `<ApiProvider>`
## 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 ## File Structure
import { ApiProvider, useTRPC } from "@repo/api-client";
// Root layout: ```
<ApiProvider trpcUrl="/api/trpc">{children}</ApiProvider> packages/api-client/
src/
// In components: trpc.ts # Creates TRPCProvider + useTRPC via createTRPCContext<AppRouter>()
const trpc = useTRPC(); query-client.ts # Singleton QueryClient factory (SSR-safe)
const articles = trpc.content.listArticles.useQuery({}); 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 ## Hard Rules
- Input validation uses Zod schemas
- Each domain gets its own router file
## 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` ## File Structure
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` packages/api/
5. Add router to root `appRouter` in `src/router/index.ts` 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/*` ## **NEVER import from `@repo/cms-core`, `@repo/core`, or any `apps/*` package.**
- The Payload instance is INJECTED, not imported
- Types are generated via `payload generate:types`
## Initialization This package is completely standalone. The Payload instance is INJECTED at app startup, never imported by this package.
| Context | Mode | How | ## File Structure
|---|---|---|
| apps/cms server | Local | `getPayload({config})` from @repo/cms-core | ```
| apps/web-next server | Local | `getPayload({config})` from @repo/cms-core | packages/cms-client/
| apps/web-tanstack server | Local | `getPayload({config})` from @repo/cms-core | src/
| Client-side (browser) | N/A | Goes through tRPC — server handles it | types.ts # FindOptions, PayloadClientResult, PayloadClient interface
| External services | HTTP | `createPayloadClient({mode:"http",baseURL})` | 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 ```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 { getPayload } from "payload";
import { config } from "@repo/cms-core"; import config from "@repo/cms-core/src/payload.config";
import { createPayloadClient } from "@repo/cms-client"; import { createPayloadClient } from "@repo/cms-client";
const payload = await getPayload({ config }); const payload = await getPayload({ config });
const client = createPayloadClient({ mode: "local", payload }); 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 ```typescript
- `findByID(collection, id, options)` — single document import { createPayloadClient } from "@repo/cms-client";
- `create(collection, data, options)` — create document
- `update(collection, id, data, options)` — update document const client = createPayloadClient({
- `delete(collection, id)` — delete document 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 ## Hook Rules
| Category | Location | Examples | | Category | Where it lives | Description | Examples |
|---|---|---| |---|---|---|---|
| CMS-operational | Stay in hook | Slugify, image resize, default values | | 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 | Delegate to use case | Notifications, validation, cross-domain updates | | 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 ### DO
- Keep hooks thin (max 5-10 lines) - Keep hooks to 5-10 lines max
- Import use cases from `@repo/core/application` - Import use cases from `@repo/core/application` (application layer only)
- Map Payload hook args to use case input types - Map Payload hook arguments (`data`, `operation`, `req`) to use case input types
- Return `data` from `beforeChange` / `beforeValidate` hooks
### DON'T ### DON'T
- Import from `@repo/core/infrastructure` - Import from `@repo/core/infrastructure` -- violates Clean Architecture
- Put business validation in hooks - Put business validation logic directly in hooks
- Call external services directly from hooks - Call external services (email, analytics, APIs) directly from hooks
- Duplicate logic that exists in a use case - 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}/` ## Recipe: Adding a New Collection
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`
## Adding a Hook That Calls a Use Case This example adds a `Tags` collection.
1. Create `src/collections/{name}/hooks/{hook-name}.ts` ### Step 1: Create the collection folder and fields
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
**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 | di/ |
interface-adapters/→ application/, entities/ | (wires everything together -- imports ALL internal layers) |
infrastructure/ → application/, entities/, @repo/cms-client, external libs +------------------------------------------------------------------+
di/ → 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 ## Import Rules
| Layer | Can import from | NEVER import from | | Layer | Can Import From | NEVER Import From |
|---|---|---| |---|---|---|
| entities/ | NOTHING | Everything else | | `entities/` | Nothing (Zod is the only external dependency) | `application/`, `infrastructure/`, `interface-adapters/`, `di/`, any `@repo/*`, any framework |
| application/ | entities/ only | infrastructure/, interface-adapters/ | | `application/` | `entities/` only | `infrastructure/`, `interface-adapters/`, `di/` (except `getInjection` from `di/container` in use cases) |
| interface-adapters/ | application/, entities/ | infrastructure/ | | `interface-adapters/` | `application/` (use cases), `entities/` (types, errors) | `infrastructure/`, any `@repo/*` except via DI |
| infrastructure/ | application/, entities/, @repo/cms-client | interface-adapters/, apps/* | | `infrastructure/` | `application/` (interfaces to implement), `entities/` (types), `@repo/cms-client` | `interface-adapters/`, apps/*, Next.js, TanStack |
| di/ | All internal layers | apps/* | | `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 ## DI Resolution Table
| Symbol | Interface | Production | Mock | | Symbol Key | Interface | Production Implementation | Mock Implementation |
|---|---|---|---| |---|---|---|---|
| IUsersRepository | IUsersRepository | PayloadUsersRepository (Plan 3) | MockUsersRepository | | `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future -- will use `@repo/cms-client`) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) |
| IArticlesRepository | IArticlesRepository | PayloadArticlesRepository (Plan 3) | MockArticlesRepository | | `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future -- will use `@repo/cms-client`) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) |
| IAuthenticationService | IAuthenticationService | BetterAuthService (future) | MockAuthenticationService | | `IAuthenticationService` | `IAuthenticationService` (generateUserId, hashPassword, verifyPassword, validateSession, createSession, invalidateSession) | BetterAuthService (future) | `MockAuthenticationService` (`infrastructure/services/mock-auth.service.ts`) |
| ITelemetryService | ITelemetryService | OTelSentryService (future) | MockTelemetryService | | `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 ## Naming Conventions
- Models: `{name}.ts` with Zod schema + type export | Type | Pattern | Example |
- Errors: `{domain}.ts` with Error subclasses |---|---|---|
- Interfaces: `{name}.repository.interface.ts` or `{name}.service.interface.ts` | Entity model | `{name}.ts` | `article.ts`, `user.ts` |
- Use cases: `{verb}-{noun}.use-case.ts` | Entity error | `{domain}.ts` | `auth.ts`, `common.ts` |
- Controllers: `{noun}.controller.ts` | Repository interface | `{name}.repository.interface.ts` | `users.repository.interface.ts` |
- Infra: `{provider}-{name}.repository.ts` or `mock-{name}.repository.ts` | Service interface | `{name}.service.interface.ts` | `auth.service.interface.ts` |
- DI modules: `{domain}.module.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 ## Running Tests
```bash ```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 ## Rules
- Imports from entities/ ONLY 1. Imports from `entities/` ONLY (for types, schemas, and error classes).
- NEVER imports from infrastructure/ or interface-adapters/ 2. **NEVER** imports from `infrastructure/` -- use cases depend on interfaces, not implementations.
- Repository interfaces define data access contracts 3. **NEVER** imports from `interface-adapters/` -- controllers call use cases, not the reverse.
- Service interfaces define external service contracts 4. Use cases obtain dependencies via `getInjection()` from `di/container` at runtime. This is the single allowed cross-layer dependency.
- Use cases get dependencies via `getInjection()` — never direct import of implementations 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` ## Existing Repository Interfaces
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
## 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` ## Existing Service Interfaces
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)
## 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) 1. **Passwords are always hashed.** Plain-text passwords never enter the repository. The `IAuthenticationService.hashPassword()` method handles hashing before storage.
- Sessions expire after 7 days (configured in mock, real impl may differ) 2. **Usernames must be unique.** Sign-up checks for existing username via `IUsersRepository.getUserByUsername()` before creating a new user.
- Sign-up requires unique username 3. **Sign-in requires valid credentials.** The user must exist AND the password must verify against the stored hash.
- Sign-in verifies password via IAuthenticationService.verifyPassword() 4. **Sessions are created on successful sign-in or sign-up.** `IAuthenticationService.createSession()` returns both a `Session` object and a `Cookie` for the client.
- Sign-out invalidates session and returns blank cookie 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 ## Error Cases
- `AuthenticationError` — wrong credentials (sign-in) or username taken (sign-up) | Error Class | When Thrown | Use Case |
- `UnauthenticatedError` — invalid/expired session |---|---|---|
| `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 ## Dependencies
- `IUsersRepository` — user lookup and creation ### IUsersRepository
- `IAuthenticationService` — password hashing, session management
## 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 | Method | Signature | Used By |
2. Get deps via `getInjection("IUsersRepository")`, `getInjection("IAuthenticationService")` |---|---|---|
3. Write test first in `tests/unit/use-cases/auth/` | `getUser` | `(id: string) => Promise<User \| undefined>` | `MockAuthenticationService.validateSession()` (indirectly) |
4. Use `initializeContainer()`/`destroyContainer()` pattern in tests | `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 1. **Articles must have a title and content.** These are required fields validated at the controller level via Zod schemas.
- Slugs auto-generated from title if not provided 2. **Slugs are auto-generated from the title if not provided.** The `generateSlug()` function in `createArticleUseCase` handles this.
- New articles default to "draft" status 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.
- Filtering by status, authorId supported 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).
- Pagination via limit/offset 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 ## Error Cases
- `NotFoundError` — article doesn't exist (future: update/delete) | Error Class | When Thrown | Use Case |
- `UnauthorizedError` — user can't edit article (future) |---|---|---|
- `InputParseError` — missing required fields (handled by controller) | `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 ## 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 | Method | Signature | Used By |
2. Get deps via `getInjection("IArticlesRepository")` |---|---|---|
3. Write test first in `tests/unit/use-cases/content/` | `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` | Symbol Key | Interface | Production Implementation | Mock Implementation | DI Module |
2. Add return type to `DI_RETURN_TYPES` interface |---|---|---|---|---|
3. Create module in `modules/{domain}.module.ts` | `IUsersRepository` | `IUsersRepository` (getUser, getUserByUsername, createUser) | PayloadUsersRepository (future) | `MockUsersRepository` (`infrastructure/repositories/mock-users.repository.ts`) | `auth.module.ts` |
4. Bind interface to implementation (production) and mock (test) | `IArticlesRepository` | `IArticlesRepository` (getArticle, getArticles, createArticle, updateArticle) | PayloadArticlesRepository (future) | `MockArticlesRepository` (`infrastructure/repositories/mock-articles.repository.ts`) | `content.module.ts` |
5. Load module in `container.ts` `initializeContainer()` and `destroyContainer()` | `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 ## Container Lifecycle
- Production: `initializeContainer()` runs automatically (not in test env) ### Production (non-test environments)
- Tests: Call `initializeContainer()` in `beforeEach`, `destroyContainer()` in `afterEach`
- Test environments swap to mock implementations via module bindings ```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 ## DO NOT
- Import from apps/* | Do Not | Why |
- Import framework-specific code (Next.js, TanStack, etc.) |---|---|
- Use the container outside of this package — expose via `getInjection()` only | 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` ## tsconfig Requirements
- `emitDecoratorMetadata: true`
- `types: ["reflect-metadata"]` These settings are required for InversifyJS and MUST NOT be removed:
- `import "reflect-metadata"` at top of container.ts
| 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 ## Rules
- NEVER import from application/, infrastructure/, interface-adapters/, or di/ 1. **NEVER** import from `application/`, `infrastructure/`, `interface-adapters/`, or `di/`.
- NEVER import external libraries except Zod (for schema validation) 2. **NEVER** import external libraries except `zod` (for schema validation in models).
- Everything here is pure — no side effects, no I/O, no async 3. Everything is **pure** -- no side effects, no I/O, no `async`, no `fetch`, no database calls.
- Models are Zod schemas with inferred TypeScript types 4. Models are Zod schemas with inferred TypeScript types.
- Errors are custom Error subclasses with domain-specific semantics 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` ## Existing Models
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`
## 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 ## Existing Errors
export class {Name}Error extends Error {
constructor(message: string, options?: ErrorOptions) { | Error Class | File | When Thrown |
super(message, options); |---|---|---|
} | `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) |
3. Export from `src/entities/errors/index.ts` | `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 ## Rules
- Implements interfaces from application/ 1. Implements interfaces from `application/` (repository or service interfaces).
- Imports from application/ and entities/ 2. Imports from `application/` (interfaces) and `entities/` (types, errors).
- NEVER imported by application/ or entities/ 3. **NEVER** imported by `application/` or `entities/`. The dependency arrow points inward: infrastructure depends on application, not the reverse.
- Can import external libraries (Drizzle, Better Auth, Sentry, etc.) 4. **NEVER** imported by `interface-adapters/` (controllers). Controllers use interfaces via DI.
- Can import @repo/cms-client 5. Can import external libraries (Payload client, Better Auth, Sentry, etc.).
- Always provide a mock implementation for every real implementation 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`) ## Existing Implementations
- Mock: `mock-{name}.repository.ts`
- All implementations must use `@injectable()` decorator for InversifyJS
## 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` The `MockAuthenticationService` is notable because it uses constructor injection:
2. Implement the interface from application/
3. Add `@injectable()` decorator ```typescript
4. Create corresponding mock: `mock-{name}.repository.ts` @injectable()
5. Register both in DI module (production binds real, test binds mock) 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 ## Rules
- Controllers validate input using Zod schemas from entities/ 1. Controllers validate input with Zod `safeParse` and throw `InputParseError` on failure.
- Controllers call use cases NEVER contain business logic themselves 2. Controllers call use cases -- they **NEVER** contain business logic themselves.
- Controllers handle error mapping (domain errors to appropriate responses) 3. Controllers import from `application/` (use cases) and `entities/` (types, errors) only.
- Import from application/ and entities/ only 4. Controllers **NEVER** import from `infrastructure/`. They must not know about concrete data access.
- NEVER import from infrastructure/ 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 ```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>>) { import { InputParseError } from "@/entities/errors/common";
const { data, error } = inputSchema.safeParse(input); import type { MyEntity } from "@/entities/models/my-entity";
if (error) throw new InputParseError("Invalid data", { cause: error }); 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); return await myUseCase(data);
} }
``` ```
## Adding a New Controller ---
1. Create `src/interface-adapters/controllers/{domain}/{name}.controller.ts` ## Error Mapping Pattern
2. Define Zod input schema
3. Validate input with `safeParse`, throw `InputParseError` on failure Controllers handle two categories of errors:
4. Call use case and return result
5. Write test in `tests/unit/controllers/{domain}/` ### 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 | ## Atomic Design Classification Guide
|---|---|---|
| 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** |
## 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 | | Level | Can import from | NEVER import from |
|---|---|---| |---|---|---|
| Atoms | lib/, hooks/, styles/ | molecules/, organisms/, templates/ | | Atoms | `lib/`, `hooks/`, `styles/` | `molecules/`, `organisms/`, `templates/` |
| Molecules | atoms/, lib/, hooks/ | organisms/, templates/ | | Molecules | `atoms/`, `lib/`, `hooks/` | `organisms/`, `templates/` |
| Organisms | atoms/, molecules/, lib/, hooks/ | templates/ | | Organisms | `atoms/`, `molecules/`, `lib/`, `hooks/` | `templates/` |
| Templates | atoms/, molecules/, organisms/, lib/, hooks/ | (top level) | | 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 - **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 - **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 - **Organisms:** Can have internal state and sub-components. Can fetch context. Self-contained sections of a page.
- **Templates:** Use children/slots, NEVER hard-code content - **Templates:** Use `children` or named slots (`sidebar`, `header`, etc.) for content injection. NEVER hard-code content or data.
- **All:** Co-locate `.stories.tsx` next to component
## 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 ## shadcn/ui Workflow
1. `pnpm dlx shadcn@latest add [component]` — lands in atoms/ by default When adding a new shadcn/ui component:
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
## 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 ```tsx
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { MyComponent } from "./my-component.js"; import { MyComponent } from "./my-component";
const meta = { const meta = {
title: "{Level}/{ComponentName}", title: "{Level}/{ComponentName}", // e.g., "Atoms/Button", "Molecules/FormField"
component: MyComponent, component: MyComponent,
tags: ["autodocs"], 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>; } satisfies Meta<typeof MyComponent>;
export default meta; 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: This example adds a `Badge` atom component.
- `list-all-documentation` — check for existing components
- `get-documentation` — understand props/variants ### Step 1: Check Storybook MCP for existing components
- After creating: `run-story-tests` to validate
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`