Files
agentic-dev/AGENTS.md
Danijel Martinek 8f34daca36 docs(dev-seed): canonical doc updates + refactor-log entry
- 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.
2026-05-06 19:49:58 +02:00

17 KiB
Raw Blame History

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

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):

// 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, R1R5)

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, R7R12)

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, R13R17)

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, R18R21)

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 any NODE_ENV)
  • NODE_ENV === "production" → production (real Payload)
  • otherwise → dev seed (developer default; pnpm dev boots 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 Specdocs/architecture/vertical-feature-spec.md — full design, rationale, decision log
  • Architecture Overviewdocs/architecture/overview.md — package responsibilities, data flow
  • Dependency Flowdocs/architecture/dependency-flow.md — allowed directions and composition pattern
  • Adding a Feature Guidedocs/guides/adding-a-feature.md — step-by-step new feature walkthrough
  • Testing Strategydocs/guides/testing-strategy.md — test placement, Vitest per-package, Playwright e2e
  • TDD Workflowdocs/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