# Clean Architecture Monorepo Template

## Quick Start

```bash
pnpm install           # Install all dependencies
pnpm dev               # Start all dev servers
pnpm build             # Build all packages
pnpm test              # Run all tests
pnpm turbo boundaries  # Validate workspace dependency graph
pnpm turbo gen feature # Scaffold a new feature package (see docs/guides/scaffolding-a-feature.md)
pnpm turbo gen event   # Scaffold an event contract (publish) or handler (consume)
pnpm turbo gen job     # Scaffold a background job
pnpm turbo gen realtime # Scaffold a realtime channel or inbound handler
pnpm turbo gen core-package # Scaffold an optional core package (see docs/scaffolding/core-package-generator.md)
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (see docs/scaffolding/core-ui-component-generator.md)
docker compose up -d   # Start PostgreSQL
```

## TDD

```bash
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; four optional core packages (`core-realtime`, `core-events`, `core-trpc`, `core-ui`) 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

- `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.md` — `turbo 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 Phase-1 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/architecture/template-tiers.md` — must-have vs optional packages and how to scaffold the optionals

## Key Conventions

- **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
- **Lazar-conformant file layout** — 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 factories** — `bind<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/di`** — `bindProductionX(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/`** — Two interfaces (`ITracer`, `ILogger`), three implementations (`NoopTracer`/`NoopLogger`, `SentryTracer`/`SentryLogger`, and `RecordingTracer`/`RecordingLogger` from `core-testing`). Feature packages MUST NOT import `@sentry/*` directly (R40, eslint-enforced)
- **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 (R41, R42)
- **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 (R43, R44). Each error gets a non-enumerable `__sentryReported` flag the first time it's captured; `withCapture`, `SentryLogger`, 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-negotiable** — `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `Sentry.setUser({ id })` only — no email/username (R36); `beforeSend` + `beforeSendTransaction` scrubbers strip emails/passwords/tokens/cookies/auth/IPs (R32, R33)
- **Three apps, three Sentry projects** — `WEB_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 binding** — `bindAll()`'s Rule 0 (DSN → 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. `auth` → `marketing-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`

## 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 |
