Files
agentic-dev-template/CLAUDE.md
Danijel Martinek b96cce5d74 feat: hybrid versioning + automated CHANGELOG via release-please
Closes the user's ask: versioning + a changelog generated on merging
to main, building on the just-mandated Conventional Commits substrate
(CLAUDE.md Key Conventions).

Architecture: ADR-021. Cookbook: docs/guides/releasing.md.

Initial state — six tracked packages at v0.1.0:
  - .                          -> template-vertical  (tag: template-v...)
  - packages/auth              -> @repo/auth         (tag: auth-v...)
  - packages/blog              -> @repo/blog         (tag: blog-v...)
  - packages/media             -> @repo/media        (tag: media-v...)
  - packages/marketing-pages   -> @repo/marketing-pages (tag: marketing-pages-v...)
  - packages/navigation        -> @repo/navigation   (tag: navigation-v...)

Core packages, tooling, and apps are NOT independently versioned
(ADR-021 rationale: core bumps cascade; apps aren't consumables;
surfacing them would create noise without information).

Configuration:
  - release-please-config.json   - 6 tracked packages, hybrid scope,
                                   pre-1.0 conservative bump policy
                                   (feat: -> patch, feat!: -> minor),
                                   conventional-commit type mapping
  - .release-please-manifest.json - baseline 0.1.0 for all 6 packages
  - .github/workflows/release-please.yml - googleapis/release-please-
                                   action@v4 on push to main,
                                   concurrency-gated, write
                                   permissions for the rolling PR

Workflow: on every push to main, release-please scans commits since
the last release tag PER PACKAGE (using commit-path, not the
conventional-commit scope), updates a single rolling release PR with
version bumps + per-package CHANGELOG entries. Merging that PR cuts
per-package tags + GitHub releases.

CHANGELOG files seeded at v0.1.0 baseline:
  - CHANGELOG.md (root)
  - packages/<feature>/CHANGELOG.md (5 features)
Subsequent versions are appended by release-please from commit
history. Do not edit manually.

Visibility surfaces updated (every agent entry point):
  - CLAUDE.md Read First + new "Versioning is hybrid" Key Conventions
    bullet (with bump policy summary)
  - AGENTS.md preamble - new "Releases:" callout alongside Commits
  - docs/glossary.md - new Releasing section with 8 terms (Conventional
    Commits, release-please, Hybrid versioning, Tag prefix, Rolling
    release PR, Bump targeting, Pre-1.0 bump policy, Release-As trailer,
    CHANGELOG.md)
  - docs/README.md - guides tree updated with releasing.md
  - .claude/hooks/session-start.sh - one-line release reminder
  - .claude/hooks/prompt-context.sh - new keyword group for
    release/version/bump/semver/tag prompts

Package.json version bumps:
  - root: name "template" -> "template-vertical", version "0.1.0"
  - packages/auth, blog, media, marketing-pages, navigation: "0.0.0" -> "0.1.0"

Root rename rationale: release-please tags use the package-name + the
component prefix; "template-vertical" matches the repo identity (and
the user's question preview).

First release-please PR after this lands will sweep all subsequent
post-baseline commits into 0.1.1 / 0.2.0 bumps as appropriate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:17:16 +02:00

18 KiB
Raw Blame History

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. 8 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.mdCanonical 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 conventions
  • docs/architecture/overview.md — High-level architecture and package responsibilities
  • docs/architecture/vertical-feature-spec.md — Design spec with rationale and decision log
  • docs/guides/scaffolding-a-feature.mdturbo gen feature reference (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; requires gen core-package events)
  • docs/guides/realtime.md — Socket.IO channels, broadcasts, handlers (requires gen core-package realtime)
  • docs/guides/audit-and-compliance.md — DPA-compliant audit logging cookbook (requires gen core-package audit)
  • docs/guides/coverage.md — 4-layer coverage cookbook (L0 vitest thresholds, L1 pnpm 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 optionals

Conformance system

Every feature has a src/feature.manifest.ts declaring its use cases, audits, publishes, consumes, and required cores. 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) ~3060s dead exports / unused files; duplicate code; circular deps; complexity hotspots; AI-change audit drift

The five conformance ESLint rules: feature-must-have-manifest (error), usecase-must-have-test-file (error), required-cores-installed (error), no-undeclared-event-publish (warn), no-undeclared-audit (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 from 0.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 under packages/<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 from src/
  • vitest.config.ts — Every package must define resolve.alias: { "@": path.resolve(__dirname, "./src") }
  • tsconfig.json rootDir — Set "rootDir": "." so TypeScript finds both src/ and test files
  • File layout convention — Entities live at entities/models/<x>.ts; errors at entities/errors/<domain>.ts + entities/errors/common.ts; mock siblings use the .mock.ts suffix (<x>.repository.mock.ts); real repository impls drop the payload- 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 exports export type I*UseCase = ReturnType<typeof xUseCase> (and the analogous I*Controller); one controller per use case (no multi-method controllers)
  • DI uses .toDynamicValue() for factoriesbind<IXUseCase>(SYMBOL).toDynamicValue((ctx) => xUseCase(ctx.container.get(...))); mocks remain the default binding
  • Tests inject mocks directly — Construct MockXRepository and 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 (a z.ZodObject with .strict(); z.object({}).strict() for void inputs) and, for non-void use cases, xOutputSchema. Types: XInput = z.infer<typeof xInputSchema> and XOutput. Use case body ends with xOutputSchema.parse(result) before returning (runtime guarantee against malformed repository data)
  • Controllers receive unknown + presenter — Controllers safeParse(xInputSchema) from the use-case file and throw InputParseError on failure. Non-void controllers define a top-level function presenter(value: XOutput) and return Promise<ReturnType<typeof presenter>> (identity is fine — return value); void controllers return Promise<void> with no presenter
  • Feature-scoped tRPC error mapping — Each feature has integrations/api/procedures.ts exporting xProcedure = t.procedure.use(defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...])) from @repo/core-shared/trpc/define-error-middleware. Routers use xProcedure.input(xInputSchema) — schemas are imported from the use-case file, never redefined inline. core-shared never 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's bindAll() dispatcher in apps/web-next/src/server/bind-production.ts picks one by env: USE_DEV_SEED="true" → dev seed; NODE_ENV="production" → production; otherwise → dev seed (developer default so pnpm dev boots without Payload). Dev seed lives in src/__seeds__/dev.ts as a lazy buildDev<Entities>() function that uses the feature's existing factory
  • Binders take a ctx arg from core-shared/dibindProductionX(ctx: BindProductionContext) for production binders; bindDevSeedX(ctx: BindContext) for dev-seed. Required fields: tracer, logger, plus config for production. Optional fields: bus, queue, realtime, realtimeRegistry (correspond to optional core packages — guard with ?. or if (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*, and Recording* from core-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/api family is the import surface for advanced cases (ADR-017)
  • Spans + capture composed at DI bind time — Use cases + controllers wrapped via withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps))) inside bind-production / bind-dev-seed. withSpan is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call this.tracer.startSpan(...) and this.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 withCapture at bind time; defineErrorMiddleware never captures. Each error gets a non-enumerable __sentryReported flag the first time it's captured; withCapture, OtelLogger, and RecordingLogger all bail if the flag is set, so a bubbled error surfaces exactly once with the inner-most layer's tags (helper at core-shared/instrumentation/reported-flag.ts)
  • PII handling is non-negotiablesendDefaultPii: false everywhere (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 projectsWEB_NEXT_SENTRY_DSN, CMS_SENTRY_DSN, WEB_TANSTACK_SENTRY_DSN. Browser DSNs use NEXT_PUBLIC_ (web-next) and VITE_ (web-tanstack) prefixes
  • Instrumentation binding is orthogonal to repo bindingbindAll()'s Rule 0 (DSN → OTel+Sentry vs Noop) is independent of USE_DEV_SEED / NODE_ENV. Run pnpm dev with WEB_NEXT_SENTRY_DSN set 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. authmarketing-pages welcome email)
  • Event contracts are public; handlers are private (E1) — Publisher's events/<x>.event.ts is exported from the feature root barrel. Consumer's events/handlers/on-<publisher>-<event>.handler.ts is never re-exported (ESLint-enforced via core-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 IJobQueue only — direct payload.jobs.queue() is ESLint-blocked outside core-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.ts is re-exported from the root barrel; realtime/handlers/*.handler.ts is wired only in bind-* files and never re-exported (ESLint-enforced via no-realtime-handler-reexport)
  • socket.io lives in @repo/core-realtime only (R2) — Feature packages MUST NOT import socket.io or socket.io-client. ESLint rule no-direct-socket-io enforces this; allowlist covers core-realtime/src/socket-io-*.ts and apps/*/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-asserting bind-production.ts so new features are conformance-compliant by default
  • Self-asserting bindProductionX(ctx) — every feature's bind-production calls assertFeatureConformance(container, manifest, symbols, ctx) at its tail. pnpm dev refuses to boot on drift
  • pnpm conformance — cross-feature event-closure check; fails CI on orphan consumers

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