Files
agentic-dev/docs/architecture/vertical-feature-spec.md
Danijel Martinek 7c915cb447 docs(architecture): surface core-audit + DPA across architecture docs
Touches the deeper architecture surfaces the Phase 6 sweep skipped:

- overview.md: split must-have (core-shared, core-cms, core-api) from
  optional (core-trpc, core-ui, core-realtime, core-events, core-audit);
  add core-audit to the Five tags optional list
- dependency-flow.md: extend the bindAll diagram with resolveAudit;
  add auditLog row to the BindContext table; rename the
  TRACER/LOGGER/METRICS heading to include AUDIT (ADR-018); note the
  R52-style boundary rule for @repo/core-audit (consume via protocol)
- vertical-feature-spec.md: target-state section now states 3 must-have
  + 5 optional cores; tag matrix includes the optional cores; bind-
  production signature destructure includes auditLog
- di-explainer.html: §08 instrumentation gains an IAuditLog block + the
  Wiring path tree shows resolveAudit + auditLog in ctx
- testing-strategy.md: RecordingAuditLog reference + reset() guidance
2026-05-11 17:06:58 +02:00

711 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Vertical Feature Architecture Spec
> **Source of truth.** Copied from `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` for in-tree reference. Edits here should be backported to the design spec.
---
# 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
superpowers/specs/ # this file + implementation plan
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/`) — **post-Plan-9 layout**:
```
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 (post-Plan-9)
bind-dev-seed.test.ts
container.test.ts
integrations/ # renamed from spec's adapters/
api/
procedures.ts # blogProcedure = t.procedure.use(defineErrorMiddleware([...])) — Plan 9
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 (Plan 9 — apps import from @repo/blog/ui)
query.ts # trpc.blog.articleBySlug.queryOptions(...)
__factories__/
article.factory.ts # test data factories (Plan 7)
__contracts__/
articles-repository.contract.ts # repo interface contract suite (Plan 7)
__seeds__/
dev.ts # buildDev<Entities>() — uses factory; consumed by bind-dev-seed (post-Plan-9)
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). `./ui` was added in Plan 9; `./di/bind-production` was added in Plan 5.
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 (Plan 8 / 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 (post-Plan-9)
After Plan 8 (Lazar conformance) and Plan 9 (I/O unification + presenter + error middleware):
- **360 tests across 26 packages** (`pnpm test` green as of 2026-05-06)
- Plan 8 grew the suite from 244 → 325 tests (+81, +33%) — factory refactor + media scaffold
- Plan 9 grew the suite from 325 → 360 tests (+35, +11%) — R25 output-validation tests + R26 router error-mapping tests + R27/R28 presenter shape tests
Key coverage areas added in these plans:
- R25 (output-validation): every non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repository data
- R26 (router error-mapping): every feature has a router test asserting domain error → correct `TRPCError.code` translation
- R27/R28 (presenter shape): `auth` sign-in/sign-up controllers assert the presenter-reshaped view (cookie, not full session object)
Cross-reference: Plan 8 refactor log Summary at `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` and Plan 9 refactor log Summary at `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`.
### 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 (Plan 9)
Every new use case and controller is expected to satisfy these rules. The rule IDs correspond to the spec `docs/superpowers/specs/2026-05-06-input-output-unification-design.md` §3.
| 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 Deleted
- `docs/superpowers/plans/*` — 6 stale plan files from previous architecture
### 11.5 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 (Plans 8 + 9)
Two additional ADRs were added after the initial vertical-feature refactor and now form part of the permanent decision record:
- `adr-012-lazar-conformance.md` — Plan 8: factory-function use cases + controllers, one-per-use-case controllers, Lazar 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` — Plan 9: 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. Migration sequencing
Big-bang refactor executed as 11 internal phases; each phase ends with a verification gate (`pnpm typecheck && pnpm test`) before proceeding. Commit per phase (or per feature within Phase 5) so `git bisect` works.
| # | Phase | Key artifact | Gate |
|---|---|---|---|
| 1 | Scaffold core packages (empty shells) | `packages/core-shared/`, `core-cms/`, `core-api/`, `core-trpc/`, `core-ui/` with stubs | `pnpm install` + `pnpm typecheck` green |
| 2 | Populate `core-shared` | Fields, blocks, access helpers, hooks, tRPC init/context | `pnpm test --filter @repo/core-shared` passes |
| 3 | Populate `core-cms` stub **and repoint `apps/cms`** | `payload.config.ts` lifted from `cms-core` to `core-cms`; `collections: []`, `globals: []` initially; `apps/cms` updates its import from `@repo/cms-core` to `@repo/core-cms` | `pnpm dev --filter @repo/cms` boots admin UI; `pnpm generate:types` succeeds |
| 4 | Migrate `blog` feature end-to-end (first vertical — proves pattern) | Full canonical shape; Articles collection; per-feature DI container; tRPC router; UI | `pnpm typecheck && pnpm test --filter @repo/blog` green |
| 5 | Migrate remaining features: `auth`, `marketing-pages`, `navigation`, `media` | Each follows the blog template | Per-feature typecheck + tests + `core-cms` regenerates |
| 6 | Populate `core-trpc` + wire apps | Client, per-framework providers; route handlers in `web-next` + `web-tanstack`; example pages | `pnpm dev` serves pages; tRPC returns Payload data |
| 7 | Migrate `core-ui` | Move `packages/ui/` contents; relocate feature-shaped organisms into features; update Storybook imports | Storybook builds; `pnpm test` green |
| 8 | Delete old packages | `core/`, `api/`, `api-client/`, `cms-core/`, `cms-client/`, `ui/` | `pnpm install && pnpm typecheck && pnpm test` green |
| 9 | Boundary enforcement | Install `eslint-plugin-boundaries`; add package-level `turbo.json` tags; write lint rules | `pnpm lint` zero violations |
| 10 | Playwright setup | Configs, initial specs in both frontends; root `test:e2e` script | `pnpm test:e2e` green |
| 11 | Docs rewrite | Copy spec; rewrite overview, dependency-flow, guides; new ADRs; all AGENTS.md; delete stale plans | Human review |
**Commit strategy:** one commit per phase. Phase 5 may be multiple commits (one per feature). Every commit builds + tests green.
---
## 13. 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)
---
## 14. 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
---
## 15. Post-approval next step
Invoke the `superpowers:writing-plans` skill to produce a detailed, executable implementation plan based on the 11-phase sequencing in §12. Each phase becomes a plan section with concrete file-by-file steps, verification commands, and commit-message templates.
---
## 16. Instrumentation & error capture (ADR-014, ADR-017)
**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31R55 interfaces); `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.