Files
agentic-dev-template/AGENTS.md
Danijel Martinek ae456a5436 docs: ui now optional — prerequisite notes + conditional HTML
- AGENTS.md (root): marks core-ui as optional in the package table and
  boundary rules; points per-package docs to the .hbs template
- apps/storybook/AGENTS.md: rewrites around no-core-ui-by-default;
  stories glob and globals.css import described as post-scaffold steps
- apps/web-next/AGENTS.md: cross-reference updated to template file
- apps/web-tanstack/AGENTS.md: cross-reference updated to template file
- docs/architecture/data-flow-explainer.html: core-ui bullet notes
  optional status + generator command

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:23:24 +02:00

25 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) — optional, scaffold via pnpm turbo gen core-package ui
@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 (12 packages) — packages/core-shared; core-ui is optional (scaffold with pnpm turbo gen core-package 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

Fast path — use the generator. pnpm turbo gen feature scaffolds a Lazar-conformant package under packages/<name>/ (single entity, single getX use case) matching the navigation reference shape. It emits package files, entities, use case + controller (with input/output schemas + presenter), mock + real repositories (real one is a Phase-1 stub), DI container, both binders (bind-production / bind-dev-seed), tRPC procedures + router with tests, contract suite, dev seed, and an empty ui/ barrel — all wired with the span + capture sandwich at bind time.

pnpm turbo gen feature                                  # interactive
pnpm turbo gen feature --args widgets Widget widgets    # non-interactive: <name> <Entity> <entities-plural>

The generator does NOT wire aggregators or emit Payload CMS templates / faker factories / multi-entity layouts. After running, hand-edit apps/web-next/src/server/bind-production.ts, packages/core-api/src/root.ts, and the two package.json files (the generator prints the exact checklist on success). See docs/guides/scaffolding-a-feature.md for the full reference.

Manual path. When the generator's Phase-1 scope doesn't fit (multiple entities/use cases, custom layout, extending an existing feature), follow docs/guides/adding-a-feature.md — a step-by-step walkthrough covering folder structure, Clean Architecture layers, Payload + tRPC integration, core wiring, and testing / 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 turbo gen feature       # Scaffold a new Lazar-conformant feature package (see docs/guides/scaffolding-a-feature.md)
pnpm turbo gen core-package  # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/scaffolding/core-package-generator.md)
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 type { BindProductionContext, BindContext } from "@repo/core-shared/di";
import type { IEventBus } from "@repo/core-events";
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";

export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = await resolveEventsAndJobsProduction();
  const resolvedConfig = await config;
  const { realtime, realtimeRegistry } = deps;

  const ctx: BindProductionContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
    config: resolvedConfig,
    tracer,
    logger,
    bus,
    queue,
    realtime,
    realtimeRegistry,
  };

  bindProductionAuth(ctx);
  bindProductionBlog(ctx);
  bindProductionMarketingPages(ctx);
  bindProductionNavigation(ctx);
  bindProductionMedia(ctx);
}

export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
  const { tracer, logger } = resolveInstrumentation();
  const { bus, queue } = resolveEventsAndJobsDevSeed();
  const { realtime, realtimeRegistry } = deps;

  const ctx: BindContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
    tracer, logger, bus, queue, realtime, realtimeRegistry,
  };

  await bindDevSeedAuth(ctx);
  await bindDevSeedBlog(ctx);
  // ... (same for marketing-pages, navigation, media)
}

Actual function names: bindProductionAuth, bindProductionBlog, bindProductionMarketingPages, bindProductionNavigation, bindProductionMedia.

Each feature binder signature is (ctx: BindProductionContext): void for production and (ctx: BindContext): Promise<void> for dev-seed. Required ctx fields: tracer, logger. Production-only: config. Optional: bus, queue, realtime, realtimeRegistry.


Cross-feature events and background jobs (Plan 10, ADR-015)

Three rules:

  • E0: Events are for cross-feature decoupling. In-feature reactions are direct use-case calls — do not use the bus.
  • E1: Event contracts are exported from the publisher's root; handlers are private to the consumer's bind-* files (never re-exported, ESLint-enforced).
  • J0: Jobs are for deferred work, not abstraction. Synchronous code stays synchronous.

@repo/core-events provides IEventBus (InMemoryEventBus for dev/test, PayloadJobsEventBus for prod). @repo/core-shared/jobs provides IJobQueue (InMemoryJobQueue / PayloadJobQueue). Both are swapped by bindAll() using the same USE_DEV_SEED / NODE_ENV rules as repositories.

Per-feature folders (all optional): events/<x>.event.ts, events/handlers/on-<publisher>-<event>.handler.ts, jobs/<x>.job.ts, integrations/cms/jobs/<x>.task.ts.

Use the generators: pnpm turbo gen event {publish|consume}, pnpm turbo gen job. They insert at six fixed // <gen:*> anchor comments present in every feature.

See docs/guides/events-and-jobs.md and docs/decisions/adr-015-events-and-jobs.md.


Realtime layer (ADR-016)

Three rules:

  • R0: Realtime is for state delivery, not for replacing tRPC. Persistent operations with request/response semantics belong on tRPC procedures. Use realtime when the server needs to push without a request, or the data is too high-frequency for HTTP.
  • R1: Channel descriptors are exported; handlers are private. A feature's realtime/<name>.channel.ts is re-exported from the package root barrel; realtime/handlers/*.handler.ts is wired only in the feature's own bind-* files and never re-exported (ESLint-enforced via no-realtime-handler-reexport).
  • R2: socket.io lives in one package only. Feature packages MUST NOT import "socket.io" or import "socket.io-client". Allowlist: packages/core-realtime/src/socket-io-*.ts + apps/*/server.ts. ESLint rule no-direct-socket-io enforces this.

@repo/core-realtime provides IRealtimeBroadcaster (server → client), IRealtimeHandlerRegistry (client → server), and the SocketIORealtimeServer adapter. apps/web-next/server.ts replaces next start/next dev with a custom Node http server hosting both Next.js and Socket.IO on port 3000.

Use the generators: pnpm turbo gen realtime channel, pnpm turbo gen realtime handler. They insert at three fixed // <gen:realtime-*> anchor comments per feature.

See docs/guides/realtime.md and docs/decisions/adr-016-realtime-layer.md.


Instrumentation conventions

Symbols (in core-shared/instrumentation/symbols.ts):

  • INSTRUMENTATION_SYMBOLS.TRACER — bound to ITracer (NoopTracer / SentryTracer)
  • INSTRUMENTATION_SYMBOLS.LOGGER — bound to ILogger (NoopLogger / SentryLogger)

Repository constructor signature (every feature):

constructor(
  config: SanitizedConfig,
  tracer: ITracer = new NoopTracer(),
  logger: ILogger = new NoopLogger(),
)

Repository method body (every public async method):

return this.tracer.startSpan(
  { name: "<entity>.<method>", op: "repository", attributes: { /* ... */ } },
  async (span) => {
    try {
      const result = await /* payload op */;
      span.setAttribute("count", /* ... */);
      return result;
    } catch (err) {
      this.logger.captureException(err, {
        tags: { feature: "<feature>", repo: "<entity>", method: "<method>" },
      });
      span.setStatus("error", err instanceof Error ? err.message : String(err));
      throw err;
    }
  },
);

Use case + controller spans + capture (applied at DI bind time):

const wrappedUC = withSpan(
  tracer,
  { name: "blog.getArticles", op: "use-case" },
  withCapture(
    logger,
    { feature: "blog", layer: "use-case", name: "blog.getArticles" },
    getArticlesUseCase(repo),
  ),
);
const wrappedCtrl = withSpan(
  tracer,
  { name: "blog.getArticles", op: "controller" },
  withCapture(
    logger,
    { feature: "blog", layer: "controller", name: "blog.getArticles" },
    getArticlesController(wrappedUC),
  ),
);

withSpan is outermost; withCapture is between span and factory so the error is captured before the span closes with error status. Bodies stay vendor-clean — neither use cases nor controllers call tracer / logger inline.

Capture rules (each error captured exactly once via the __sentryReported flag from core-shared/instrumentation/reported-flag.ts):

Layer Captures Doesn't capture
Repository Infra/Payload errors that originate here (inline in catch) Bubbled errors
Use case Business-rule violations + output-schema failures originated in this body (via withCapture) Errors from repos — flag set, withCapture bails
Controller InputParseError from safeParse failure (via withCapture) Errors from use cases — flag set, withCapture bails
defineErrorMiddleware Nothing — maps domain → TRPCError only

Boundary rule (eslint-enforced, R40): Feature packages MUST NOT import "@sentry/*". Allowlist:

  • **/instrumentation/sentry/** (core-shared)
  • **/instrumentation/di/bind-sentry-instrumentation.{ts,test.ts}
  • **/setup/no-sentry.{ts,js} + the test guard
  • apps/*/instrumentation*.{ts,mjs,js}
  • apps/*/next.config.{mjs,ts,js}
  • apps/*/vite.config.{ts,mjs,js}

Test rules:

  • Default to NoopTracer / NoopLogger (constructor defaults)
  • Assert spans/captures by injecting RecordingTracer / RecordingLogger from @repo/core-testing/instrumentation
  • Real @sentry/* SDK MUST NOT initialize during tests (guarded by core-testing/setup/no-sentry.ts)

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
  • Scaffolding a Featuredocs/guides/scaffolding-a-feature.mdturbo gen feature reference (fast path)
  • Adding a Feature Guidedocs/guides/adding-a-feature.md — step-by-step new feature walkthrough (manual path)
  • Events and Jobs Guidedocs/guides/events-and-jobs.md — publish, consume, schedule background work
  • Realtime Guidedocs/guides/realtime.md — declare channels, broadcast, receive
  • 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 (optional — generated by pnpm turbo gen core-package ui; see turbo/generators/templates/core-package/ui/AGENTS.md.hbs)
  • 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