Files
agentic-dev-template/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

38 KiB
Raw Blame History

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-configcore-eslint, typescript-configcore-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:

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)

{
  "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:

// 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:

// 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.