Two CLAUDE.md conventions had no mechanical gate, so both drifted: entity models shipped without sibling tests, and feature test files imported src modules via `../` instead of the `@/` alias. - `entity-must-have-test` — every entities/models/<x>.ts needs a sibling <x>.test.ts (errors and barrels excluded). - `no-relative-parent-import-in-tests` — feature test files must import src via `@/`, not `../`. Scoped to feature packages; core packages are governed by their own generator templates. Both register at warn level, bringing the conformance rule count to 15.
19 KiB
Clean Architecture Monorepo Template
Quick Start
pnpm install # Install + auto-wire husky pre-commit hooks
pnpm dev # Start all dev servers
pnpm build # Build all packages
pnpm test # Run all tests
pnpm typecheck # TypeScript across all packages
pnpm lint # ESLint (incl. 15 conformance/* rules)
pnpm conformance # Cross-feature event closure
pnpm fallow # Whole-codebase: dead exports, dupes, complexity
pnpm fallow:audit # AI-change audit (run before commits)
pnpm coverage:aggregate # Merge per-package lcovs -> coverage/lcov.info + summary.json (L2)
pnpm coverage:diff # Cover-the-diff gate; JSON to stdout (L1, ADR-020)
pnpm mutate # Stryker mutation testing on entities + use-cases (L3, on-demand)
pnpm turbo boundaries # Workspace dependency graph
pnpm work status # docs/work/ epic + story state
pnpm work next # Next ready story
pnpm work dispatch # Print next dispatch plan (use --execute to invoke sandcastle)
pnpm turbo gen feature # Scaffold a new feature package
pnpm turbo gen event # Scaffold an event contract or handler
pnpm turbo gen job # Scaffold a background job
pnpm turbo gen realtime # Scaffold a realtime channel or handler
pnpm turbo gen core-package # Scaffold an optional core package
pnpm turbo gen core-ui-component # Scaffold an atomic-design component
docker compose up -d # Start PostgreSQL
First time? Read docs/guides/runbook.md end-to-end.
TDD
pnpm test --watch --filter @repo/<feature> # watch one feature
pnpm test -- --coverage # full run with coverage
pnpm test:stories # Storybook smoke tests
pnpm test:e2e # Playwright e2e
See docs/guides/tdd-workflow.md for the full cycle.
Project Overview
Turborepo + pnpm monorepo organized by vertical features. Each feature (auth, blog, media, marketing-pages, navigation) owns its Clean Architecture layers. Must-have core packages (core-shared, core-cms, core-api) provide foundation; five optional core packages (core-realtime, core-events, core-trpc, core-ui, core-audit) scaffold on demand via pnpm turbo gen core-package <name> (see docs/architecture/template-tiers.md). Two tooling packages (core-eslint, core-typescript) provide shared configs. Workspace boundaries are enforced by ESLint (lint-time) and Turborepo (build-graph time). Supports Next.js and TanStack Start as frontend frameworks, Payload CMS for content management, and comprehensive agent-optimized documentation.
Read First
docs/glossary.md— Canonical vocabulary for the monorepo. Resolves "what does X mean here?" for every cross-cutting term (feature, use case, manifest, conformance, slice, dispatch, etc.). Shared between humans and agents.AGENTS.md— Package map, boundary rules, per-package conventionsdocs/architecture/overview.md— High-level architecture and package responsibilitiesdocs/architecture/vertical-feature-spec.md— Design spec with rationale and decision logdocs/guides/scaffolding-a-feature.md—turbo gen featurereference (fast path; prefer this over the manual walkthrough)docs/guides/adding-a-feature.md— End-to-end new feature walkthrough (manual path; for cases the generator's scope doesn't cover)docs/guides/events-and-jobs.md— publish/consume/schedule cookbook (cross-feature events + background jobs; requiresgen core-package events)docs/guides/realtime.md— Socket.IO channels, broadcasts, handlers (requiresgen core-package realtime)docs/guides/audit-and-compliance.md— DPA-compliant audit logging cookbook (requiresgen core-package audit)docs/guides/coverage.md— 4-layer coverage cookbook (L0 vitest thresholds, L1pnpm coverage:diff, L2 aggregate, L3 mutation; ADR-020)docs/guides/releasing.md— release-please workflow: how Conventional Commits become tagged versions + per-package CHANGELOGs (ADR-021)docs/architecture/template-tiers.md— must-have vs optional packages and how to scaffold the optionalsdocs/guides/compliance-overview.md— hub for operator compliance obligations: GDPR, cookie consent, DSR, and pre-launch checklist
Conformance system
Every feature has a src/feature.manifest.ts declaring its use cases, audits, publishes, consumes, required cores, rateLimit?: RateLimitBudget[] (when applicable, for per-use-case rate-limit budgets), and (when applicable) requiresConsent: ConsentCategory[] for features that gate behaviour behind user consent. Drift is caught at five latencies:
| Layer | Latency | Catches |
|---|---|---|
| TypeScript brands | 0s | forgotten withSpan / withCapture / withAudit at bind time |
| ESLint rules | <1s | manifest ↔ code drift; undeclared bus.publish / auditLog.record; missing manifest; missing sibling test |
Boot assertion (pnpm dev) |
~3s | binding without required brand at runtime; manifest edited without rebinder |
CI drift gate (pnpm conformance) |
~120s | orphan event consumers across features |
Fallow (pnpm fallow) |
~30–60s | dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift |
The fifteen conformance ESLint rules: feature-must-have-manifest (error), usecase-must-have-test-file (error), required-cores-installed (error), usecase-must-be-wired (error), no-undeclared-event-publish (warn), no-undeclared-audit (warn), no-undeclared-analytics-event (warn), pii-declaration-must-be-complete (warn), component-must-have-story (warn), component-must-have-test (warn), atomic-tier-import-direction (warn), no-undeclared-consent-check (warn), no-undeclared-rate-limit (warn), entity-must-have-test (warn), no-relative-parent-import-in-tests (warn). Fallow runs as a fifth layer, post-ESLint, whole-codebase.
See docs/architecture/agent-first-workflow-and-conformance.md for the full design and docs/guides/conformance-quickref.md for the day-to-day reference.
Sibling architecture: coverage (ADR-020)
Coverage runs in parallel to the 5-gate conformance system above — same multi-latency philosophy, different signal. Each feature's feature.manifest.ts declares a coverage.bands section that vitest (test-time), pnpm coverage:diff (CI/agent-loop), and pnpm mutate (nightly) all read from. Four layers:
| Layer | Catches | Surface |
|---|---|---|
| L0 Per-layer vitest thresholds | Drift below declared bands (entities/use-cases/controllers at 100%) | pnpm test -- --coverage |
| L1 Diff coverage | Changed line not exercised by tests | pnpm coverage:diff — CI-gated on PRs + dispatch post-task |
| L2 Aggregate trend | Codebase coverage drifted over time | pnpm coverage:aggregate → committed coverage/summary.json |
| L3 Mutation testing | Tests that exist + execute the code but assert nothing | pnpm mutate — on-demand + nightly GH Action |
See docs/guides/coverage.md for the cookbook and ADR-020 for the full rationale. Agents running in sandcastle: run pnpm coverage:diff before reporting complete — the implementer and reviewer prompts enforce this.
Key Conventions
- Conventional Commits (non-negotiable) — Every commit message MUST follow the Conventional Commits spec:
<type>(<scope>): <imperative subject>(≤72 chars). Types:feat | fix | docs | style | refactor | test | chore | perf | ci | build | revert. Use!after type/scope for breaking changes. Body explains WHY if non-obvious. Examples:feat(auth): hash password before persisting,test(blog): assert article not found error,refactor(docs)!: consolidate scaffolding into guides. The sandcastle implementer + reviewer prompts both enforce this; agents authoring commits autonomously MUST honor it. Commits become versions + changelog entries automatically via release-please (ADR-021 /docs/guides/releasing.md). - Versioning is hybrid (ADR-021) — Root template (
template-vertical) + 5 feature packages (@repo/{auth,blog,media,marketing-pages,navigation}) each version independently from0.1.0. release-please reads Conventional Commits since the last tag and opens a rolling release PR on every merge to main; merging it cuts per-package tags (template-v0.2.0,auth-v0.1.1, etc.) + GitHub releases. Bump targeting is by commit path — files underpackages/<feature>/**bump that feature; cross-cutting paths (docs/,scripts/,.github/, root configs) bump the root. Pre-1.0 policy:feat:→ patch,feat!:→ minor. - Relative imports in
src/— Source files use relative paths (../repositories/...), not@/alias @/alias in tests — Test files (*.test.ts) use@/to import fromsrc/vitest.config.ts— Every package must defineresolve.alias: { "@": path.resolve(__dirname, "./src") }tsconfig.jsonrootDir — Set"rootDir": "."so TypeScript finds bothsrc/and test files- File layout convention — Entities live at
entities/models/<x>.ts; errors atentities/errors/<domain>.ts+entities/errors/common.ts; mock siblings use the.mock.tssuffix (<x>.repository.mock.ts); real repository impls drop thepayload-prefix (<x>.repository.ts); interface filenames are dot-separated (<x>.repository.interface.ts) - Factory-function use cases & controllers — Every use case and controller is
(deps) => async (input) => result; each exportsexport type I*UseCase = ReturnType<typeof xUseCase>(and the analogousI*Controller); one controller per use case (no multi-method controllers) - DI uses
.toDynamicValue()for factories —bind<IXUseCase>(SYMBOL).toDynamicValue((ctx) => xUseCase(ctx.container.get(...))); mocks remain the default binding - Tests inject mocks directly — Construct
MockXRepositoryand pass into the factory:signInUseCase(mockUsers, mockAuth)(input). No container rebinding in unit tests - Schemas in the use-case file — Every use case exports
xInputSchema(az.ZodObjectwith.strict();z.object({}).strict()for void inputs) and, for non-void use cases,xOutputSchema. Types:XInput = z.infer<typeof xInputSchema>andXOutput. Use case body ends withxOutputSchema.parse(result)before returning (runtime guarantee against malformed repository data) - Controllers receive
unknown+ presenter — ControllerssafeParse(xInputSchema)from the use-case file and throwInputParseErroron failure. Non-void controllers define a top-levelfunction presenter(value: XOutput)and returnPromise<ReturnType<typeof presenter>>(identity is fine —return value); void controllers returnPromise<void>with no presenter - Feature-scoped tRPC error mapping — Each feature has
integrations/api/procedures.tsexportingxProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))from@repo/core-shared/trpc/define-error-middleware. Routers usexProcedure.input(xInputSchema)— schemas are imported from the use-case file, never redefined inline.core-sharednever enumerates feature error classes - Public surface split — Feature root (
.) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind./ui(src/ui/index.ts). Apps import queries from@repo/<feature>/ui, schemas/types from@repo/<feature> - Payload repositories via constructor — Feature packages receive Payload config at constructor time, not as a direct dependency
- Three binding modes per feature — Each feature exports two binders:
./di/bind-production(real Payload) and./di/bind-dev-seed(populated mock). The app'sbindAll()dispatcher inapps/web-next/src/server/bind-production.tspicks one by env:USE_DEV_SEED="true"→ dev seed;NODE_ENV="production"→ production; otherwise → dev seed (developer default sopnpm devboots without Payload). Dev seed lives insrc/__seeds__/dev.tsas a lazybuildDev<Entities>()function that uses the feature's existing factory - Binders take a
ctxarg fromcore-shared/di—bindProductionX(ctx: BindProductionContext)for production binders;bindDevSeedX(ctx: BindContext)for dev-seed. Required fields:tracer,logger, plusconfigfor production. Optional fields:bus,queue,realtime,realtimeRegistry(correspond to optional core packages — guard with?.orif (bus) { ... }when used; use-case signatures should accept the protocol type when they only need protocol methods, not the full concrete interface). Aggregator builds one ctx object and passes it to all feature binders - App bootstrap — Each app calls
bindAll()from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent - Instrumentation lives in
core-shared/instrumentation/— Three interfaces (ITracer,ILogger,IMetrics), three implementation pairs (Noop*,Otel*, andRecording*fromcore-testing). The OTel SDK is the substrate; Sentry is wired as the exporter via@sentry/opentelemetry. Feature packages MUST NOT import@opentelemetry/sdk-*or@sentry/*directly (ESLint-enforced); the vendor-neutral@opentelemetry/apifamily is the import surface for advanced cases (ADR-017) - Spans + capture composed at DI bind time — Use cases + controllers are wrapped at DI bind time in this order (outermost → innermost):
withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps). ApplywithAuditwhen the manifest declaresaudits,withAnalyticswhen it declaresanalyticsEvents,withConsentwhen it declaresrequiresConsent.withSpanis always outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they callthis.tracer.startSpan(...)andthis.logger.captureException(...)inline per method because they own per-call attributes - Capture at throw sites only, with double-report guard — Repos capture infra errors inline; use cases + controllers capture via
withCaptureat bind time;defineErrorMiddlewarenever captures. Each error gets a non-enumerable__sentryReportedflag the first time it's captured;withCapture,OtelLogger, andRecordingLoggerall bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper atcore-shared/instrumentation/reported-flag.ts) - PII handling is non-negotiable —
sendDefaultPii: falseeverywhere (CI grep gate); replay default-masks all text/inputs/media (allowlist starts empty);setUser({ id })only — no email/username; server-side PII scrubbing happens at the OTel processor layer (PiiScrubSpanProcessor+PiiScrubLogRecordProcessor) before any exporter sees the data (ADR-017 §7) - Three apps, three Sentry projects —
WEB_NEXT_SENTRY_DSN,CMS_SENTRY_DSN,WEB_TANSTACK_SENTRY_DSN. Browser DSNs useNEXT_PUBLIC_(web-next) andVITE_(web-tanstack) prefixes - Instrumentation binding is orthogonal to repo binding —
bindAll()'s Rule 0 (DSN → OTel+Sentry vs Noop) is independent ofUSE_DEV_SEED/NODE_ENV. Runpnpm devwithWEB_NEXT_SENTRY_DSNset to test the integration locally - Cross-feature events go through
IEventBus(E0) — In-feature reactions are direct use-case calls, not bus publishes. The bus is for crossing feature boundaries (e.g.auth→marketing-pageswelcome email) - Event contracts are public; handlers are private (E1) — Publisher's
events/<x>.event.tsis exported from the feature root barrel. Consumer'sevents/handlers/on-<publisher>-<event>.handler.tsis never re-exported (ESLint-enforced viacore-eslint/rules/no-handler-reexport) - Jobs are for deferred work, not abstraction (J0) — Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron). Feature packages enqueue via
IJobQueueonly — directpayload.jobs.queue()is ESLint-blocked outsidecore-shared/jobs/ - Realtime is for state delivery, not for replacing tRPC (R0) — Persistent request/response operations belong on tRPC procedures. Use realtime when the server needs to push without a request or the data is too high-frequency for HTTP
- Realtime channel descriptors are exported; handlers are private (R1) — A feature's
realtime/<name>.channel.tsis re-exported from the root barrel;realtime/handlers/*.handler.tsis wired only in bind-* files and never re-exported (ESLint-enforced viano-realtime-handler-reexport) socket.iolives in@repo/core-realtimeonly (R2) — Feature packages MUST NOT importsocket.ioorsocket.io-client. ESLint ruleno-direct-socket-ioenforces this; allowlist coverscore-realtime/src/socket-io-*.tsandapps/*/server.ts- Manifest-first ordering — for any new use case, the workflow is (1) manifest entry → (2) contracts (
xInputSchema,xOutputSchema,IXUseCase) → (3) tests (red) → (4) implementation (green). The generator emits the manifest + a self-assertingbind-production.tsso new features are conformance-compliant by default - Self-asserting
bindProductionX(ctx)— every feature's bind-production callsassertFeatureConformance(container, manifest, symbols, ctx)at its tail.pnpm devrefuses to boot on drift pnpm conformance— cross-feature event-closure check; fails CI on orphan consumers- New runtime dependencies require a library trace — adding a runtime dependency to a feature- or core-tier package requires a trace at
docs/library-decisions/<date>-<name>.mdproduced by the/evaluate-libraryskill; see ADR-022 anddocs/guides/adding-a-library.md - CI security + supply-chain enforcement — Renovate for bumps + Action SHA pinning, Socket for supply-chain behavior, weekly trace revalidation, CodeQL + audit signatures + gitleaks. See ADR-023 +
docs/guides/ci-security.md
MCP Servers
Start Storybook before UI work: pnpm dev --filter @repo/storybook
Storybook MCP available at http://localhost:6006/mcp — use list-all-documentation to discover existing components before creating new ones.
Key Ports
| Service | Port |
|---|---|
| Next.js | 3000 |
| Payload CMS | 3001 |
| TanStack Start | 3002 |
| PostgreSQL | 5432 |
| Storybook | 6006 |