# Vertical Feature Architecture Spec

> **Architecture reference.** This document is the canonical design spec for the vertical-feature architecture.

---

# Vertical Feature Monorepo Refactor — Design Spec

**Date:** 2026-04-21
**Status:** Approved for implementation planning
**Supersedes (partially):** 2026-04-06-clean-architecture-monorepo-template-design.md
**Source spec:** `monorepo-architecture-spec-detailed-v5.md` (v1 + addenda v3/v4/v5)

---

## 1. Goal

Refactor the template from a horizontal Clean Architecture monorepo (single `packages/core`, single `packages/api`, etc.) into a vertical feature-package monorepo where business capabilities (`auth`, `blog`, `media`, `marketing-pages`, `navigation`) are the top-level organizing principle, supported by `core-*` foundation packages for non-business concerns.

The refactor preserves Clean Architecture layering _inside_ each feature (the existing rigor) while reorganizing _between_ packages by business capability.

---

## 2. Current state (summary)

- `packages/core` — all domains (auth, content) share one Clean Architecture layout with InversifyJS DI container
- `packages/api` — single tRPC aggregator + per-domain routers
- `packages/api-client` — React Query hooks + `ApiProvider` + `useTRPC`
- `packages/cms-core` — Payload config + all collections (Users, Articles, Media, SiteSettings global)
- `packages/cms-client` — dual-mode Payload client wrapper; defined but unused
- `packages/ui` — atomic-design component library
- `apps/web-next` (empty shell), `apps/web-tanstack` (empty shell), `apps/cms` (just stabilized), `apps/storybook`
- Mock repositories only — no Payload-backed infrastructure
- 9 Vitest unit tests under `packages/core/tests/unit/`
- Extensive per-directory AGENTS.md; 5 ADRs; 2 guides; 6 dated superpower plans

---

## 3. Target state (summary)

- Three must-have `core-*` packages: `core-shared`, `core-cms`, `core-api`. Five optional cross-cutting cores scaffold on demand via `pnpm turbo gen core-package <name>`: `core-trpc`, `core-ui`, `core-realtime` (ADR-016), `core-events` (ADR-015), `core-audit` (ADR-018). See `docs/architecture/template-tiers.md`.
- Five feature packages: `auth`, `blog`, `media`, `marketing-pages`, `navigation`
- Two tooling packages renamed: `eslint-config` → `core-eslint`, `typescript-config` → `core-typescript`
- Three apps unchanged in name: `apps/web-next`, `apps/web-tanstack`, `apps/cms`
- Packages deleted: `packages/core`, `packages/api`, `packages/api-client`, `packages/cms-core`, `packages/cms-client`, `packages/ui`
- Per-feature InversifyJS containers (no shared container)
- Clean Architecture controllers retained inside each feature (`interface-adapters/controllers/`)
- Spec's `adapters/` renamed to `integrations/` to avoid collision with `interface-adapters/`
- Boundary enforcement via `eslint-plugin-boundaries` + `package.json` deps + `exports` maps + Turborepo tags
- Playwright e2e set up from day one in `web-next` and `web-tanstack`

---

## 4. Decision log

All decisions captured from the brainstorming conversation:

| #   | Decision                                                                                                            | Rationale                                                                                                                                                                   |
| --- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | **Big-bang migration** (not incremental)                                                                            | Template has empty reference apps and no external consumers; incremental dual-maintenance is overhead without benefit                                                       |
| 2   | **Feature scope:** `auth` + `blog` + `media` + `marketing-pages` + `navigation` (all real, none as empty skeletons) | Template value is in worked examples; reference app needs something to render; spec §15A.2 forbids empty folders                                                            |
| 3   | **Keep InversifyJS**                                                                                                | User wants to preserve existing DI pattern rather than move to plain function injection                                                                                     |
| 4   | **Per-feature DI containers** (not a shared container)                                                              | Each feature owns its own `Container`, symbols, `getInjection()`. Perfect vertical ownership; no composition package needed for DI; tests unbind/rebind their own container |
| 5   | **`media` is a feature package**, not `core-media`                                                                  | Application can live without media; it's a business capability per spec §3. Site-wide concerns (SiteSettings) fold into `marketing-pages`, not a separate `site` package    |
| 6   | **Keep all three apps** (`web-next`, `web-tanstack`, `cms`) with existing names                                     | `web-tanstack` proves features are framework-agnostic; no rename avoids churn                                                                                               |
| 7   | **Keep `interface-adapters/controllers/` layer**                                                                    | Transport-agnostic controllers enable CLI/cron reuse; template should demonstrate growth room for presenters/gateways                                                       |
| 8   | **Rename spec's `adapters/` → `integrations/`**                                                                     | Avoids collision with Clean Architecture's `interface-adapters/`; captures spec's "role not implementation" intent equally well; bounded deviation from spec                |
| 9   | **Delete existing tests, rewrite fresh**; rewrite AGENTS.md, add ADRs, mark old ADRs superseded where relevant      | DI pattern change + file layout change make porting more work than rewriting; ADRs preserve architectural history                                                           |
| 10  | **Include `eslint-plugin-boundaries`** from day one                                                                 | Spec §13A.7 explicitly requires three-layer enforcement; package.json deps + exports + lint rules                                                                           |
| 11  | **Playwright set up immediately** in `web-next` and `web-tanstack`                                                  | Not deferred; demonstrates framework portability end-to-end                                                                                                                 |
| 12  | **Keep `articles` collection/entity naming** (not rename to `posts`)                                                | Simpler migration; collapses CMS-doc vs domain distinction which is fine for a template                                                                                     |

Deviations from source spec (document explicitly as new ADRs):

- Keep InversifyJS (spec examples use plain function injection)
- Keep controller layer between tRPC and use-case (spec goes tRPC→use-case direct)
- Rename spec's `adapters/` to `integrations/`
- Omit `core-payload-client` wrapper (aligned with spec §10)

---

## 5. Target package layout

```
repo/
  apps/
    web-next/                              # Next.js 15 App Router (port 3000)
      app/
        layout.tsx                         # <TrpcProvider> from @repo/core-trpc/next
        trpc/[trpc]/route.ts               # tRPC fetch adapter → @repo/core-api appRouter
        blog/[slug]/page.tsx
        about/page.tsx
        page.tsx                           # home — navigation + marketing-pages
      e2e/
        playwright.config.ts
        blog-post.spec.ts
        marketing-page.spec.ts
        home-nav.spec.ts
      package.json next.config.mjs tsconfig.json turbo.json

    web-tanstack/                          # TanStack Start (port 3002)
      ...                                  # parallel tRPC wiring; own providers from @repo/core-trpc/tanstack
      e2e/
        playwright.config.ts
        blog-post.spec.ts

    cms/                                   # Payload admin host (port 3001)
      app/(payload)/                       # unchanged from current stabilized state
      package.json next.config.mjs tsconfig.json turbo.json

    storybook/                             # unchanged; updates imports from @repo/ui → @repo/core-ui

  packages/
    # ─── CORE (foundation, tagged "core") ───
    core-shared/                           # generic primitives (no business knowledge)
    core-cms/                              # Payload composition only (aggregates feature cms exports)
    core-api/                              # tRPC composition only (aggregates feature api exports)
    core-trpc/                             # frontend tRPC platform (client, providers per framework)
    core-ui/                               # design-system primitives (atoms/molecules/templates)

    # ─── FEATURES (business capabilities, tagged "feature") ───
    auth/                                  # Users collection + sign-in/up/out
    blog/                                  # Articles collection + article use-cases
    media/                                 # Media collection + upload helpers
    marketing-pages/                       # pages collection + SiteSettings global
    navigation/                            # header global

    # ─── TOOLING (tagged "tooling") ───
    core-eslint/
    core-typescript/

  docs/
    architecture/
      overview.md                          # rewritten
      dependency-flow.md                   # rewritten
      vertical-feature-spec.md             # copy of source spec
    decisions/
      adr-001 … adr-009                    # five existing + four new (see §10)
    guides/
      adding-a-feature.md                  # rewritten
      testing-strategy.md                  # rewritten
    work/                                  # epic + story tracking

  CLAUDE.md AGENTS.md docker-compose.yml package.json pnpm-lock.yaml
  pnpm-workspace.yaml tsconfig.base.json turbo.json
```

**Package count:** 3 apps + 5 core + 5 feature + 2 tooling = **15 packages** (was 12).

---

## 6. Feature package internal shape

Canonical mature shape (e.g., `packages/blog/`):

```
packages/blog/
  src/
    config.ts                                       # constants if needed

    entities/
      models/
        article.ts                                  # Zod schema + Article type
        article.test.ts
      errors/
        article.ts                                  # ArticleNotFoundError (sets this.name)
        common.ts                                   # InputParseError
        errors.test.ts

    application/
      repositories/
        articles.repository.interface.ts            # IArticlesRepository
      use-cases/
        get-articles.use-case.ts                    # factory + getArticlesInputSchema + getArticlesOutputSchema + parse
        get-articles.use-case.test.ts               # incl. R25 output-validation test
        get-article-by-slug.use-case.ts
        get-article-by-slug.use-case.test.ts
        create-article.use-case.ts
        create-article.use-case.test.ts

    infrastructure/
      repositories/
        articles.repository.ts                      # real Payload-backed impl (getPayload({ config }) from core-cms)
        articles.repository.mock.ts                 # MockArticlesRepository
        articles.repository.test.ts
        articles.repository.mock.test.ts

    interface-adapters/                             # Clean Arch grouping (controllers now; presenters/gateways later)
      controllers/
        get-articles.controller.ts                  # factory + safeParse(getArticlesInputSchema) + function presenter
        get-articles.controller.test.ts             # incl. R27/R28 if presenter reshapes
        get-article-by-slug.controller.ts           # one file per use case
        get-article-by-slug.controller.test.ts
        create-article.controller.ts
        create-article.controller.test.ts

    di/                                             # feature-local InversifyJS container
      symbols.ts                                    # BLOG_SYMBOLS
      module.ts                                     # ContainerModule — .toDynamicValue() for use cases + controllers
      container.ts                                  # blogContainer singleton
      bind-production.ts                            # swaps mock → real Payload impls at app boot
      bind-dev-seed.ts                              # swaps empty mock → populated mock for dev mode
      bind-dev-seed.test.ts
      container.test.ts

    integrations/                                   # renamed from spec's adapters/
      api/
        procedures.ts                               # blogProcedure = t.procedure.use(defineErrorMiddleware([...]))
        router.ts                                   # blogProcedure.input(xInputSchema).query/mutation(...)
        router.test.ts                              # incl. R26 router error-mapping test
        index.ts
      cms/
        collections/
          articles.ts                               # Payload CollectionConfig
        hooks/
          <lifecycle-hook>.ts                       # if needed
        index.ts                                    # exports: articles (for core-cms composition)

    ui/
      index.ts                                      # re-exports query builders (apps import from @repo/blog/ui)
      query.ts                                      # trpc.blog.articleBySlug.queryOptions(...)

    __factories__/
      article.factory.ts                            # test data factories

    __contracts__/
      articles-repository.contract.ts              # repo interface contract suite

    __seeds__/
      dev.ts                                        # buildDev<Entities>() — uses factory; consumed by bind-dev-seed

    index.ts                                        # contracts only: types, errors, schemas, IUseCase/IController aliases, router type, constants

  tests/
    article-by-slug.feature.test.ts                 # cross-layer integration test

  package.json                                      # exports: ".", "./ui", "./api", "./cms", "./di/bind-production", "./di/bind-dev-seed"
  tsconfig.json
  turbo.json                                        # tags: ["feature"]
```

Small-feature variant (e.g., `packages/navigation/`) omits folders without meaningful code per spec §15 / addendum v5 ("create folders only when needed"):

```
packages/navigation/
  src/
    entities/
      models/ header.ts
      errors/ header.ts common.ts
    application/repositories/ header.repository.interface.ts
    infrastructure/repositories/ header.repository.ts header.repository.mock.ts
    di/ symbols.ts module.ts container.ts bind-production.ts container.test.ts
    interface-adapters/controllers/ get-header.controller.ts get-header.controller.test.ts
    integrations/
      cms/ globals/ header.ts + index.ts
      api/ procedures.ts router.ts router.test.ts index.ts
    ui/ index.ts query.ts
    index.ts
```

Optional `events/`, `jobs/`, `integrations/cms/jobs/` directories (see ADR-015 and `docs/guides/events-and-jobs.md`); optional `realtime/` and `realtime/handlers/` directories (see ADR-016 and `docs/guides/realtime.md`); features may grow any of them on demand. The spec's canonical layout above remains correct as the minimum.

**Request flow:**

```
useQuery(articleBySlugQuery({ slug }))                      ui/index.ts (typed tRPC client, via @repo/blog/ui)
  ↓
tRPC router.articleBySlug                                    integrations/api/router.ts
  ↓ blogProcedure has defineErrorMiddleware applied
  ↓ .input(getArticleBySlugInputSchema)
articlesController.getBySlug(input: unknown)                interface-adapters/controllers/
  ↓ getArticleBySlugInputSchema.safeParse(input)
  ↓ throws InputParseError on failure
  ↓ delegates to use case
getArticleBySlugUseCase(parsed.data)                         application/use-cases/
  ↓ deps injected by container at xProcedure.use(...) time
  ↓ throws ArticleNotFoundError on miss
  ↓ ends with getArticleBySlugOutputSchema.parse(result)
ArticlesRepository.getArticleBySlug                          infrastructure/repositories/
  ↓ getPayload({ config }) from @repo/core-cms
Payload Local API → PostgreSQL
                                                              ↑ on throw:
                                                              domain error → defineErrorMiddleware
                                                              → TRPCError(code, cause)
                                                              ↓ on success:
                                                              controller's `function presenter(value)`
                                                              shapes the view
```

**DI placement rationale:** `di/` sits at feature root (not under `infrastructure/`) because the container wires `application/` interfaces to `infrastructure/` implementations — it has knowledge of both layers and is a sibling to them, not a sub-layer.

---

## 7. Core package responsibilities

### `core-shared/`

Generic reusable primitives. Zero business knowledge.

```
src/
  lib/
    env.ts date.ts
  payload/
    access/    is-admin.ts
    fields/    slug-field.ts seo-fields.ts
    blocks/    cta.ts
    hooks/     set-published-at.ts slugify-if-missing.ts
    index.ts
  trpc/
    init.ts    # initTRPC.create + router/publicProcedure
    context.ts # createTrpcContext + TrpcContext type
  index.ts
```

Exports: `.`, `./payload`, `./trpc/init`, `./trpc/context`.

Forbidden: importing any feature package.

### `core-cms/`

Payload composition only.

```
src/
  payload.config.ts    # imports feature /cms exports, composes buildConfig
  generated-types.ts   # Payload type generator output
  index.ts             # re-exports config as default
```

Exports: `.`, `./generated-types`.

Allowed exception: may import `@repo/<feature>/cms` subpath exports only.

### `core-api/`

tRPC composition only.

```
src/
  root.ts              # aggregates feature routers → appRouter
  index.ts             # re-exports appRouter + AppRouter type
```

Allowed exception: may import `@repo/<feature>/api` subpath exports only.

### `core-trpc/`

Frontend tRPC platform with framework-specific provider shims.

```
src/
  client.ts                     # createTRPCReact<AppRouter>()
  query-client.ts               # makeQueryClient()
  providers/
    next-provider.tsx           # 'use client' — Next.js App Router pattern
    tanstack-provider.tsx       # TanStack Start pattern
  index.ts
```

Exports: `.`, `./next`, `./tanstack`.

Forbidden: importing any feature package.

### `core-ui/`

Design-system primitives only.

```
src/
  atoms/       button/ input/ label/
  molecules/   form-field/
  organisms/   (generic only — e.g., modal, tabs, navigation-menu)
  templates/   auth-layout/ dashboard-layout/
  lib/         utils.ts
  index.ts
```

Generic organisms (Modal, Tabs, NavigationMenu, Command) live here. Feature-specific organisms (e.g., `ArticleCard`, `PricingSection`, `HeaderNavMenu`) live in the owning feature's `ui/`. Spec §6.5 boundary.

Forbidden: importing any feature package.

---

## 8. Payload collection & global ownership

| Current                               | → New location                                                           | Slug / type     | Notes                           |
| ------------------------------------- | ------------------------------------------------------------------------ | --------------- | ------------------------------- |
| `cms-core/src/collections/users/`     | `packages/auth/src/integrations/cms/collections/users.ts`                | `users`         | Authenticated collection        |
| `cms-core/src/collections/articles/`  | `packages/blog/src/integrations/cms/collections/articles.ts`             | `articles`      | Name preserved per decision #12 |
| `cms-core/src/collections/media/`     | `packages/media/src/integrations/cms/collections/media.ts`               | `media`         | Upload collection               |
| `cms-core/src/globals/site-settings/` | `packages/marketing-pages/src/integrations/cms/globals/site-settings.ts` | `site-settings` | Site-wide metadata              |
| (new)                                 | `packages/marketing-pages/src/integrations/cms/collections/pages.ts`     | `pages`         |                                 |
| (new)                                 | `packages/navigation/src/integrations/cms/globals/header.ts`             | `header`        |                                 |

Composition in `core-cms/src/payload.config.ts`:

```ts
import { buildConfig } from "payload";
import { users } from "@repo/auth/cms";
import { articles } from "@repo/blog/cms";
import { pages, siteSettings } from "@repo/marketing-pages/cms";
import { header } from "@repo/navigation/cms";
import { media } from "@repo/media/cms";

export default buildConfig({
  collections: [users, articles, pages, media],
  globals: [header, siteSettings],
  typescript: {
    outputFile: new URL("./generated-types.ts", import.meta.url).pathname,
    declare: false,
  },
  // db, admin config unchanged from current cms-core
});
```

**Hook routing policy:**

- Generic hooks (e.g., `slugify-if-missing`, `set-published-at`) live in `core-shared/src/payload/hooks/` and are imported by any collection that needs them.
- Business-specific hooks (e.g., "revalidate blog post page when published") live in the feature's `integrations/cms/hooks/`, which call the feature's `effects/` for reusable side effects.

---

## 9. Boundaries + enforcement

### 9.1 Five tags

Package-level `turbo.json` tags (refined from earlier ADR-006's three-tag model):

| Tag                | Packages                                                                                                        |
| ------------------ | --------------------------------------------------------------------------------------------------------------- |
| `app`              | `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook`                                              |
| `core-composition` | `packages/core-api`, `core-cms`, plus `core-trpc` when scaffolded (optional)                                    |
| `core`             | `packages/core-shared`, plus `core-ui`, `core-realtime`, `core-events`, `core-audit` when scaffolded (optional) |
| `feature`          | `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation`                                               |
| `tooling`          | `packages/core-eslint`, `core-typescript`                                                                       |

Note: `core-trpc` is `core-composition` (not plain `core`) because it transitively reaches features through `core-api`'s `AppRouter` type. The other optional cross-cutting cores stay in plain `core` — they expose protocol types (consumed via `@repo/core-shared/di/bind-protocols`) and never reach into feature packages.

### 9.2 Allowed dependency directions

| Tag              | May depend on                                 |
| ---------------- | --------------------------------------------- |
| app              | app, core, core-composition, feature, tooling |
| core-composition | core, core-composition, feature, tooling      |
| core             | core, core-composition, tooling               |
| feature          | core, tooling                                 |
| tooling          | tooling                                       |

### 9.3 Composition exceptions

- `core-cms` may import `@repo/<feature>/cms` subpath exports only.
- `core-api` may import `@repo/<feature>/api` subpath exports only.
- No other package may deviate from the five-tag rules.

### 9.4 Four enforcement layers

1. **`package.json` dependencies** — only allowed deps declared.
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production` only (no deep source paths).
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/` flat config:
   - Enforces the five-tag rules (same rules as Turbo boundaries)
   - File-specific exemptions via `// @boundaries-ignore` comments
4. **Turborepo `boundaries`** (build-graph time) — configured in root `turbo.json`:
   - Validates the entire workspace dependency graph, including transitive dependencies
   - Catches issues that lint-time checking misses (e.g., transitive feature reaches)
   - Run `pnpm turbo boundaries` to validate

### 9.5 Root `turbo.json` (unchanged concept)

```json
{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] },
    "lint": { "dependsOn": ["^lint"] },
    "typecheck": { "dependsOn": ["^typecheck"] },
    "test": { "dependsOn": ["^build"] },
    "test:e2e": { "dependsOn": ["^build"], "cache": false }
  }
}
```

Tags govern architectural boundaries; `dependsOn: ["^build"]` governs task execution order — separate concerns per spec §13A.6.

---

## 10. Test placement + tooling

### 10.1 Placement

| Scope                       | Location                    | Suffix              |
| --------------------------- | --------------------------- | ------------------- |
| Entity / value-object       | colocated                   | `*.test.ts`         |
| Use-case (with fake repo)   | colocated                   | `*.test.ts`         |
| Controller (Zod validation) | colocated                   | `*.test.ts`         |
| Infrastructure repository   | colocated                   | `*.test.ts`         |
| DI container bindings       | colocated                   | `*.test.ts`         |
| React component             | colocated                   | `*.test.tsx`        |
| Query helper                | colocated                   | `*.test.ts`         |
| Core-shared primitive       | colocated in `core-shared`  | `*.test.ts`         |
| Feature-level cross-layer   | `packages/<feature>/tests/` | `*.feature.test.ts` |
| Browser e2e                 | `apps/<app>/e2e/`           | `*.spec.ts`         |

### 10.2 Vitest

- Each package has its own `vitest.config.ts`.
- Shared base in `packages/core-typescript/vitest.base.ts`; packages extend it and pick `environment: 'jsdom' | 'node'` per need.
- Turbo `test` task runs `vitest run` per package.

### 10.3 DI in tests (per-feature container)

**Default (use case + controller tests) — direct factory injection.** Construct mock dependencies and pass them into the factory function. No container involvement:

```ts
// Use case test — direct factory injection (ADR-012)
const repo = new MockArticlesRepository();
const useCase = getArticleBySlugUseCase(repo);
const result = await useCase({ slug: "hello-world" });

// Controller test — same pattern
const repo = new MockArticlesRepository();
const useCase = getArticleBySlugUseCase(repo);
const controller = getArticleBySlugController(useCase);
const result = await controller({ slug: "hello-world" });
```

**Router tests — container rebinding still appropriate.** tRPC routers resolve controllers via `container.get<IXController>(SYMBOL)`, so router tests must rebind the container:

```ts
// Router test (only here is container rebinding still appropriate)
beforeEach(() => {
  if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
    blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
  }
  blogContainer
    .bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
    .toConstantValue(new MockArticlesRepository());
});
```

No shared `initializeContainer()` / `destroyContainer()`.

### 10.4 Actual test coverage

- **360 tests across 26 packages** (`pnpm test` green as of 2026-05-06)

Key coverage areas:

- Output-validation: every non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repository data
- Router error-mapping: every feature has a router test asserting domain error → correct `TRPCError.code` translation
- Presenter shape: `auth` sign-in/sign-up controllers assert the presenter-reshaped view (cookie, not full session object)

### 10.5 Playwright (included from day one)

- `apps/web-next/e2e/`:
  - `playwright.config.ts` — starts dev server on port 3000 via `webServer`, chromium only initially
  - `blog-post.spec.ts`, `marketing-page.spec.ts`, `home-nav.spec.ts`
- `apps/web-tanstack/e2e/`:
  - Parallel config (port 3002)
  - `blog-post.spec.ts` (validates framework-agnostic feature claim)
- `apps/cms/` — no e2e (Payload has its own admin tests)
- Root script: `pnpm test:e2e` via Turbo
- ESLint config adds `eslint-plugin-playwright` for e2e folders
- Playwright's `globalSetup` verifies Postgres is running; fails fast with a helpful message otherwise

### 10.6 Test obligations per layer

Every new use case and controller is expected to satisfy these rules.

| Rule | Description                                                                                 | Layer                                                        | Where the test lives                                                                    |
| ---- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
| R10  | Controller input must be typed `unknown`; schema is the gate                                | `interface-adapters/controllers/`                            | `*.controller.test.ts` — assert `InputParseError` on invalid input                      |
| R24  | Use-case + controller tests use direct factory injection; no `container.unbind/bind`        | `application/use-cases/` + `interface-adapters/controllers/` | `*.use-case.test.ts`, `*.controller.test.ts`                                            |
| R25  | Non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repo data  | `application/use-cases/`                                     | `*.use-case.test.ts`                                                                    |
| R26  | Every feature has a router test asserting domain error → expected `TRPCError.code`          | `integrations/api/`                                          | `router.test.ts` — call via `xRouter.createCaller({})` and assert `TRPCError.code`      |
| R27  | When presenter strips/renames/transforms, controller test asserts the resulting view shape  | `interface-adapters/controllers/`                            | `*.controller.test.ts` — assert omitted fields absent, transformed fields present       |
| R28  | When controller has a non-identity presenter, tests assert the _view_ shape (not `XOutput`) | `interface-adapters/controllers/`                            | `*.controller.test.ts` — catches regressions where presenter short-circuits to identity |

Identity presenters do not require R27/R28 tests. Void-output controllers (e.g., `signOutController`, `deleteMediaController`) are exempt from R11 (presenter), R25, R27, and R28.

---

## 11. Docs + ADR strategy

### 11.1 Existing ADRs

| File                          | Action                       | Notes                                                                          |
| ----------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
| `adr-001-monorepo-tool.md`    | Keep unchanged               | Turborepo + pnpm still accurate                                                |
| `adr-002-di-framework.md`     | Keep; append note            | InversifyJS kept, but now per-feature containers                               |
| `adr-003-cms-separation.md`   | Mark v1 superseded, write v2 | New architecture splits `cms-core` into `core-cms` + feature-owned collections |
| `adr-004-dual-mode-client.md` | Mark superseded              | `cms-client` deleted per spec §10                                              |
| `adr-005-atomic-design.md`    | Keep; append scope note      | Applies to `core-ui/` only                                                     |

### 11.2 New ADRs

- `adr-006-vertical-feature-packages.md` — the main architectural pivot; references source spec
- `adr-007-drop-cms-client-wrapper.md` — rationale for removing `packages/cms-client`
- `adr-008-per-feature-di-containers.md` — why each feature owns its InversifyJS container
- `adr-009-integrations-folder-naming.md` — why spec's `adapters/` is renamed `integrations/`

### 11.3 Rewritten docs

- `docs/architecture/overview.md` — new diagram, new flow, vertical package organization
- `docs/architecture/dependency-flow.md` — new graph, three-tag boundary model
- `docs/architecture/vertical-feature-spec.md` — copy of source spec for offline reference
- `docs/guides/adding-a-feature.md` — new recipe (small feature + mature feature, per addendum v5)
- `docs/guides/testing-strategy.md` — new placement table

### 11.4 AGENTS.md

All rewritten:

- Root `AGENTS.md` — new package map, new data flow, new rules, new boundary model
- Per-core-package: responsibilities + forbidden imports (~60 lines each)
- Per-feature-package: layer rules, addendum v5 folder-creation checklist, test placement
- Per-layer inside features: local import rules, test patterns (short)
- Per-app: purpose, imports, port, dev commands, e2e commands

Root `CLAUDE.md` — updated "Read First" pointers, unchanged port table, added boundary-enforcement note.

### 11.6 Post-spec ADRs

Two additional ADRs were added after the initial vertical-feature refactor and now form part of the permanent decision record:

- `adr-012-feature-conventions.md` — factory-function use cases + controllers, one-per-use-case controllers, canonical file-layout conventions, real Payload implementations for `auth`, full `media` scaffold. Accepts the pattern with four intentional divergences (inversify retained, per-feature DI containers, colocated tests, no Sentry wrapping).
- `adr-013-input-output-unification.md` — use-case file is the single source of truth for `xInputSchema`/`xOutputSchema`; runtime output validation (`xOutputSchema.parse(result)`); co-located `function presenter` in every non-void controller; per-feature `procedures.ts` for domain error → `TRPCError` mapping via `defineErrorMiddleware` from `core-shared`; `./ui` subpath separates UI artifacts from contracts.

---

## 12. Out of scope (deferred)

- Real Payload integration tests with a test database (stub with mock repos; write real integration tests later)
- Coverage reporting aggregation across packages (initial vitest setup per-package; aggregation is a follow-up)
- Multi-browser Playwright matrix (chromium only initially; add firefox/webkit later)
- Payload subscriptions / realtime (spec addendum v4); no feature requires it yet
- CMS app Next.js 15.5 + Payload 3.81 stabilization concerns (recently patched; monitor; no specific action in this refactor)

---

## 13. Success criteria

- `pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm build` all green
- `pnpm test:e2e` green against running dev servers
- `pnpm dev --filter @repo/web-next` serves a home page with navigation + marketing content + a blog index, and `/blog/[slug]` shows an article — all fed by tRPC → feature controllers → use-cases → Payload Local API
- `pnpm dev --filter @repo/web-tanstack` renders the same blog post using the same feature packages
- Storybook builds showing `core-ui` primitives
- Any deep import (e.g., `import x from '@repo/blog/src/...'`) fails `pnpm lint`
- Cross-feature imports are restricted to event contracts (ADR-015): the `feature` boundary tag accepts other `feature` tags, but rule E1 (`no-handler-reexport`) keeps consumer handlers, use cases, and repositories private. Importing a publisher's contract is allowed; importing anything else still fails `pnpm lint`.
- Root `AGENTS.md` and one-per-package AGENTS.md reflect the new architecture
- 9 new ADRs (5 existing maintained/appended/superseded + 4 new)
- Zero references to deleted packages anywhere in the codebase

---

## 14. Instrumentation & error capture (ADR-014, ADR-017)

**Decisions:** `docs/decisions/adr-014-instrumentation-sentry.md` (interface decisions); `docs/decisions/adr-017-opentelemetry-migration.md` (OTel substrate, supersedes ADR-014 impl section).

**Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing happens at the OTel processor layer before the Sentry exporter. Feature code depends only on `ITracer`, `ILogger`, `IMetrics` interfaces — no Sentry or OTel SDK imports.

**File additions per feature:**

- `infrastructure/repositories/<entity>.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing.
- `infrastructure/repositories/<entity>.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors).
- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`metrics`, `bus`, `queue`, `realtime`, `realtimeRegistry`, `auditLog`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. an authoritative action calls `ctx.auditLog?.record({...})`; the `?.` makes it safe whether or not `@repo/core-audit` is scaffolded).
- `di/bind-dev-seed.ts` — signature `(ctx: BindContext)` (no `config`). Same wrapping as bind-production but with the populated mock.

**Required exports (per feature root):** unchanged.

**Public surface impact:** none for `./` (contracts) and `./ui`. The `./di/bind-production` and `./di/bind-dev-seed` subpaths now have new signatures — any consumer outside the app dispatcher is unaffected (the dispatcher is the only consumer per ADR-008).

**Test patterns:**

- **Direct injection** of `RecordingTracer` / `RecordingLogger` from `@repo/core-testing/instrumentation` for span/capture assertions.
- **Contract suite span assertions** — every repo's contract suite (`__contracts__/*-repository.contract.ts`) includes a `span emission (R50)` describe block enumerating one assertion per method.

**Tradeoff:** every public repo method gains ~6 lines of `tracer.startSpan(...)` boilerplate. Worth the per-method visibility in production traces; if it ever proves excessive, a `withRepoSpan` helper can collapse the wrapping.
