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
711 lines
38 KiB
Markdown
711 lines
38 KiB
Markdown
# 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` (R31–R55 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.
|