- 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.
17 KiB
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
core-apimay import@repo/<feature>/apisubpath exports only (to compose tRPC routers).core-cmsmay import@repo/<feature>/cmssubpath exports only (to compose Payload collections).core-trpcreaches features transitively throughcore-api'sAppRoutertype.
No other cross-package boundary deviations are permitted.
Four enforcement layers
package.jsondependencies — only allowed deps are declared; illegal imports fail at install time.exportsmaps — feature packages expose.,./ui,./cms,./api,./di/bind-production,./di/bind-dev-seedonly; no deep source paths exist.- ESLint
eslint-plugin-boundaries(lint-time) — configured inpackages/core-eslint/:- Enforces the five-tag rules at linting
- Feature packages may import from
coreand tooling only. core-shared,core-uimay not import any feature.core-apirestricted to@repo/<feature>/apiimports.core-cmsrestricted to@repo/<feature>/cmsimports.- No
../../../cross-package relative imports.
- Turborepo
boundaries(build-graph time) — configured in rootturbo.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
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) anddocs/superpowers/refactor-logs/2026-05-06-input-output-unification.md(Plan 9). Decision records:docs/decisions/adr-012-lazar-conformance.mdanddocs/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):
// 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/:
// 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:
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:
{
"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.
// 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:
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.
// 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():
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.
// 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:
// 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 anyNODE_ENV)NODE_ENV === "production"→ production (real Payload)- otherwise → dev seed (developer default;
pnpm devboots without Payload)
// 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.mdpackages/core-api/AGENTS.md,core-cms/AGENTS.md,core-trpc/AGENTS.mdpackages/core-ui/AGENTS.mdpackages/auth/AGENTS.md,blog/AGENTS.md,media/AGENTS.md,marketing-pages/AGENTS.md,navigation/AGENTS.mdpackages/core-eslint/AGENTS.md,core-typescript/AGENTS.md,core-testing/AGENTS.mdapps/cms/AGENTS.md,web-next/AGENTS.md,web-tanstack/AGENTS.md,storybook/AGENTS.md