- CLAUDE.md Key Conventions: 'App bootstrap' rule rewritten as 'Three binding modes per feature' — describes USE_DEV_SEED + NODE_ENV resolution order and the new ./di/bind-dev-seed export. - AGENTS.md (root): exports list now mentions ./ui + ./di/bind-dev-seed; Per-feature public-API surface table gains a row; Apps section shows the bindAll() dispatcher with three-rule logic. - docs/architecture/vertical-feature-spec.md §6: file shape now includes bind-dev-seed.ts, bind-dev-seed.test.ts, __seeds__/dev.ts; package.json exports list updated to include ./di/bind-dev-seed. - docs/architecture/data-flow-explainer.html: anatomy tree gains __seeds__/ row; LAYERS.di description updated with new binders + cross-link to di-explainer.html; new LAYERS.seeds entry; public- surface card expanded to six subpaths. - docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md §7: new 'Post-Plan-9: dev-seed binders' entry summarizing the rollout (commits, per-feature additions, app wiring, tests, turbo, docs). - bind-production.test.ts: dispatcher tests use vi.stubEnv (typesafe way to test process.env in TypeScript 5+ with @types/node read-only process.env types). 4 dispatcher tests + 2 bindAllProduction tests = 7 tests total.
367 lines
17 KiB
Markdown
367 lines
17 KiB
Markdown
# AGENTS.md — Vertical Feature Monorepo
|
||
|
||
This is a **Turborepo + pnpm monorepo** organized by vertical features. Each feature package owns its own Clean Architecture layers (entities, application, infrastructure, interface-adapters) and integrations (CMS collections, tRPC routers, UI components). Core packages provide foundation: primitives, design system, CMS composition, API aggregation, and tRPC client platform.
|
||
|
||
---
|
||
|
||
## Package Map
|
||
|
||
| Package | Tag | Purpose |
|
||
|---|---|---|
|
||
| `@repo/core-shared` | core | Generic primitives (Zod, env, Payload hooks/fields/blocks, tRPC init/context) |
|
||
| `@repo/core-ui` | core | Design system (atoms, molecules, generic organisms, templates) |
|
||
| `@repo/core-api` | core-composition | tRPC router aggregator — imports `@repo/<feature>/api` only |
|
||
| `@repo/core-cms` | core-composition | Payload config aggregator — imports `@repo/<feature>/cms` only |
|
||
| `@repo/core-trpc` | core-composition | Frontend tRPC client + framework-specific providers (Next.js, TanStack) |
|
||
| `@repo/auth` | feature | Users collection + sign-in/up/out |
|
||
| `@repo/blog` | feature | Articles collection + article use-cases |
|
||
| `@repo/media` | feature | Media collection + upload helpers |
|
||
| `@repo/marketing-pages` | feature | Pages collection + SiteSettings global |
|
||
| `@repo/navigation` | feature | Header global |
|
||
| `@repo/core-eslint` | tooling | Shared ESLint 9 flat configs (base, next, react-internal, boundaries) |
|
||
| `@repo/core-typescript` | tooling | Shared TypeScript base configs + Vitest base |
|
||
| `@repo/core-testing` | tooling | Shared test utilities (defineFactory, defineContractSuite, renderWithProviders, payload mocks) |
|
||
|
||
---
|
||
|
||
## Boundary Rules
|
||
|
||
### Five tags
|
||
|
||
- **app** (4 packages) — `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook`
|
||
- **core-composition** (3 packages) — `packages/core-api`, `core-cms`, `core-trpc`
|
||
- **core** (2 packages) — `packages/core-shared`, `core-ui`
|
||
- **feature** (5 packages) — `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation`
|
||
- **tooling** (3 packages) — `packages/core-eslint`, `core-typescript`, `core-testing`
|
||
|
||
### Allowed dependency directions
|
||
|
||
| Tag | May depend on |
|
||
|---|---|
|
||
| app | app, core, core-composition, feature, tooling |
|
||
| core-composition | core, core-composition, feature, tooling |
|
||
| core | core, core-composition, tooling |
|
||
| feature | core, tooling |
|
||
| tooling | tooling |
|
||
|
||
### Composition exceptions
|
||
|
||
1. **`core-api`** may import `@repo/<feature>/api` subpath exports only (to compose tRPC routers).
|
||
2. **`core-cms`** may import `@repo/<feature>/cms` subpath exports only (to compose Payload collections).
|
||
3. **`core-trpc`** reaches features transitively through `core-api`'s `AppRouter` type.
|
||
|
||
No other cross-package boundary deviations are permitted.
|
||
|
||
### Four enforcement layers
|
||
|
||
1. **`package.json` dependencies** — only allowed deps are declared; illegal imports fail at install time.
|
||
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production`, `./di/bind-dev-seed` only; no deep source paths exist.
|
||
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/`:
|
||
- Enforces the five-tag rules at linting
|
||
- Feature packages may import from `core` and tooling only.
|
||
- `core-shared`, `core-ui` may not import any feature.
|
||
- `core-api` restricted to `@repo/<feature>/api` imports.
|
||
- `core-cms` restricted to `@repo/<feature>/cms` imports.
|
||
- No `../../../` cross-package relative imports.
|
||
4. **Turborepo `boundaries`** (build-graph time) — configured in root `turbo.json`:
|
||
- Validates the entire workspace dependency graph, including transitive dependencies
|
||
- Catches issues ESLint might miss (e.g., transitive feature reaches through composition packages)
|
||
- Run with `pnpm turbo boundaries`
|
||
|
||
---
|
||
|
||
## Adding a Feature
|
||
|
||
Start with `docs/guides/adding-a-feature.md` for a step-by-step walkthrough covering:
|
||
- New feature scaffold (folder structure, `package.json`, `tsconfig.json`, `vitest.config.ts`)
|
||
- Clean Architecture layers (entities, use cases, repositories, DI container, controllers)
|
||
- Payload integration (collections, hooks, Payload repository binding)
|
||
- tRPC integration (routers, procedure binding)
|
||
- Core wiring (`core-api`, `core-cms`, path aliases, app bootstrap)
|
||
- Testing and lint validation
|
||
|
||
---
|
||
|
||
## Key Commands
|
||
|
||
```bash
|
||
pnpm install # Install all dependencies
|
||
pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storybook :6006)
|
||
pnpm typecheck # Type-check all packages
|
||
pnpm lint # Lint all packages (ESLint boundaries enforced)
|
||
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
|
||
pnpm test # Run all unit + integration tests (Vitest)
|
||
pnpm test:e2e # Run e2e tests (Playwright across both apps)
|
||
pnpm build # Build all packages (Turborepo)
|
||
docker compose up -d # Start PostgreSQL
|
||
|
||
# Filtered commands
|
||
pnpm dev --filter @repo/web-next # Only Next.js app
|
||
pnpm dev --filter @repo/cms # Only CMS admin
|
||
pnpm dev --filter @repo/storybook # Only Storybook
|
||
pnpm typecheck --filter @repo/blog # Only blog feature
|
||
pnpm test --filter @repo/blog # Only blog unit/integration tests
|
||
```
|
||
|
||
---
|
||
|
||
## Per-Package Conventions
|
||
|
||
> These conventions reflect the post-Plan-8 (Lazar conformance) and post-Plan-9 (input/output unification) state.
|
||
> Canonical summary: `CLAUDE.md` § Key Conventions.
|
||
> Refactor logs: `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` (Plan 8) and `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md` (Plan 9).
|
||
> Decision records: `docs/decisions/adr-012-lazar-conformance.md` and `docs/decisions/adr-013-input-output-unification.md`.
|
||
|
||
### Source files use RELATIVE imports (not @/)
|
||
|
||
Inside `src/` files, import from sibling layers using relative paths (no `.js` extension — modern Node/Vitest resolves without it):
|
||
|
||
```typescript
|
||
// packages/blog/src/application/use-cases/get-articles.use-case.ts
|
||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||
import { BLOG_SYMBOLS } from "../../di/symbols";
|
||
import type { Article } from "../../entities/models/article";
|
||
```
|
||
|
||
Entity models live at `entities/models/<x>.ts`; domain errors at `entities/errors/<domain>.ts`; the shared `InputParseError` at `entities/errors/common.ts`.
|
||
Mock siblings use the `.mock.ts` suffix (`<x>.repository.mock.ts`); real repository impls drop the `Payload` prefix (`articles.repository.ts`); interface filenames are dot-separated (`articles.repository.interface.ts`).
|
||
|
||
This keeps source code portable and avoids circular alias issues.
|
||
|
||
### Test files use @/ alias
|
||
|
||
Test files (`*.test.ts`) use the `@/` alias to import from `src/`:
|
||
|
||
```typescript
|
||
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
||
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||
```
|
||
|
||
### vitest.config.ts MUST declare @/ alias
|
||
|
||
Every package's `vitest.config.ts` must define the alias:
|
||
|
||
```typescript
|
||
import path from "path";
|
||
import { defineConfig } from "vitest/config";
|
||
|
||
export default defineConfig({
|
||
test: { environment: "node", globals: true },
|
||
resolve: {
|
||
alias: {
|
||
"@": path.resolve(__dirname, "./src"),
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
### tsconfig.json rootDir = "."
|
||
|
||
TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files to coexist:
|
||
|
||
```json
|
||
{
|
||
"extends": "@repo/core-typescript/base.json",
|
||
"compilerOptions": {
|
||
"rootDir": ".",
|
||
"outDir": "dist"
|
||
},
|
||
"include": ["src/**/*", "tests/**/*"],
|
||
"exclude": ["node_modules", "dist"]
|
||
}
|
||
```
|
||
|
||
### Use cases own input + output schemas (Plan 9, R1–R5)
|
||
|
||
Every use-case file exports its Zod schemas and inferred types. The use case body validates its output before returning — a misbehaving repository fails loudly at the layer that owns the contract.
|
||
|
||
```typescript
|
||
// packages/blog/src/application/use-cases/get-articles.use-case.ts
|
||
import { z } from "zod";
|
||
import { articleSchema } from "../../entities/models/article";
|
||
import type { IArticlesRepository } from "../repositories/articles.repository.interface";
|
||
|
||
// ── Input ────────────────────────────────────────────────────────────────
|
||
export const getArticlesInputSchema = z
|
||
.object({ status: z.string().optional(), limit: z.number().int().optional() })
|
||
.strict();
|
||
export type GetArticlesInput = z.infer<typeof getArticlesInputSchema>;
|
||
|
||
// ── Output ───────────────────────────────────────────────────────────────
|
||
export const getArticlesOutputSchema = z.array(articleSchema);
|
||
export type GetArticlesOutput = z.infer<typeof getArticlesOutputSchema>;
|
||
|
||
// ── Use case ─────────────────────────────────────────────────────────────
|
||
export type IGetArticlesUseCase = ReturnType<typeof getArticlesUseCase>;
|
||
|
||
export const getArticlesUseCase =
|
||
(articlesRepository: IArticlesRepository) =>
|
||
async (input: GetArticlesInput): Promise<GetArticlesOutput> => {
|
||
const result = await articlesRepository.getArticles(input);
|
||
return getArticlesOutputSchema.parse(result);
|
||
};
|
||
```
|
||
|
||
Void-input use cases use `z.object({}).strict()` and accept `_input: XInput`. Void-output use cases (e.g. `signOutUseCase`, `deleteMediaUseCase`) export only `xInputSchema` — no `xOutputSchema`.
|
||
|
||
Tests inject mocks directly — no container rebinding:
|
||
|
||
```typescript
|
||
const repo = new MockArticlesRepository([]);
|
||
const useCase = getArticlesUseCase(repo);
|
||
const articles = await useCase({ status: "published" });
|
||
```
|
||
|
||
### Controllers receive `unknown` + presenter (Plan 9, R7–R12)
|
||
|
||
Controllers `safeParse(xInputSchema)` from the use-case file and throw `InputParseError` on failure. Every non-void controller defines a top-level `function presenter(value: XOutput)` and returns `Promise<ReturnType<typeof presenter>>`. Identity is fine — `return value` — but the function form is always present so adding a transform later is a one-line edit.
|
||
|
||
```typescript
|
||
// packages/blog/src/interface-adapters/controllers/get-articles.controller.ts
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
import {
|
||
getArticlesInputSchema,
|
||
type GetArticlesOutput,
|
||
type IGetArticlesUseCase,
|
||
} from "../../application/use-cases/get-articles.use-case";
|
||
|
||
function presenter(value: GetArticlesOutput) {
|
||
return value;
|
||
}
|
||
|
||
export type IGetArticlesController = ReturnType<typeof getArticlesController>;
|
||
|
||
export const getArticlesController =
|
||
(getArticlesUseCase: IGetArticlesUseCase) =>
|
||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||
const parsed = getArticlesInputSchema.safeParse(input);
|
||
if (!parsed.success) {
|
||
throw new InputParseError("Invalid input", { cause: parsed.error });
|
||
}
|
||
return presenter(await getArticlesUseCase(parsed.data));
|
||
};
|
||
```
|
||
|
||
Void controllers (e.g. `signOutController`, `deleteMediaController`) return `Promise<void>` and skip the presenter entirely. One controller file per use case — no multi-method controller files.
|
||
|
||
DI binds each factory with `.toDynamicValue()`:
|
||
|
||
```typescript
|
||
bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase)
|
||
.toDynamicValue((ctx) => getArticlesUseCase(ctx.container.get(BLOG_SYMBOLS.IArticlesRepository)));
|
||
```
|
||
|
||
### Feature-scoped tRPC error mapping (Plan 9, R13–R17)
|
||
|
||
Each feature owns `integrations/api/procedures.ts` that wires domain errors to tRPC codes. `core-shared` provides the `defineErrorMiddleware` factory but never enumerates feature error classes.
|
||
|
||
```typescript
|
||
// packages/blog/src/integrations/api/procedures.ts
|
||
import { t } from "@repo/core-shared/trpc/init";
|
||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||
import { ArticleNotFoundError } from "../../entities/errors/article";
|
||
import { InputParseError } from "../../entities/errors/common";
|
||
|
||
export const blogProcedure = t.procedure.use(
|
||
defineErrorMiddleware([
|
||
[InputParseError, "BAD_REQUEST"],
|
||
[ArticleNotFoundError, "NOT_FOUND"],
|
||
]),
|
||
);
|
||
```
|
||
|
||
The router then uses `blogProcedure.input(xInputSchema)` for every procedure — schemas are imported from the use-case file, never redefined inline. Unmapped errors still surface as `TRPCError(code: INTERNAL_SERVER_ERROR)`; the original domain error is preserved as `.cause`.
|
||
|
||
### Per-feature public-API surface (Plan 9, R18–R21)
|
||
|
||
Each feature package exposes exactly these subpath exports:
|
||
|
||
| Subpath | What it exports | Who consumes |
|
||
|---|---|---|
|
||
| `.` (root) | Contracts only: types, errors, schemas, `IUseCase` / `IController` aliases, router type, constants | Any consumer |
|
||
| `./ui` | Query builders (`queryOptions`), UI components | App packages |
|
||
| `./api` | tRPC router (`xRouter` + `XRouter` type) | `@repo/core-api` only |
|
||
| `./cms` | Payload collections | `@repo/core-cms` only |
|
||
| `./di/bind-production` | App boot side-effect — swaps mock for real Payload impl | App packages only |
|
||
| `./di/bind-dev-seed` | App boot side-effect — swaps empty mock for populated mock | App packages, storybook |
|
||
|
||
Apps import schemas/types from `@repo/<feature>` (root) and React Query builders from `@repo/<feature>/ui`. Deep source paths are not accessible — the `exports` map enforces this.
|
||
|
||
### Payload-backed features use constructor injection
|
||
|
||
Feature packages that need Payload receive the `SanitizedConfig` via constructor, not via `@repo/core-cms` dependency:
|
||
|
||
```typescript
|
||
// packages/blog/src/infrastructure/repositories/articles.repository.ts
|
||
@injectable()
|
||
export class ArticlesRepository implements IArticlesRepository {
|
||
constructor(private config: SanitizedConfig) {}
|
||
|
||
async getArticles(options?: { status?: string; limit?: number }): Promise<Article[]> {
|
||
const payload = await getPayload({ config: this.config });
|
||
// ...
|
||
}
|
||
}
|
||
```
|
||
|
||
Class names carry no `Payload` prefix — `ArticlesRepository`, `PagesRepository`, `HeaderRepository`, etc. The config comes from the app at boot time (see below).
|
||
|
||
### Apps call `bindAll()` per feature at boot
|
||
|
||
Each app (`web-next`, `web-tanstack`, `cms`) imports both binders per feature and uses a small dispatcher (`bindAll()`) that picks based on environment:
|
||
|
||
- `USE_DEV_SEED === "true"` → dev seed (explicit override; works in any `NODE_ENV`)
|
||
- `NODE_ENV === "production"` → production (real Payload)
|
||
- otherwise → dev seed (developer default; `pnpm dev` boots without Payload)
|
||
|
||
```typescript
|
||
// apps/web-next/src/server/bind-production.ts
|
||
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
|
||
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
|
||
import { bindProductionMedia } from "@repo/media/di/bind-production";
|
||
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
|
||
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
||
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
|
||
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
|
||
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
|
||
import config from "@repo/core-cms";
|
||
|
||
export async function bindAll(): Promise<void> {
|
||
if (process.env.USE_DEV_SEED === "true") return bindAllDevSeed();
|
||
if (process.env.NODE_ENV === "production") return bindAllProduction();
|
||
return bindAllDevSeed();
|
||
}
|
||
|
||
export async function bindAllProduction(): Promise<void> {
|
||
const resolvedConfig = await config;
|
||
bindProductionAuth(resolvedConfig);
|
||
bindProductionBlog(resolvedConfig);
|
||
bindProductionMarketingPages(resolvedConfig);
|
||
bindProductionNavigation(resolvedConfig);
|
||
bindProductionMedia(resolvedConfig);
|
||
}
|
||
```
|
||
|
||
Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.
|
||
|
||
---
|
||
|
||
## Specification & Guides
|
||
|
||
- **Vertical Feature Spec** — `docs/architecture/vertical-feature-spec.md` — full design, rationale, decision log
|
||
- **Architecture Overview** — `docs/architecture/overview.md` — package responsibilities, data flow
|
||
- **Dependency Flow** — `docs/architecture/dependency-flow.md` — allowed directions and composition pattern
|
||
- **Adding a Feature Guide** — `docs/guides/adding-a-feature.md` — step-by-step new feature walkthrough
|
||
- **Testing Strategy** — `docs/guides/testing-strategy.md` — test placement, Vitest per-package, Playwright e2e
|
||
- **TDD Workflow** — `docs/guides/tdd-workflow.md` — red-green-refactor cycle, mocking decision tree, coverage targets
|
||
|
||
Per-package documentation lives in each `AGENTS.md`:
|
||
- `packages/core-shared/AGENTS.md`
|
||
- `packages/core-api/AGENTS.md`, `core-cms/AGENTS.md`, `core-trpc/AGENTS.md`
|
||
- `packages/core-ui/AGENTS.md`
|
||
- `packages/auth/AGENTS.md`, `blog/AGENTS.md`, `media/AGENTS.md`, `marketing-pages/AGENTS.md`, `navigation/AGENTS.md`
|
||
- `packages/core-eslint/AGENTS.md`, `core-typescript/AGENTS.md`, `core-testing/AGENTS.md`
|
||
- `apps/cms/AGENTS.md`, `web-next/AGENTS.md`, `web-tanstack/AGENTS.md`, `storybook/AGENTS.md`
|