refactor: strip Lazar references from top-level docs + guides
This commit is contained in:
26
AGENTS.md
26
AGENTS.md
@@ -84,7 +84,7 @@ No other cross-package boundary deviations are permitted.
|
|||||||
|
|
||||||
## Adding a Feature
|
## 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.
|
**Fast path — use the generator.** `pnpm turbo gen feature` scaffolds a 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, 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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm turbo gen feature # interactive
|
pnpm turbo gen feature # interactive
|
||||||
@@ -93,7 +93,7 @@ pnpm turbo gen feature --args widgets Widget widgets # non-interactive: <name
|
|||||||
|
|
||||||
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.
|
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.
|
**Manual path.** When the generator's 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ pnpm dev # Start all dev servers (Next.js :3000, CMS :3001, Storyb
|
|||||||
pnpm typecheck # Type-check all packages
|
pnpm typecheck # Type-check all packages
|
||||||
pnpm lint # Lint all packages (ESLint boundaries enforced)
|
pnpm lint # Lint all packages (ESLint boundaries enforced)
|
||||||
pnpm turbo boundaries # Validate workspace dependency graph (Turbo boundaries)
|
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 feature # Scaffold a new 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 turbo gen core-package # Scaffold an optional core package back (realtime, events, trpc, ui — see docs/scaffolding/core-package-generator.md)
|
||||||
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/scaffolding/core-ui-component-generator.md)
|
pnpm turbo gen core-ui-component # Scaffold a core-ui atomic-design component (atom/molecule/organism — see docs/scaffolding/core-ui-component-generator.md)
|
||||||
pnpm test # Run all unit + integration tests (Vitest)
|
pnpm test # Run all unit + integration tests (Vitest)
|
||||||
@@ -125,10 +125,8 @@ pnpm test --filter @repo/blog # Only blog unit/integration tests
|
|||||||
|
|
||||||
## Per-Package Conventions
|
## 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.
|
> 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-feature-conventions.md` and `docs/decisions/adr-013-input-output-unification.md`.
|
||||||
> Decision records: `docs/decisions/adr-012-lazar-conformance.md` and `docs/decisions/adr-013-input-output-unification.md`.
|
|
||||||
|
|
||||||
### Source files use RELATIVE imports (not @/)
|
### Source files use RELATIVE imports (not @/)
|
||||||
|
|
||||||
@@ -189,7 +187,7 @@ TypeScript configs must set `"rootDir": "."` to allow both `src/` and test files
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use cases own input + output schemas (Plan 9, R1–R5)
|
### Use cases own input + output schemas
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -230,7 +228,7 @@ const useCase = getArticlesUseCase(repo);
|
|||||||
const articles = await useCase({ status: "published" });
|
const articles = await useCase({ status: "published" });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Controllers receive `unknown` + presenter (Plan 9, R7–R12)
|
### Controllers receive `unknown` + presenter
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -271,7 +269,7 @@ bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Feature-scoped tRPC error mapping (Plan 9, R13–R17)
|
### Feature-scoped tRPC error mapping
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -292,7 +290,7 @@ export const blogProcedure = t.procedure.use(
|
|||||||
|
|
||||||
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`.
|
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, R18–R21)
|
### Per-feature public-API surface
|
||||||
|
|
||||||
Each feature package exposes exactly these subpath exports:
|
Each feature package exposes exactly these subpath exports:
|
||||||
|
|
||||||
@@ -407,7 +405,7 @@ See `docs/guides/conformance-quickref.md` for the canonical pattern; the generat
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Cross-feature events and background jobs (Plan 10, ADR-015)
|
### Cross-feature events and background jobs (ADR-015)
|
||||||
|
|
||||||
Three rules:
|
Three rules:
|
||||||
|
|
||||||
@@ -516,11 +514,11 @@ const wrappedCtrl = withSpan(
|
|||||||
| Controller | `InputParseError` from `safeParse` failure (via `withCapture`) | Errors from use cases — 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 | — |
|
| `defineErrorMiddleware` | Nothing — maps domain → TRPCError only | — |
|
||||||
|
|
||||||
**Boundary rules (eslint-enforced, R40 + R52):**
|
**Boundary rules (eslint-enforced):**
|
||||||
Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists:
|
Feature packages MUST NOT `import "@sentry/*"` or `import "@opentelemetry/sdk-*"`. Allowlists:
|
||||||
|
|
||||||
- R40 (`@sentry/*`): `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}`
|
- `@sentry/*`: `**/instrumentation/otel/sentry-bridge.{ts,js}`, `**/instrumentation/sentry/init-client*.{ts,js}`, `**/instrumentation/sentry/init-server*.{ts,js}`, `**/setup/no-instrumentation.{ts,js}`, `apps/*/instrumentation*.{ts,mjs,js}`, `apps/*/next.config.{mjs,ts,js}`, `apps/*/vite.config.{ts,mjs,js}`
|
||||||
- R52 (`@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`): `**/instrumentation/otel/**`
|
- `@opentelemetry/sdk-*`, `@opentelemetry/instrumentation-*`, `@opentelemetry/resources`, `@opentelemetry/semantic-conventions`, `@sentry/opentelemetry`: `**/instrumentation/otel/**`
|
||||||
|
|
||||||
The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`.
|
The vendor-neutral API packages (`@opentelemetry/api`, `@opentelemetry/api-logs`) are unrestricted within `core-shared/instrumentation/`.
|
||||||
|
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -76,7 +76,7 @@ See `docs/architecture/agent-first-workflow-and-conformance.md` for the full des
|
|||||||
- **`@/` alias in tests** — Test files (`*.test.ts`) use `@/` to import from `src/`
|
- **`@/` 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") }`
|
- **`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
|
- **`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`)
|
- **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)
|
- **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
|
- **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
|
- **Tests inject mocks directly** — Construct `MockXRepository` and pass into the factory: `signInUseCase(mockUsers, mockAuth)(input)`. No container rebinding in unit tests
|
||||||
@@ -88,10 +88,10 @@ See `docs/architecture/agent-first-workflow-and-conformance.md` for the full des
|
|||||||
- **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
|
- **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
|
- **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
|
- **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 (R40 + R52, ESLint-enforced); the vendor-neutral `@opentelemetry/api` family is the import surface for advanced cases (ADR-017)
|
- **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 (R41, R42)
|
- **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 (R43, R44). 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`)
|
- **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-negotiable** — `sendDefaultPii: false` everywhere (R31, CI grep gate); replay default-masks all text/inputs/media (R34, R35, allowlist starts empty); `setUser({ id })` only — no email/username (R36); server-side PII scrubbing happens at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before any exporter sees the data (R32, R33, ADR-017 §7)
|
- **PII handling is non-negotiable** — `sendDefaultPii: 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 projects** — `WEB_NEXT_SENTRY_DSN`, `CMS_SENTRY_DSN`, `WEB_TANSTACK_SENTRY_DSN`. Browser DSNs use `NEXT_PUBLIC_` (web-next) and `VITE_` (web-tanstack) prefixes
|
- **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 → 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
|
- **Instrumentation binding is orthogonal to repo binding** — `bindAll()`'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. `auth` → `marketing-pages` welcome email)
|
- **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)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ docker compose up -d # Start PostgreSQL
|
|||||||
## Scaffolding
|
## Scaffolding
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm turbo gen feature <name> # Lazar-conformant feature (manifest + contracts + tests)
|
pnpm turbo gen feature <name> # Scaffold a feature (manifest + contracts + tests)
|
||||||
pnpm turbo gen event # Event contract or handler (requires gen core-package events)
|
pnpm turbo gen event # Event contract or handler (requires gen core-package events)
|
||||||
pnpm turbo gen job # Background job
|
pnpm turbo gen job # Background job
|
||||||
pnpm turbo gen realtime # Realtime channel (requires gen core-package realtime)
|
pnpm turbo gen realtime # Realtime channel (requires gen core-package realtime)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ tRPC router, CMS collection, DI container, and query builders — all owned
|
|||||||
by one package under `packages/<feature>/`.
|
by one package under `packages/<feature>/`.
|
||||||
|
|
||||||
> **Prefer the generator.** `pnpm turbo gen feature` produces a
|
> **Prefer the generator.** `pnpm turbo gen feature` produces a
|
||||||
> Lazar-conformant single-entity / single-use-case package matching the
|
> A single-entity / single-use-case package matching the
|
||||||
> `navigation` reference shape (DI, tRPC router with tests, span + capture
|
> `navigation` reference shape (DI, tRPC router with tests, span + capture
|
||||||
> sandwich, dev seed, contract suite). See
|
> sandwich, dev seed, contract suite). See
|
||||||
> [Scaffolding a Feature](./scaffolding-a-feature.md). Use this guide when
|
> [Scaffolding a Feature](./scaffolding-a-feature.md). Use this guide when
|
||||||
@@ -44,20 +44,20 @@ For the fast path, run `pnpm turbo gen feature <name>` — the generator emits t
|
|||||||
|
|
||||||
Every feature package owns:
|
Every feature package owns:
|
||||||
|
|
||||||
| Layer | What lives there |
|
| Layer | What lives there |
|
||||||
|---|---|
|
| --------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `entities/models/` | Zod schemas + inferred TypeScript types |
|
| `entities/models/` | Zod schemas + inferred TypeScript types |
|
||||||
| `entities/errors/` | Domain error classes (`this.name` required); `common.ts` for `InputParseError` |
|
| `entities/errors/` | Domain error classes (`this.name` required); `common.ts` for `InputParseError` |
|
||||||
| `application/repositories/` | Repository interface (no implementation) |
|
| `application/repositories/` | Repository interface (no implementation) |
|
||||||
| `application/use-cases/` | One factory per operation; owns `xInputSchema`, `xOutputSchema`, and `xOutputSchema.parse()` |
|
| `application/use-cases/` | One factory per operation; owns `xInputSchema`, `xOutputSchema`, and `xOutputSchema.parse()` |
|
||||||
| `infrastructure/repositories/` | Real (`<noun>.repository.ts`) and mock (`<noun>.repository.mock.ts`) siblings |
|
| `infrastructure/repositories/` | Real (`<noun>.repository.ts`) and mock (`<noun>.repository.mock.ts`) siblings |
|
||||||
| `interface-adapters/controllers/` | One factory per use case; accepts `unknown`, calls `safeParse`, runs presenter |
|
| `interface-adapters/controllers/` | One factory per use case; accepts `unknown`, calls `safeParse`, runs presenter |
|
||||||
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
|
| `di/` | `symbols.ts` + `module.ts` + `container.ts` + `bind-production.ts` |
|
||||||
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
|
| `integrations/api/` | `procedures.ts` (feature error map) + `router.ts` (uses `xProcedure.input(xInputSchema)`) |
|
||||||
| `integrations/cms/` | Payload collection/global configs |
|
| `integrations/cms/` | Payload collection/global configs |
|
||||||
| `ui/` | Query builders and future React components (behind `./ui` subpath) |
|
| `ui/` | Query builders and future React components (behind `./ui` subpath) |
|
||||||
| `__factories__/` | Test data factories |
|
| `__factories__/` | Test data factories |
|
||||||
| `__contracts__/` | Contract suites shared by mock and real repository tests |
|
| `__contracts__/` | Contract suites shared by mock and real repository tests |
|
||||||
|
|
||||||
The walkthrough below builds a minimal `comments` feature from scratch.
|
The walkthrough below builds a minimal `comments` feature from scratch.
|
||||||
All concrete code mirrors the `blog` package (the most fully developed
|
All concrete code mirrors the `blog` package (the most fully developed
|
||||||
@@ -108,7 +108,7 @@ packages/comments/
|
|||||||
api/
|
api/
|
||||||
procedures.ts # commentsProcedure with feature error map
|
procedures.ts # commentsProcedure with feature error map
|
||||||
router.ts # commentsProcedure.input(xInputSchema)
|
router.ts # commentsProcedure.input(xInputSchema)
|
||||||
router.test.ts # includes R26 error-mapping assertions
|
router.test.ts # includes error-mapping assertions
|
||||||
index.ts
|
index.ts
|
||||||
cms/
|
cms/
|
||||||
collections/
|
collections/
|
||||||
@@ -245,7 +245,13 @@ describe("commentSchema", () => {
|
|||||||
|
|
||||||
it("rejects an empty body", () => {
|
it("rejects an empty body", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
commentSchema.parse({ id: "c-1", articleId: "a-1", body: "", authorId: "u-1", createdAt: new Date() }),
|
commentSchema.parse({
|
||||||
|
id: "c-1",
|
||||||
|
articleId: "a-1",
|
||||||
|
body: "",
|
||||||
|
authorId: "u-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
}),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -285,7 +291,7 @@ pnpm test --filter @repo/comments -- comment.test.ts # GREEN
|
|||||||
export class CommentNotFoundError extends Error {
|
export class CommentNotFoundError extends Error {
|
||||||
constructor(message = "Comment not found", options?: ErrorOptions) {
|
constructor(message = "Comment not found", options?: ErrorOptions) {
|
||||||
super(message, options);
|
super(message, options);
|
||||||
this.name = "CommentNotFoundError"; // required — R6
|
this.name = "CommentNotFoundError"; // required — R6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -295,7 +301,7 @@ export class CommentNotFoundError extends Error {
|
|||||||
export class InputParseError extends Error {
|
export class InputParseError extends Error {
|
||||||
constructor(message: string, options?: ErrorOptions) {
|
constructor(message: string, options?: ErrorOptions) {
|
||||||
super(message, options);
|
super(message, options);
|
||||||
this.name = "InputParseError"; // required — R6
|
this.name = "InputParseError"; // required — R6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -364,6 +370,7 @@ export const commentFactory = defineFactory<Comment>(({ sequence }) => ({
|
|||||||
### Step 9: Use case — factory function with input/output schemas (RED → GREEN)
|
### Step 9: Use case — factory function with input/output schemas (RED → GREEN)
|
||||||
|
|
||||||
Every use case exports:
|
Every use case exports:
|
||||||
|
|
||||||
- `xInputSchema` — a `z.ZodObject` with `.strict()` (use `z.object({}).strict()` for void inputs)
|
- `xInputSchema` — a `z.ZodObject` with `.strict()` (use `z.object({}).strict()` for void inputs)
|
||||||
- `xOutputSchema` — for non-void use cases
|
- `xOutputSchema` — for non-void use cases
|
||||||
- `XInput` / `XOutput` types
|
- `XInput` / `XOutput` types
|
||||||
@@ -404,14 +411,16 @@ describe("getCommentsUseCase", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// R25 — output validation
|
// output validation
|
||||||
describe("getCommentsUseCase output validation (R25)", () => {
|
describe("getCommentsUseCase output validation", () => {
|
||||||
it("throws ZodError when the repository returns malformed data", async () => {
|
it("throws ZodError when the repository returns malformed data", async () => {
|
||||||
const repo = new MockCommentsRepository();
|
const repo = new MockCommentsRepository();
|
||||||
(repo as unknown as { _comments: unknown[] })._comments.push({ id: 123 });
|
(repo as unknown as { _comments: unknown[] })._comments.push({ id: 123 });
|
||||||
|
|
||||||
const useCase = getCommentsUseCase(repo);
|
const useCase = getCommentsUseCase(repo);
|
||||||
await expect(useCase({ articleId: "a-1" })).rejects.toBeInstanceOf(ZodError);
|
await expect(useCase({ articleId: "a-1" })).rejects.toBeInstanceOf(
|
||||||
|
ZodError,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports getCommentsOutputSchema that validates Comment[]", () => {
|
it("exports getCommentsOutputSchema that validates Comment[]", () => {
|
||||||
@@ -448,7 +457,9 @@ export type IGetCommentsUseCase = ReturnType<typeof getCommentsUseCase>;
|
|||||||
export const getCommentsUseCase =
|
export const getCommentsUseCase =
|
||||||
(commentsRepository: ICommentsRepository) =>
|
(commentsRepository: ICommentsRepository) =>
|
||||||
async (input: GetCommentsInput): Promise<GetCommentsOutput> => {
|
async (input: GetCommentsInput): Promise<GetCommentsOutput> => {
|
||||||
const result = await commentsRepository.getCommentsForArticle(input.articleId);
|
const result = await commentsRepository.getCommentsForArticle(
|
||||||
|
input.articleId,
|
||||||
|
);
|
||||||
return getCommentsOutputSchema.parse(result);
|
return getCommentsOutputSchema.parse(result);
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -586,7 +597,9 @@ describe("getCommentsController", () => {
|
|||||||
it("throws InputParseError on unknown extra fields (strict)", async () => {
|
it("throws InputParseError on unknown extra fields (strict)", async () => {
|
||||||
const repo = new MockCommentsRepository();
|
const repo = new MockCommentsRepository();
|
||||||
const ctrl = getCommentsController(getCommentsUseCase(repo));
|
const ctrl = getCommentsController(getCommentsUseCase(repo));
|
||||||
await expect(ctrl({ articleId: "a-1", extra: true })).rejects.toBeInstanceOf(InputParseError);
|
await expect(
|
||||||
|
ctrl({ articleId: "a-1", extra: true }),
|
||||||
|
).rejects.toBeInstanceOf(InputParseError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -617,7 +630,9 @@ export const getCommentsController =
|
|||||||
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = getCommentsInputSchema.safeParse(input);
|
const parsed = getCommentsInputSchema.safeParse(input);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new InputParseError("Invalid get-comments input", { cause: parsed.error });
|
throw new InputParseError("Invalid get-comments input", {
|
||||||
|
cause: parsed.error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const result = await getCommentsUseCase(parsed.data);
|
const result = await getCommentsUseCase(parsed.data);
|
||||||
return presenter(result);
|
return presenter(result);
|
||||||
@@ -659,17 +674,27 @@ import {
|
|||||||
import { COMMENTS_SYMBOLS } from "./symbols";
|
import { COMMENTS_SYMBOLS } from "./symbols";
|
||||||
|
|
||||||
export const CommentsModule = new ContainerModule((bind: interfaces.Bind) => {
|
export const CommentsModule = new ContainerModule((bind: interfaces.Bind) => {
|
||||||
bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(MockCommentsRepository);
|
bind<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository).to(
|
||||||
|
MockCommentsRepository,
|
||||||
|
);
|
||||||
|
|
||||||
bind<IGetCommentsUseCase>(COMMENTS_SYMBOLS.IGetCommentsUseCase).toDynamicValue((ctx) =>
|
bind<IGetCommentsUseCase>(
|
||||||
|
COMMENTS_SYMBOLS.IGetCommentsUseCase,
|
||||||
|
).toDynamicValue((ctx) =>
|
||||||
getCommentsUseCase(
|
getCommentsUseCase(
|
||||||
ctx.container.get<ICommentsRepository>(COMMENTS_SYMBOLS.ICommentsRepository),
|
ctx.container.get<ICommentsRepository>(
|
||||||
|
COMMENTS_SYMBOLS.ICommentsRepository,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
bind<IGetCommentsController>(COMMENTS_SYMBOLS.IGetCommentsController).toDynamicValue((ctx) =>
|
bind<IGetCommentsController>(
|
||||||
|
COMMENTS_SYMBOLS.IGetCommentsController,
|
||||||
|
).toDynamicValue((ctx) =>
|
||||||
getCommentsController(
|
getCommentsController(
|
||||||
ctx.container.get<IGetCommentsUseCase>(COMMENTS_SYMBOLS.IGetCommentsUseCase),
|
ctx.container.get<IGetCommentsUseCase>(
|
||||||
|
COMMENTS_SYMBOLS.IGetCommentsUseCase,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -695,15 +720,21 @@ import { COMMENTS_SYMBOLS } from "@/di/symbols";
|
|||||||
|
|
||||||
describe("commentsContainer", () => {
|
describe("commentsContainer", () => {
|
||||||
it("resolves ICommentsRepository", () => {
|
it("resolves ICommentsRepository", () => {
|
||||||
expect(commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository)).toBeDefined();
|
expect(
|
||||||
|
commentsContainer.get(COMMENTS_SYMBOLS.ICommentsRepository),
|
||||||
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves IGetCommentsUseCase", () => {
|
it("resolves IGetCommentsUseCase", () => {
|
||||||
expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase)).toBeDefined();
|
expect(
|
||||||
|
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsUseCase),
|
||||||
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves IGetCommentsController", () => {
|
it("resolves IGetCommentsController", () => {
|
||||||
expect(commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController)).toBeDefined();
|
expect(
|
||||||
|
commentsContainer.get(COMMENTS_SYMBOLS.IGetCommentsController),
|
||||||
|
).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -733,9 +764,10 @@ export const commentsProcedure = t.procedure.use(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 14: tRPC router (RED → GREEN, includes R26 error-mapping test)
|
### Step 14: tRPC router (RED → GREEN, includes error-mapping test)
|
||||||
|
|
||||||
The router:
|
The router:
|
||||||
|
|
||||||
- uses `commentsProcedure` (never bare `publicProcedure`)
|
- uses `commentsProcedure` (never bare `publicProcedure`)
|
||||||
- calls `.input(xInputSchema)` importing from the use-case file — never redefines the schema inline
|
- calls `.input(xInputSchema)` importing from the use-case file — never redefines the schema inline
|
||||||
- resolves controllers from the container
|
- resolves controllers from the container
|
||||||
@@ -758,7 +790,9 @@ describe("commentsRouter", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exposes getComments procedure", () => {
|
it("exposes getComments procedure", () => {
|
||||||
expect(Object.keys(commentsRouter._def.procedures)).toContain("getComments");
|
expect(Object.keys(commentsRouter._def.procedures)).toContain(
|
||||||
|
"getComments",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getComments returns empty array by default", async () => {
|
it("getComments returns empty array by default", async () => {
|
||||||
@@ -767,8 +801,8 @@ describe("commentsRouter", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// R26 — error mapping
|
// error mapping
|
||||||
describe("commentsRouter (R26 error mapping)", () => {
|
describe("commentsRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
commentsContainer.unbindAll();
|
commentsContainer.unbindAll();
|
||||||
commentsContainer.load(CommentsModule);
|
commentsContainer.load(CommentsModule);
|
||||||
@@ -871,7 +905,11 @@ export class CommentsRepository implements ICommentsRepository {
|
|||||||
async getComment(id: string): Promise<Comment | undefined> {
|
async getComment(id: string): Promise<Comment | undefined> {
|
||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
try {
|
try {
|
||||||
const doc = await payload.findByID({ collection: "comments", id, overrideAccess: true });
|
const doc = await payload.findByID({
|
||||||
|
collection: "comments",
|
||||||
|
id,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
return mapDoc(doc as PayloadCommentDoc);
|
return mapDoc(doc as PayloadCommentDoc);
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -892,7 +930,11 @@ export class CommentsRepository implements ICommentsRepository {
|
|||||||
const payload = await getPayload({ config: this.config });
|
const payload = await getPayload({ config: this.config });
|
||||||
const created = await payload.create({
|
const created = await payload.create({
|
||||||
collection: "comments",
|
collection: "comments",
|
||||||
data: { articleId: input.articleId, body: input.body, author: input.authorId } as never,
|
data: {
|
||||||
|
articleId: input.articleId,
|
||||||
|
body: input.body,
|
||||||
|
author: input.authorId,
|
||||||
|
} as never,
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
return mapDoc(created as PayloadCommentDoc);
|
return mapDoc(created as PayloadCommentDoc);
|
||||||
@@ -917,13 +959,17 @@ describe("CommentsRepository", () => {
|
|||||||
const store = new Map<string, Record<string, unknown>>();
|
const store = new Map<string, Record<string, unknown>>();
|
||||||
const stub = {
|
const stub = {
|
||||||
findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)),
|
findByID: vi.fn(async ({ id }: { id: string }) => store.get(id)),
|
||||||
find: vi.fn(async ({ where }: { where?: { articleId?: { equals: string } } }) => {
|
find: vi.fn(
|
||||||
let docs = Array.from(store.values());
|
async ({ where }: { where?: { articleId?: { equals: string } } }) => {
|
||||||
if (where?.articleId) {
|
let docs = Array.from(store.values());
|
||||||
docs = docs.filter((d) => d["articleId"] === where.articleId?.equals);
|
if (where?.articleId) {
|
||||||
}
|
docs = docs.filter(
|
||||||
return { docs };
|
(d) => d["articleId"] === where.articleId?.equals,
|
||||||
}),
|
);
|
||||||
|
}
|
||||||
|
return { docs };
|
||||||
|
},
|
||||||
|
),
|
||||||
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
const doc = { id: `stub-${store.size + 1}`, ...data };
|
const doc = { id: `stub-${store.size + 1}`, ...data };
|
||||||
store.set(String(doc.id), doc);
|
store.set(String(doc.id), doc);
|
||||||
@@ -996,7 +1042,12 @@ export const comments: CollectionConfig = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ name: "articleId", type: "text", required: true },
|
{ name: "articleId", type: "text", required: true },
|
||||||
{ name: "body", type: "textarea", required: true },
|
{ name: "body", type: "textarea", required: true },
|
||||||
{ name: "author", type: "relationship", relationTo: "users", required: true },
|
{
|
||||||
|
name: "author",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "users",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -1089,7 +1140,9 @@ Add path aliases to `tsconfig.base.json`:
|
|||||||
"@repo/comments/api": ["packages/comments/src/integrations/api/index.ts"],
|
"@repo/comments/api": ["packages/comments/src/integrations/api/index.ts"],
|
||||||
"@repo/comments/ui": ["packages/comments/src/ui/index.ts"],
|
"@repo/comments/ui": ["packages/comments/src/ui/index.ts"],
|
||||||
"@repo/comments/cms": ["packages/comments/src/integrations/cms/index.ts"],
|
"@repo/comments/cms": ["packages/comments/src/integrations/cms/index.ts"],
|
||||||
"@repo/comments/di/bind-production": ["packages/comments/src/di/bind-production.ts"]
|
"@repo/comments/di/bind-production": [
|
||||||
|
"packages/comments/src/di/bind-production.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1131,14 +1184,14 @@ All must pass before shipping.
|
|||||||
|
|
||||||
## 4. Configuration Checklist
|
## 4. Configuration Checklist
|
||||||
|
|
||||||
| File | Key items |
|
| File | Key items |
|
||||||
|---|---|
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `package.json` | `"type": "module"`; exports map with `.`, `./ui`, `./api`, `./cms`, `./di/bind-production`; `@repo/core-shared`, `inversify`, `zod`, `payload` in deps |
|
| `package.json` | `"type": "module"`; exports map with `.`, `./ui`, `./api`, `./cms`, `./di/bind-production`; `@repo/core-shared`, `inversify`, `zod`, `payload` in deps |
|
||||||
| `tsconfig.json` | `"rootDir": "."` (covers both `src/` and `tests/`); `"outDir": "dist"` |
|
| `tsconfig.json` | `"rootDir": "."` (covers both `src/` and `tests/`); `"outDir": "dist"` |
|
||||||
| `vitest.config.ts` | `resolve.alias: { "@": path.resolve(__dirname, "./src") }` |
|
| `vitest.config.ts` | `resolve.alias: { "@": path.resolve(__dirname, "./src") }` |
|
||||||
| `eslint.config.js` | extends `@repo/core-eslint`; tag set to `"feature"` in Turborepo `turbo.json` |
|
| `eslint.config.js` | extends `@repo/core-eslint`; tag set to `"feature"` in Turborepo `turbo.json` |
|
||||||
| `tsconfig.base.json` | path aliases for every subpath export |
|
| `tsconfig.base.json` | path aliases for every subpath export |
|
||||||
| `turbo.json` | feature package must appear (or be glob-matched) in the workspace graph |
|
| `turbo.json` | feature package must appear (or be glob-matched) in the workspace graph |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1168,7 +1221,7 @@ All must pass before shipping.
|
|||||||
`Promise<ReturnType<typeof presenter>>`. Identity (`return value`) is
|
`Promise<ReturnType<typeof presenter>>`. Identity (`return value`) is
|
||||||
fine, but the function must exist. Skipping it makes adding a view
|
fine, but the function must exist. Skipping it makes adding a view
|
||||||
transform later a structural change instead of a one-line edit
|
transform later a structural change instead of a one-line edit
|
||||||
(ADR-013 R11).
|
(ADR-013).
|
||||||
|
|
||||||
5. **Adding feature error classes to `core-shared`.** `core-shared` must
|
5. **Adding feature error classes to `core-shared`.** `core-shared` must
|
||||||
stay boundary-clean — it provides `defineErrorMiddleware` but knows
|
stay boundary-clean — it provides `defineErrorMiddleware` but knows
|
||||||
@@ -1194,22 +1247,16 @@ All must pass before shipping.
|
|||||||
|
|
||||||
## 6. Cross-References
|
## 6. Cross-References
|
||||||
|
|
||||||
- **ADR-012** (`docs/decisions/adr-012-lazar-conformance.md`) — factory-function
|
- **ADR-012** (`docs/decisions/adr-012-feature-conventions.md`) — factory-function
|
||||||
use cases and controllers, entity layout, file naming, one-controller-per-use-case,
|
use cases and controllers, entity layout, file naming, one-controller-per-use-case,
|
||||||
`.toDynamicValue()` DI bindings, direct injection in tests.
|
`.toDynamicValue()` DI bindings, direct injection in tests.
|
||||||
- **ADR-013** (`docs/decisions/adr-013-input-output-unification.md`) — use-case
|
- **ADR-013** (`docs/decisions/adr-013-input-output-unification.md`) — use-case
|
||||||
file as single source for `xInputSchema` + `xOutputSchema`; presenter pattern;
|
file as single source for `xInputSchema` + `xOutputSchema`; presenter pattern;
|
||||||
per-feature `procedures.ts` error map; public surface split (`./` vs `./ui`).
|
per-feature `procedures.ts` error map; public surface split (`./` vs `./ui`).
|
||||||
- **Refactor log — Plan 8** (`docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`) —
|
|
||||||
file-by-file inventory of every rename, split, and pattern change applied
|
|
||||||
to all existing features.
|
|
||||||
- **Refactor log — Plan 9** (`docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`) —
|
|
||||||
inventory of schema additions, presenter additions, `procedures.ts` additions,
|
|
||||||
and `./ui` subpath additions across all 5 features.
|
|
||||||
- **CLAUDE.md** (root) — Key Conventions section is the quick-reference
|
- **CLAUDE.md** (root) — Key Conventions section is the quick-reference
|
||||||
summary; this guide is the authoritative walkthrough.
|
summary; this guide is the authoritative walkthrough.
|
||||||
- **Architecture overview** (`docs/architecture/overview.md`) — canonical
|
- **Architecture overview** (`docs/architecture/overview.md`) — canonical
|
||||||
data-flow diagram showing the full request path from React component
|
data-flow diagram showing the full request path from React component
|
||||||
through tRPC, controller, use case, repository, and back.
|
through tRPC, controller, use case, repository, and back.
|
||||||
- **TDD Workflow** (`docs/guides/tdd-workflow.md`) — required reading on
|
- **TDD Workflow** (`docs/guides/tdd-workflow.md`) — required reading on
|
||||||
RED → GREEN discipline, direct factory injection, and R25/R26 test obligations.
|
RED → GREEN discipline, direct factory injection, and test obligations per layer.
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ This emits:
|
|||||||
|
|
||||||
- `packages/<name>/src/feature.manifest.ts` — the conformance manifest (use cases, audits, publishes, consumes)
|
- `packages/<name>/src/feature.manifest.ts` — the conformance manifest (use cases, audits, publishes, consumes)
|
||||||
- `packages/<name>/src/di/bind-production.ts` with `assertFeatureConformance(...)` at the tail (refuses to boot on drift)
|
- `packages/<name>/src/di/bind-production.ts` with `assertFeatureConformance(...)` at the tail (refuses to boot on drift)
|
||||||
- Mock repository, factory, seed, entity, use-case, controller, tests — full Lazar-conformant shape
|
- Mock repository, factory, seed, entity, use-case, controller, tests — full canonical shape
|
||||||
- `packages/<name>/src/index.ts` exports
|
- `packages/<name>/src/index.ts` exports
|
||||||
|
|
||||||
After scaffolding, the four-step ordering for any new use case:
|
After scaffolding, the four-step ordering for any new use case:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Scaffolding a feature
|
# Scaffolding a feature
|
||||||
|
|
||||||
`turbo gen feature` produces a Lazar-conformant feature package under
|
`turbo gen feature` produces a feature package under
|
||||||
`packages/<name>/` matching the shape of the reference `navigation` feature.
|
`packages/<name>/` matching the shape of the reference `navigation` feature.
|
||||||
|
|
||||||
## Invoking the generator
|
## Invoking the generator
|
||||||
@@ -18,11 +18,11 @@ Non-interactive (positional bypass — order matches the prompts in
|
|||||||
pnpm turbo gen feature --args <name> <Entity> <entities-plural>
|
pnpm turbo gen feature --args <name> <Entity> <entities-plural>
|
||||||
```
|
```
|
||||||
|
|
||||||
| Position | Prompt | Example | Conventions |
|
| Position | Prompt | Example | Conventions |
|
||||||
|---|---|---|---|
|
| ------------------- | -------------------- | --------- | ----------------------------------------------------------- |
|
||||||
| `<name>` | Feature package name | `widgets` | `kebab-case`, becomes `@repo/<name>` and `packages/<name>/` |
|
| `<name>` | Feature package name | `widgets` | `kebab-case`, becomes `@repo/<name>` and `packages/<name>/` |
|
||||||
| `<Entity>` | Entity name | `Widget` | `PascalCase` singular, drives class/symbol/use-case names |
|
| `<Entity>` | Entity name | `Widget` | `PascalCase` singular, drives class/symbol/use-case names |
|
||||||
| `<entities-plural>` | Entity plural slug | `widgets` | `kebab-case`, used for the future Payload collection slug |
|
| `<entities-plural>` | Entity plural slug | `widgets` | `kebab-case`, used for the future Payload collection slug |
|
||||||
|
|
||||||
Example end-to-end:
|
Example end-to-end:
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ See `docs/guides/conformance-quickref.md` for the manifest field reference.
|
|||||||
- DI: `symbols.ts`, `module.ts`, `container.ts`, plus
|
- DI: `symbols.ts`, `module.ts`, `container.ts`, plus
|
||||||
`bind-production.ts` and `bind-dev-seed.ts` that compose
|
`bind-production.ts` and `bind-dev-seed.ts` that compose
|
||||||
`withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` at
|
`withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` at
|
||||||
bind time (post-R44 sandwich pattern)
|
bind time (span + capture sandwich pattern)
|
||||||
- tRPC integration: `procedures.ts` (feature-scoped error middleware) and
|
- tRPC integration: `procedures.ts` (feature-scoped error middleware) and
|
||||||
`router.ts` exposing `get<Entity>` with full router tests including
|
`router.ts` exposing `get<Entity>` with full router tests including
|
||||||
`BAD_REQUEST` / `NOT_FOUND` mapping
|
`BAD_REQUEST` / `NOT_FOUND` mapping
|
||||||
@@ -123,8 +123,8 @@ The realtime generators insert at three additional fixed `// <gen:realtime-*>` a
|
|||||||
schemas-in-use-case, three binding modes per feature, span + capture sandwich)
|
schemas-in-use-case, three binding modes per feature, span + capture sandwich)
|
||||||
- `packages/navigation/AGENTS.md` — canonical reference shape the templates mirror
|
- `packages/navigation/AGENTS.md` — canonical reference shape the templates mirror
|
||||||
- `docs/architecture/vertical-feature-spec.md` — design rationale for the layout
|
- `docs/architecture/vertical-feature-spec.md` — design rationale for the layout
|
||||||
- `docs/decisions/adr-012-lazar-conformance.md` — file naming + factory pattern
|
- `docs/decisions/adr-012-feature-conventions.md` — file naming + factory pattern
|
||||||
- `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter
|
- `docs/decisions/adr-013-input-output-unification.md` — schemas-in-use-case + presenter
|
||||||
- `docs/decisions/adr-014-instrumentation-sentry.md` — span + capture wiring (R41–R44)
|
- `docs/decisions/adr-014-instrumentation-sentry.md` — span + capture wiring
|
||||||
- `docs/decisions/adr-015-events-and-jobs.md` — cross-feature events + background jobs
|
- `docs/decisions/adr-015-events-and-jobs.md` — cross-feature events + background jobs
|
||||||
- `docs/decisions/adr-016-realtime-layer.md` — Socket.IO realtime channels + handlers
|
- `docs/decisions/adr-016-realtime-layer.md` — Socket.IO realtime channels + handlers
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ describe("getArticleBySlugController", () => {
|
|||||||
it("returns the article when the slug exists", async () => {
|
it("returns the article when the slug exists", async () => {
|
||||||
const repo = new MockArticlesRepository();
|
const repo = new MockArticlesRepository();
|
||||||
articleFactory.reset();
|
articleFactory.reset();
|
||||||
await repo.createArticle(articleFactory.build({ slug: "hello-world", authorId: "u1" }));
|
await repo.createArticle(
|
||||||
|
articleFactory.build({ slug: "hello-world", authorId: "u1" }),
|
||||||
|
);
|
||||||
|
|
||||||
const useCase = getArticleBySlugUseCase(repo);
|
const useCase = getArticleBySlugUseCase(repo);
|
||||||
const controller = getArticleBySlugController(useCase);
|
const controller = getArticleBySlugController(useCase);
|
||||||
@@ -38,9 +40,9 @@ describe("getArticleBySlugController", () => {
|
|||||||
const useCase = getArticleBySlugUseCase(repo);
|
const useCase = getArticleBySlugUseCase(repo);
|
||||||
const controller = getArticleBySlugController(useCase);
|
const controller = getArticleBySlugController(useCase);
|
||||||
|
|
||||||
await expect(
|
await expect(controller({ slug: "no-such-slug" })).rejects.toBeInstanceOf(
|
||||||
controller({ slug: "no-such-slug" }),
|
ArticleNotFoundError,
|
||||||
).rejects.toBeInstanceOf(ArticleNotFoundError);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -60,23 +62,31 @@ pnpm test --filter @repo/blog -- get-article-by-slug.controller.test.ts
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts
|
// packages/blog/src/interface-adapters/controllers/get-article-by-slug.controller.ts
|
||||||
import type { IGetArticleBySlugUseCase, GetArticleBySlugOutput } from
|
import type {
|
||||||
"../application/use-cases/get-article-by-slug.use-case";
|
IGetArticleBySlugUseCase,
|
||||||
|
GetArticleBySlugOutput,
|
||||||
|
} from "../application/use-cases/get-article-by-slug.use-case";
|
||||||
import { getArticleBySlugInputSchema } from "../application/use-cases/get-article-by-slug.use-case";
|
import { getArticleBySlugInputSchema } from "../application/use-cases/get-article-by-slug.use-case";
|
||||||
import { InputParseError } from "../entities/errors/common";
|
import { InputParseError } from "../entities/errors/common";
|
||||||
|
|
||||||
function presenter(value: GetArticleBySlugOutput) { return value; }
|
function presenter(value: GetArticleBySlugOutput) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function getArticleBySlugController(useCase: IGetArticleBySlugUseCase) {
|
export function getArticleBySlugController(useCase: IGetArticleBySlugUseCase) {
|
||||||
return async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
return async (input: unknown): Promise<ReturnType<typeof presenter>> => {
|
||||||
const parsed = getArticleBySlugInputSchema.safeParse(input);
|
const parsed = getArticleBySlugInputSchema.safeParse(input);
|
||||||
if (!parsed.success)
|
if (!parsed.success)
|
||||||
throw new InputParseError("Invalid get-article-by-slug input", { cause: parsed.error });
|
throw new InputParseError("Invalid get-article-by-slug input", {
|
||||||
|
cause: parsed.error,
|
||||||
|
});
|
||||||
return presenter(await useCase(parsed.data));
|
return presenter(await useCase(parsed.data));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IGetArticleBySlugController = ReturnType<typeof getArticleBySlugController>;
|
export type IGetArticleBySlugController = ReturnType<
|
||||||
|
typeof getArticleBySlugController
|
||||||
|
>;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Run again — confirm GREEN:**
|
**Run again — confirm GREEN:**
|
||||||
@@ -140,6 +150,7 @@ describe("getArticleBySlugController", () => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- `describe` names the class or function under test — not the file.
|
- `describe` names the class or function under test — not the file.
|
||||||
- `it` uses active voice: `returns`, `throws`, `filters`, `creates`.
|
- `it` uses active voice: `returns`, `throws`, `filters`, `creates`.
|
||||||
- Conditions go after `when`: `it("returns undefined when slug is missing")`.
|
- Conditions go after `when`: `it("returns undefined when slug is missing")`.
|
||||||
@@ -232,15 +243,15 @@ The rule: mock the thing your layer depends on, never the thing under test.
|
|||||||
|
|
||||||
## 5. Test Pyramid for This Monorepo
|
## 5. Test Pyramid for This Monorepo
|
||||||
|
|
||||||
| Layer | Tool | Target ratio | Location pattern |
|
| Layer | Tool | Target ratio | Location pattern |
|
||||||
|---|---|---|---|
|
| ---------------------------- | ----------------------- | ---------------------------- | ---------------------------------------------- |
|
||||||
| Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/models/*.test.ts` |
|
| Entity (schema, type guards) | Vitest | Highest — every entity | `src/entities/models/*.test.ts` |
|
||||||
| Use case (business logic) | Vitest + mock repo | High — every use case | `src/application/use-cases/*.test.ts` |
|
| Use case (business logic) | Vitest + mock repo | High — every use case | `src/application/use-cases/*.test.ts` |
|
||||||
| Controller (input parsing) | Vitest + mock repo | High — every controller | `src/interface-adapters/controllers/*.test.ts` |
|
| Controller (input parsing) | Vitest + mock repo | High — every controller | `src/interface-adapters/controllers/*.test.ts` |
|
||||||
| Repository contract | Vitest + contract suite | One per impl | `src/infrastructure/repositories/*.test.ts` |
|
| Repository contract | Vitest + contract suite | One per impl | `src/infrastructure/repositories/*.test.ts` |
|
||||||
| Feature integration (tRPC) | Vitest + createCaller | Medium — happy path + error | `src/integrations/api/router.test.ts` |
|
| Feature integration (tRPC) | Vitest + createCaller | Medium — happy path + error | `src/integrations/api/router.test.ts` |
|
||||||
| Component | Vitest + RTL | Per UI component | `src/ui/**/*.test.tsx` |
|
| Component | Vitest + RTL | Per UI component | `src/ui/**/*.test.tsx` |
|
||||||
| E2E | Playwright | Few — smoke + critical flows | `apps/web-next/e2e/*.spec.ts` |
|
| E2E | Playwright | Few — smoke + critical flows | `apps/web-next/e2e/*.spec.ts` |
|
||||||
|
|
||||||
Entities and use cases have the highest ratio because they encode business rules. E2E tests have the lowest ratio because they are slow and test the full stack.
|
Entities and use cases have the highest ratio because they encode business rules. E2E tests have the lowest ratio because they are slow and test the full stack.
|
||||||
|
|
||||||
@@ -260,13 +271,13 @@ Entities and use cases have the highest ratio because they encode business rules
|
|||||||
|
|
||||||
## 7. Coverage Targets
|
## 7. Coverage Targets
|
||||||
|
|
||||||
| Scope | Statements | Branches | Functions | Lines |
|
| Scope | Statements | Branches | Functions | Lines |
|
||||||
|---|---|---|---|---|
|
| ----------------------- | ---------- | -------- | --------- | ----- |
|
||||||
| Baseline (all packages) | 80% | 75% | 80% | 80% |
|
| Baseline (all packages) | 80% | 75% | 80% | 80% |
|
||||||
| Entities | 100% | 100% | 100% | 100% |
|
| Entities | 100% | 100% | 100% | 100% |
|
||||||
| Use cases | 100% | 100% | 100% | 100% |
|
| Use cases | 100% | 100% | 100% | 100% |
|
||||||
| Controllers | 100% | 100% | 100% | 100% |
|
| Controllers | 100% | 100% | 100% | 100% |
|
||||||
| Infrastructure (repos) | 80% | 75% | 80% | 80% |
|
| Infrastructure (repos) | 80% | 75% | 80% | 80% |
|
||||||
|
|
||||||
**Inspect coverage locally:**
|
**Inspect coverage locally:**
|
||||||
|
|
||||||
@@ -339,21 +350,24 @@ The `defineFactory` function lives in `packages/core-testing/src/factory/define-
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Output Validation Tests (R25)
|
## 9. Output Validation Tests
|
||||||
|
|
||||||
Every **non-void** use case must have an R25 test that injects a malformed mock response and asserts the use case throws `ZodError`. This verifies that the `xOutputSchema.parse(result)` at the end of each use case actually guards against misbehaving repositories.
|
Every **non-void** use case must have an output-validation test that injects a malformed mock response and asserts the use case throws `ZodError`. This verifies that the `xOutputSchema.parse(result)` at the end of each use case actually guards against misbehaving repositories.
|
||||||
|
|
||||||
**Void use cases are exempt:** `signOutUseCase`, `deleteMediaUseCase`, and any future use case returning `Promise<void>` skip R25 — they have no output schema.
|
**Void use cases are exempt:** `signOutUseCase`, `deleteMediaUseCase`, and any future use case returning `Promise<void>` — they have no output schema.
|
||||||
|
|
||||||
**Pattern A — reach into `_articles` (or equivalent backing array) when the typed API prevents you from inserting bad data:**
|
**Pattern A — reach into `_articles` (or equivalent backing array) when the typed API prevents you from inserting bad data:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
// packages/blog/src/application/use-cases/get-articles.use-case.test.ts
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
import { getArticlesUseCase, getArticlesOutputSchema } from "@/application/use-cases/get-articles.use-case";
|
import {
|
||||||
|
getArticlesUseCase,
|
||||||
|
getArticlesOutputSchema,
|
||||||
|
} from "@/application/use-cases/get-articles.use-case";
|
||||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
|
|
||||||
describe("getArticlesUseCase output validation (R25)", () => {
|
describe("getArticlesUseCase output validation", () => {
|
||||||
it("throws when the repository returns a malformed article", async () => {
|
it("throws when the repository returns a malformed article", async () => {
|
||||||
const repo = new MockArticlesRepository();
|
const repo = new MockArticlesRepository();
|
||||||
// bypass typed createArticle by reaching into the backing array directly
|
// bypass typed createArticle by reaching into the backing array directly
|
||||||
@@ -376,7 +390,7 @@ describe("getArticlesUseCase output validation (R25)", () => {
|
|||||||
// packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
// packages/auth/src/application/use-cases/sign-in.use-case.test.ts
|
||||||
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
import type { IAuthenticationService } from "@/application/services/authentication.service.interface";
|
||||||
|
|
||||||
describe("signInUseCase output validation (R25)", () => {
|
describe("signInUseCase output validation", () => {
|
||||||
it("throws when authenticationService returns a malformed session", async () => {
|
it("throws when authenticationService returns a malformed session", async () => {
|
||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
await users.createUser(userFactory.build({ username: "alice" }));
|
await users.createUser(userFactory.build({ username: "alice" }));
|
||||||
@@ -387,16 +401,18 @@ describe("signInUseCase output validation (R25)", () => {
|
|||||||
} as unknown as IAuthenticationService;
|
} as unknown as IAuthenticationService;
|
||||||
|
|
||||||
const useCase = signInUseCase(users, auth);
|
const useCase = signInUseCase(users, auth);
|
||||||
await expect(useCase({ username: "alice", password: "x" })).rejects.toBeInstanceOf(ZodError);
|
await expect(
|
||||||
|
useCase({ username: "alice", password: "x" }),
|
||||||
|
).rejects.toBeInstanceOf(ZodError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Group R25 tests in a separate `describe` block labelled `"<useCase> output validation (R25)"` so they are easy to grep.
|
Group output-validation tests in a separate `describe` block labelled `"<useCase> output validation"` so they are easy to grep.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Router Error-Mapping Tests (R26)
|
## 10. Router Error-Mapping Tests
|
||||||
|
|
||||||
Each feature's `router.test.ts` must assert that thrown domain errors become `TRPCError` with the correct code. The router test uses `xRouter.createCaller({})` and calls real procedures backed by the default mock bindings.
|
Each feature's `router.test.ts` must assert that thrown domain errors become `TRPCError` with the correct code. The router test uses `xRouter.createCaller({})` and calls real procedures backed by the default mock bindings.
|
||||||
|
|
||||||
@@ -410,7 +426,7 @@ import { blogContainer } from "@/di/container";
|
|||||||
import { BlogModule } from "@/di/module";
|
import { BlogModule } from "@/di/module";
|
||||||
import { blogRouter } from "@/integrations/api/router";
|
import { blogRouter } from "@/integrations/api/router";
|
||||||
|
|
||||||
describe("blogRouter (R26 error mapping)", () => {
|
describe("blogRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
blogContainer.unbindAll();
|
blogContainer.unbindAll();
|
||||||
blogContainer.load(BlogModule);
|
blogContainer.load(BlogModule);
|
||||||
@@ -450,10 +466,14 @@ For features where a domain error can only be triggered by an empty store (e.g.
|
|||||||
it("translates HeaderNotFoundError → NOT_FOUND", async () => {
|
it("translates HeaderNotFoundError → NOT_FOUND", async () => {
|
||||||
@injectable()
|
@injectable()
|
||||||
class NullHeaderRepository implements IHeaderRepository {
|
class NullHeaderRepository implements IHeaderRepository {
|
||||||
async getHeader() { return undefined; }
|
async getHeader() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
navigationContainer.unbind(NAVIGATION_SYMBOLS.IHeaderRepository);
|
||||||
navigationContainer.bind(NAVIGATION_SYMBOLS.IHeaderRepository).to(NullHeaderRepository);
|
navigationContainer
|
||||||
|
.bind(NAVIGATION_SYMBOLS.IHeaderRepository)
|
||||||
|
.to(NullHeaderRepository);
|
||||||
|
|
||||||
const caller = navigationRouter.createCaller({});
|
const caller = navigationRouter.createCaller({});
|
||||||
try {
|
try {
|
||||||
@@ -470,7 +490,7 @@ Every feature needs at least one `NOT_FOUND` (or domain-error) test and one `BAD
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Presenter Shape Tests (R27/R28)
|
## 11. Presenter Shape Tests
|
||||||
|
|
||||||
When a controller's presenter **reshapes** the use-case output (rather than returning it unchanged), the controller test must assert the **view shape** — not the full use-case output.
|
When a controller's presenter **reshapes** the use-case output (rather than returning it unchanged), the controller test must assert the **view shape** — not the full use-case output.
|
||||||
|
|
||||||
@@ -485,13 +505,19 @@ describe("signInController", () => {
|
|||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const auth = new MockAuthenticationService(users);
|
const auth = new MockAuthenticationService(users);
|
||||||
await users.createUser(
|
await users.createUser(
|
||||||
userFactory.build({ username: "alice", passwordHash: "hashed_testpassword" }),
|
userFactory.build({
|
||||||
|
username: "alice",
|
||||||
|
passwordHash: "hashed_testpassword",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const useCase = signInUseCase(users, auth);
|
const useCase = signInUseCase(users, auth);
|
||||||
const controller = signInController(useCase);
|
const controller = signInController(useCase);
|
||||||
|
|
||||||
const result = await controller({ username: "alice", password: "testpassword" });
|
const result = await controller({
|
||||||
|
username: "alice",
|
||||||
|
password: "testpassword",
|
||||||
|
});
|
||||||
// assert the VIEW shape (cookie), not the use-case output ({ session, cookie })
|
// assert the VIEW shape (cookie), not the use-case output ({ session, cookie })
|
||||||
expect(result.name).toBe("session");
|
expect(result.name).toBe("session");
|
||||||
expect(result.value).toBeTruthy();
|
expect(result.value).toBeTruthy();
|
||||||
@@ -501,7 +527,7 @@ describe("signInController", () => {
|
|||||||
|
|
||||||
**Identity presenters skip this.** Blog, marketing-pages, navigation, and media controllers all use identity presenters (`return value`). Their controller tests assert on the same fields the use case would return — that is fine, because the presenter does not transform.
|
**Identity presenters skip this.** Blog, marketing-pages, navigation, and media controllers all use identity presenters (`return value`). Their controller tests assert on the same fields the use case would return — that is fine, because the presenter does not transform.
|
||||||
|
|
||||||
**Rule of thumb:** if `presenter(value)` does anything other than `return value`, write a test that cannot pass by accident — assert a field that only exists on the *view*, not on `XOutput`.
|
**Rule of thumb:** if `presenter(value)` does anything other than `return value`, write a test that cannot pass by accident — assert a field that only exists on the _view_, not on `XOutput`.
|
||||||
|
|
||||||
Void controllers (`signOutController`, `deleteMediaController`) return `Promise<void>` and have no presenter — no view-shape test applies.
|
Void controllers (`signOutController`, `deleteMediaController`) return `Promise<void>` and have no presenter — no view-shape test applies.
|
||||||
|
|
||||||
@@ -580,6 +606,7 @@ describe("CommentsRepository", () => {
|
|||||||
The `buildSubject` pattern ensures each `run()` call supplies a fresh instance — every contract `it()` starts with a clean repository.
|
The `buildSubject` pattern ensures each `run()` call supplies a fresh instance — every contract `it()` starts with a clean repository.
|
||||||
|
|
||||||
**File naming convention (post-Plan-8):**
|
**File naming convention (post-Plan-8):**
|
||||||
|
|
||||||
- Mock implementation: `<x>.repository.mock.ts` (not `mock-<x>.repository.ts`)
|
- Mock implementation: `<x>.repository.mock.ts` (not `mock-<x>.repository.ts`)
|
||||||
- Mock test: `<x>.repository.mock.test.ts`
|
- Mock test: `<x>.repository.mock.test.ts`
|
||||||
- Real implementation: `<x>.repository.ts` (no `payload-` prefix)
|
- Real implementation: `<x>.repository.ts` (no `payload-` prefix)
|
||||||
@@ -655,12 +682,15 @@ E2E tests live in `apps/web-next/e2e/`. The `webServer` block in `apps/web-next/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Asserting spans and captures (Plan 10)
|
## Asserting spans and captures
|
||||||
|
|
||||||
Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Repositories also call `logger.captureException` inline; use cases and controllers get capture composed in via `withCapture` at DI bind time. Tests that need to assert either inject `RecordingTracer` + `RecordingLogger`:
|
Use cases, controllers, and repositories emit OpenTelemetry-style spans through the `ITracer` interface. Repositories also call `logger.captureException` inline; use cases and controllers get capture composed in via `withCapture` at DI bind time. Tests that need to assert either inject `RecordingTracer` + `RecordingLogger`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { RecordingTracer, RecordingLogger } from "@repo/core-testing/instrumentation";
|
import {
|
||||||
|
RecordingTracer,
|
||||||
|
RecordingLogger,
|
||||||
|
} from "@repo/core-testing/instrumentation";
|
||||||
import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
|
import { withSpan, withCapture } from "@repo/core-shared/instrumentation";
|
||||||
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
import { MockArticlesRepository } from "@/infrastructure/repositories/articles.repository.mock";
|
||||||
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
import { getArticlesUseCase } from "@/application/use-cases/get-articles.use-case";
|
||||||
|
|||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
A layered approach: direct factory injection + colocated unit tests + Playwright e2e.
|
A layered approach: direct factory injection + colocated unit tests + Playwright e2e.
|
||||||
|
|
||||||
For the *how* of TDD (red-green-refactor cycle, when to mock, what NOT to test), see [tdd-workflow.md](./tdd-workflow.md). This document covers test *placement* and infrastructure.
|
For the _how_ of TDD (red-green-refactor cycle, when to mock, what NOT to test), see [tdd-workflow.md](./tdd-workflow.md). This document covers test _placement_ and infrastructure.
|
||||||
|
|
||||||
For the full R25 / R26 / R27 / R28 patterns with worked examples, see [tdd-workflow.md §4 (mock decision tree)](./tdd-workflow.md).
|
For the full output-validation, error-mapping, and view-shape patterns with worked examples, see [tdd-workflow.md §4 (mock decision tree)](./tdd-workflow.md).
|
||||||
|
|
||||||
Related ADRs: [ADR-012](../decisions/adr-012-lazar-conformance.md) — Clean Architecture conformance; [ADR-013](../decisions/adr-013-input-output-unification.md) — input/output unification + presenter + error middleware.
|
Related ADRs: [ADR-012](../decisions/adr-012-feature-conventions.md) — Clean Architecture conformance; [ADR-013](../decisions/adr-013-input-output-unification.md) — input/output unification + presenter + error middleware.
|
||||||
|
|
||||||
## Test placement
|
## Test placement
|
||||||
|
|
||||||
| Level | Location | Tool | Example |
|
| Level | Location | Tool | Example |
|
||||||
|---|---|---|---|
|
| ------------------------ | --------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------- |
|
||||||
| **Unit (colocated)** | `packages/<feature>/src/entities/models/<entity>.test.ts` | Vitest | Schema validation, type guards |
|
| **Unit (colocated)** | `packages/<feature>/src/entities/models/<entity>.test.ts` | Vitest | Schema validation, type guards |
|
||||||
| **Use case** | `packages/<feature>/src/application/use-cases/<name>.use-case.test.ts` | Vitest | Direct factory injection + R25 output-validation |
|
| **Use case** | `packages/<feature>/src/application/use-cases/<name>.use-case.test.ts` | Vitest | Direct factory injection + output-validation |
|
||||||
| **Controller** | `packages/<feature>/src/interface-adapters/controllers/<name>.controller.test.ts` | Vitest | Direct factory injection + R10 input validation + R27/R28 view shape |
|
| **Controller** | `packages/<feature>/src/interface-adapters/controllers/<name>.controller.test.ts` | Vitest | Direct factory injection + input validation + view shape |
|
||||||
| **Repository contract** | `packages/<feature>/src/infrastructure/repositories/<impl>.repository.test.ts` | Vitest + contract suite | Interface conformance |
|
| **Repository contract** | `packages/<feature>/src/infrastructure/repositories/<impl>.repository.test.ts` | Vitest + contract suite | Interface conformance |
|
||||||
| **Router (integration)** | `packages/<feature>/src/integrations/api/router.test.ts` | Vitest + container | R26 domain → TRPCError mapping |
|
| **Router (integration)** | `packages/<feature>/src/integrations/api/router.test.ts` | Vitest + container | Domain → TRPCError mapping |
|
||||||
| **Feature level** | `packages/<feature>/tests/<name>.feature.test.ts` | Vitest | Cross-layer integration via direct injection |
|
| **Feature level** | `packages/<feature>/tests/<name>.feature.test.ts` | Vitest | Cross-layer integration via direct injection |
|
||||||
| **E2E (app)** | `apps/web-next/e2e/<name>.spec.ts` | Playwright | Full user flow across frontend + backend |
|
| **E2E (app)** | `apps/web-next/e2e/<name>.spec.ts` | Playwright | Full user flow across frontend + backend |
|
||||||
|
|
||||||
**Colocated vs feature-level:** Colocated tests (`*.test.ts` next to source) test isolated units. Feature-level tests (`tests/` folder) wire the full chain via direct injection and test interactions between layers.
|
**Colocated vs feature-level:** Colocated tests (`*.test.ts` next to source) test isolated units. Feature-level tests (`tests/` folder) wire the full chain via direct injection and test interactions between layers.
|
||||||
|
|
||||||
@@ -39,10 +39,14 @@ describe("getArticlesUseCase", () => {
|
|||||||
it("filters by status", async () => {
|
it("filters by status", async () => {
|
||||||
const repo = new MockArticlesRepository();
|
const repo = new MockArticlesRepository();
|
||||||
articleFactory.reset();
|
articleFactory.reset();
|
||||||
await repo.createArticle(articleFactory.build({ id: "1", status: "draft" }));
|
await repo.createArticle(
|
||||||
await repo.createArticle(articleFactory.build({ id: "2", status: "published" }));
|
articleFactory.build({ id: "1", status: "draft" }),
|
||||||
|
);
|
||||||
|
await repo.createArticle(
|
||||||
|
articleFactory.build({ id: "2", status: "published" }),
|
||||||
|
);
|
||||||
|
|
||||||
const useCase = getArticlesUseCase(repo); // direct injection
|
const useCase = getArticlesUseCase(repo); // direct injection
|
||||||
const result = await useCase({ status: "published" });
|
const result = await useCase({ status: "published" });
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -63,10 +67,13 @@ describe("signInController", () => {
|
|||||||
const users = new MockUsersRepository([]);
|
const users = new MockUsersRepository([]);
|
||||||
const auth = new MockAuthenticationService(users);
|
const auth = new MockAuthenticationService(users);
|
||||||
const useCase = signInUseCase(users, auth);
|
const useCase = signInUseCase(users, auth);
|
||||||
const controller = signInController(useCase); // direct injection
|
const controller = signInController(useCase); // direct injection
|
||||||
|
|
||||||
const result = await controller({ username: "alice", password: "testpassword" });
|
const result = await controller({
|
||||||
expect(result.name).toBe("session"); // presenter view shape (R27)
|
username: "alice",
|
||||||
|
password: "testpassword",
|
||||||
|
});
|
||||||
|
expect(result.name).toBe("session"); // presenter view shape
|
||||||
expect(result.value).toBeTruthy();
|
expect(result.value).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -86,10 +93,10 @@ import { blogContainer } from "@/di/container";
|
|||||||
import { BlogModule } from "@/di/module";
|
import { BlogModule } from "@/di/module";
|
||||||
import { blogRouter } from "@/integrations/api/router";
|
import { blogRouter } from "@/integrations/api/router";
|
||||||
|
|
||||||
describe("blogRouter (R26 error mapping)", () => {
|
describe("blogRouter error mapping", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
blogContainer.unbindAll();
|
blogContainer.unbindAll();
|
||||||
blogContainer.load(BlogModule); // loads default mock bindings
|
blogContainer.load(BlogModule); // loads default mock bindings
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -98,10 +105,9 @@ describe("blogRouter (R26 error mapping)", () => {
|
|||||||
|
|
||||||
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
|
it("translates ArticleNotFoundError → NOT_FOUND", async () => {
|
||||||
const caller = blogRouter.createCaller({});
|
const caller = blogRouter.createCaller({});
|
||||||
await expect(caller.articleBySlug({ slug: "missing" }))
|
await expect(caller.articleBySlug({ slug: "missing" })).rejects.toSatisfy(
|
||||||
.rejects.toSatisfy((e: unknown) =>
|
(e: unknown) => e instanceof TRPCError && e.code === "NOT_FOUND",
|
||||||
e instanceof TRPCError && e.code === "NOT_FOUND"
|
);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -111,7 +117,9 @@ When a test needs a specific repo behaviour at the router level, bind it directl
|
|||||||
```typescript
|
```typescript
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
blogContainer.unbindAll();
|
blogContainer.unbindAll();
|
||||||
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(new AlwaysEmptyRepo());
|
blogContainer
|
||||||
|
.bind(BLOG_SYMBOLS.IArticlesRepository)
|
||||||
|
.toConstantValue(new AlwaysEmptyRepo());
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -137,12 +145,13 @@ The mock repository satisfies the repository interface without touching Payload
|
|||||||
|
|
||||||
### Option 2: Router-level — use the feature's DI container
|
### Option 2: Router-level — use the feature's DI container
|
||||||
|
|
||||||
When testing the tRPC router (R26 error-mapping, procedure wiring), bind a mock or stub repository through the feature container in `beforeEach`:
|
When testing the tRPC router (error-mapping, procedure wiring), bind a mock or stub repository through the feature container in `beforeEach`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
blogContainer.unbindAll();
|
blogContainer.unbindAll();
|
||||||
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository)
|
blogContainer
|
||||||
|
.bind(BLOG_SYMBOLS.IArticlesRepository)
|
||||||
.toConstantValue(new MockArticlesRepository());
|
.toConstantValue(new MockArticlesRepository());
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -156,25 +165,25 @@ vi.mock("payload", () => ({ getPayload: vi.fn() }));
|
|||||||
|
|
||||||
Then provide a stub via `stubPayloadConfig` from `@repo/core-testing/payload` (see [tdd-workflow.md §4](./tdd-workflow.md) for the full contract-suite pattern).
|
Then provide a stub via `stubPayloadConfig` from `@repo/core-testing/payload` (see [tdd-workflow.md §4](./tdd-workflow.md) for the full contract-suite pattern).
|
||||||
|
|
||||||
## Test obligations per layer (Plan 9)
|
## Test obligations per layer
|
||||||
|
|
||||||
| Layer | Test type | Required by spec | Example |
|
| Layer | Test type | Required by spec | Example |
|
||||||
|---|---|---|---|
|
| ------------------------ | ----------------------- | ---------------- | ------------------------------------------------------------------------ |
|
||||||
| Entity | Schema validation | — | `articleSchema.safeParse(...)` → accepts valid / rejects invalid |
|
| Entity | Schema validation | — | `articleSchema.safeParse(...)` → accepts valid / rejects invalid |
|
||||||
| Use case (input) | Behavior | — | factory injection; assert result shape and filtering |
|
| Use case (input) | Behavior | — | factory injection; assert result shape and filtering |
|
||||||
| Use case (output, R25) | Runtime guarantee | R25 | inject malformed mock output → `.rejects.toBeInstanceOf(ZodError)` |
|
| Use case (output) | Runtime guarantee | — | inject malformed mock output → `.rejects.toBeInstanceOf(ZodError)` |
|
||||||
| Controller (input, R10) | Validation | R10 | `controller({} as unknown)` → `.rejects.toBeInstanceOf(InputParseError)` |
|
| Controller (input) | Validation | — | `controller({} as unknown)` → `.rejects.toBeInstanceOf(InputParseError)` |
|
||||||
| Controller (presenter, R27/R28) | View shape | R27/R28 (when reshaping) | `expect(result.name).toBe("session")` (not `result.session`) |
|
| Controller (presenter) | View shape | when reshaping | `expect(result.name).toBe("session")` (not `result.session`) |
|
||||||
| Repository contract | Interface conformance | — | run `defineContractSuite` against mock + real impl |
|
| Repository contract | Interface conformance | — | run `defineContractSuite` against mock + real impl |
|
||||||
| Router (R26) | Domain → TRPCError | R26 | `xRouter.createCaller({}).x(...)` → assert `TRPCError.code` |
|
| Router | Domain → TRPCError | — | `xRouter.createCaller({}).x(...)` → assert `TRPCError.code` |
|
||||||
| Feature-level (`tests/`) | Cross-layer integration | — | wire the chain via direct injection (no container) |
|
| Feature-level (`tests/`) | Cross-layer integration | — | wire the chain via direct injection (no container) |
|
||||||
| E2E | Full user flow | — | Playwright |
|
| E2E | Full user flow | — | Playwright |
|
||||||
|
|
||||||
**R25** — Every non-void use case ends with `xOutputSchema.parse(result)`. The R25 test proves this: inject a mock that returns a structurally invalid object and assert `ZodError` propagates.
|
**Output validation** — Every non-void use case ends with `xOutputSchema.parse(result)`. The test proves this: inject a mock that returns a structurally invalid object and assert `ZodError` propagates.
|
||||||
|
|
||||||
**R26** — Every feature has `procedures.ts` with a `defineErrorMiddleware` error map. The R26 test calls the tRPC procedure through `router.createCaller({})` and asserts the correct `TRPCError.code` (e.g. `NOT_FOUND`, `BAD_REQUEST`, `UNAUTHORIZED`).
|
**Error mapping** — Every feature has `procedures.ts` with a `defineErrorMiddleware` error map. The router test calls the tRPC procedure through `router.createCaller({})` and asserts the correct `TRPCError.code` (e.g. `NOT_FOUND`, `BAD_REQUEST`, `UNAUTHORIZED`).
|
||||||
|
|
||||||
**R27/R28** — Controllers that reshape the use-case output (e.g. auth controllers that return a cookie instead of the full session object) must have a test asserting the view shape — not the raw use-case output.
|
**View shape** — Controllers that reshape the use-case output (e.g. auth controllers that return a cookie instead of the full session object) must have a test asserting the view shape — not the raw use-case output.
|
||||||
|
|
||||||
## Vitest setup per package
|
## Vitest setup per package
|
||||||
|
|
||||||
@@ -308,18 +317,17 @@ Root `turbo.json`:
|
|||||||
3. **E2E tests** prove the app works end-to-end (minimal smoke specs initially)
|
3. **E2E tests** prove the app works end-to-end (minimal smoke specs initially)
|
||||||
4. **Per-feature containers** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container)
|
4. **Per-feature containers** are used at the **router level**; use-case + controller tests inject mocks directly into the factory (no container)
|
||||||
|
|
||||||
## R49 / R50 — Instrumentation testing (Plan 10)
|
## Instrumentation testing
|
||||||
|
|
||||||
**R49 — No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert specific Sentry SDK calls add their own `vi.mock(...)` per file.
|
**No real Sentry in tests.** The `core-testing/setup/no-sentry.ts` guard mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs. Tests that want to assert specific Sentry SDK calls add their own `vi.mock(...)` per file.
|
||||||
|
|
||||||
**R50 — Repository contracts assert span shape.** Every `__contracts__/<x>-repository.contract.ts` includes a `span emission (R50)` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site:
|
**Repository contracts assert span shape.** Every `__contracts__/<x>-repository.contract.ts` includes a `span emission` describe block enumerating one assertion per public method. Suites run against both mock and real (Payload-backed) implementations, ensuring span emission stays in sync. Wire the recording tracer at the call site:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const tracer = new RecordingTracer();
|
const tracer = new RecordingTracer();
|
||||||
articlesRepositoryContract.run(
|
articlesRepositoryContract.run(() => new MockArticlesRepository(tracer), {
|
||||||
() => new MockArticlesRepository(tracer),
|
tracer: () => tracer,
|
||||||
{ tracer: () => tracer },
|
});
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Capture vs span assertions:**
|
**Capture vs span assertions:**
|
||||||
@@ -330,5 +338,4 @@ articlesRepositoryContract.run(
|
|||||||
- `RecordingLogger.users` — every `setUser` call (history).
|
- `RecordingLogger.users` — every `setUser` call (history).
|
||||||
- `RecordingAuditLog.entries` — every `record(entry)` call. Use to assert audit emissions in feature-package tests **without** importing `@repo/core-audit` (the recording double lives in `@repo/core-testing/instrumentation` and mirrors the `AuditEntry` shape inline to avoid the tooling→core boundary). Also exposes `RecordingAuditLog.erasures` for `eraseSubject` history. See ADR-018 and `docs/guides/audit-and-compliance.md` for what to assert.
|
- `RecordingAuditLog.entries` — every `record(entry)` call. Use to assert audit emissions in feature-package tests **without** importing `@repo/core-audit` (the recording double lives in `@repo/core-testing/instrumentation` and mirrors the `AuditEntry` shape inline to avoid the tooling→core boundary). Also exposes `RecordingAuditLog.erasures` for `eraseSubject` history. See ADR-018 and `docs/guides/audit-and-compliance.md` for what to assert.
|
||||||
|
|
||||||
**Test cleanup:** call `tracer.reset()`, `logger.reset()`, and `auditLog.reset()` in `beforeEach` if the test creates one shared instance across multiple cases.
|
**Test cleanup:** call `tracer.reset()`, `logger.reset()`, and `auditLog.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. 5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests
|
||||||
5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests
|
|
||||||
|
|||||||
Reference in New Issue
Block a user