Files
agentic-dev-template/docs/architecture/vertical-feature-spec.md
Danijel Martinek 0748f9e5ed
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
docs(architecture): refresh explainers and spec to the shipped system
Bring docs/architecture/ in line with the current repo:

- feature-conformance-explainer.html: drop the "proposed / not yet
  implemented" framing — the system is shipped. Four enforcement points
  become five (adds `pnpm fallow` as the whole-codebase audit). Manifest
  playground shows `coverage`, `analyticsEvents`, `rateLimit`,
  `requiresConsent`. Milestone / anchor / open-question sections kept
  but marked historical.
- agent-first-workflow-and-conformance.md: four → five enforcement
  layers; layer table gains the Fallow row.
- di-explainer.html: bind-production sample rewritten to show
  wireUseCase() + assertFeatureConformance() + the full wrapper stack
  (span → capture → audit? → analytics? → consent? → rateLimit?).
- data-flow-explainer.html: same bind-production refresh for the
  data-flow narrative.
- audit-and-compliance-explainer.html: AuditAction enum 6 → 10 values
  (CONSENT_GRANT / WITHDRAW / RESTRICT / UNRESTRICT);
  BindProductionContext example gains analytics, consentFactory,
  rateLimit.
- vertical-feature-spec.md: §5 layout lists the 8 optional cores plus
  core-testing; §9.5 hedges the turbo.json snippet against the live
  file; §10.4 drops the dated "360 tests" metric for the ADR-020
  coverage architecture; §11 gains a historical lead-in pointing at
  docs/decisions/ as the canonical 25-ADR set.
2026-05-23 14:06:03 +02:00

42 KiB
Raw Permalink Blame History

Vertical Feature Architecture Spec

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


Vertical Feature Monorepo Refactor — Design Spec

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


1. Goal

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

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


2. Current state (summary)

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

3. Target state (summary)

  • Three must-have core-* packages: core-shared, core-cms, core-api. Five optional cross-cutting cores scaffold on demand via pnpm turbo gen core-package <name>: core-trpc, core-ui, core-realtime (ADR-016), core-events (ADR-015), core-audit (ADR-018). See docs/architecture/template-tiers.md.
  • Five feature packages: auth, blog, media, marketing-pages, navigation
  • Two tooling packages renamed: eslint-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 — must-have (tagged "core" / "core-composition") ───
    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 — optional (scaffold via `pnpm turbo gen core-package <name>`) ───
    core-trpc/                             # frontend tRPC platform (client, providers per framework)
    core-ui/                               # design-system primitives (atoms/molecules/templates)
    core-events/                           # in-memory + Payload-backed event bus + job queue (ADR-015)
    core-realtime/                         # Socket.IO broadcaster + handler registry (ADR-016)
    core-audit/                            # DPA-compliant audit logging (ADR-018)
    core-analytics/                        # product analytics capture channel (ADR-024)
    core-consent/                          # consent + cookie banner (ADR-025)
    core-dsr/                              # data-subject-rights — export/delete/rectify/restrict (ADR-025)

    # ─── 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/                           # ESLint preset + the 15 conformance rules + boundary rules
    core-typescript/                       # tsconfig presets (base, react-library, nextjs)
    core-testing/                          # factories, contract suites, recording test doubles

  docs/
    architecture/
      overview.md                          # rewritten
      dependency-flow.md                   # rewritten
      vertical-feature-spec.md             # copy of source spec
    decisions/
      adr-001 … adr-NNN                    # 25 ADRs at time of writing — see §11 and `docs/decisions/`
    guides/
      adding-a-feature.md                  # rewritten
      testing-strategy.md                  # rewritten
    work/                                  # epic + story tracking

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

Package count: the packages/ workspace holds 19 packages — 3 must-have cores (core-shared, core-cms, core-api), 8 optional cores scaffolded on demand (core-ui, core-events, core-realtime, core-trpc, core-audit, core-analytics, core-consent, core-dsr), 3 tooling packages (core-eslint, core-typescript, core-testing), and 5 feature packages — plus 4 apps. A minimal project that scaffolds none of the optional cores ships 11 packages.


6. Feature package internal shape

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

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

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

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

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

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

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

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

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

    __factories__/
      article.factory.ts                            # test data factories

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

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

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

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

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

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

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

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

Request flow:

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

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


7. Core package responsibilities

core-shared/

Generic reusable primitives. Zero business knowledge.

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

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

Forbidden: importing any feature package.

core-cms/

Payload composition only.

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

Exports: ., ./generated-types.

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

core-api/

tRPC composition only.

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

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

core-trpc/

Frontend tRPC platform with framework-specific provider shims.

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

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

Forbidden: importing any feature package.

core-ui/

Design-system primitives only.

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

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

Forbidden: importing any feature package.


8. Payload collection & global ownership

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

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

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, feature, tooling
tooling tooling

A feature may import another feature's public exports — its @repo/<feature> contract barrel (types, errors, schemas, event contracts). It must not reach another feature's internals, and cross-feature behaviour still flows through IEventBus, never a direct use-case call.

9.3 Composition exceptions

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

9.4 Four enforcement layers

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

9.5 Root turbo.json (unchanged concept)

The snippet below shows the original task shape. The live turbo.json has evolved — additional tasks (conformance, fallow, boundaries, test:stories, build-storybook), tweaks to dependsOn (test and typecheck no longer depend on ^build), and the boundaries.tags block enforcing the dependency matrix (see §9.2). See the actual root turbo.json for the authoritative shape; the principle below is unchanged.

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

  • pnpm test runs green across every workspace package. Coverage thresholds are declared per-feature in feature.manifest.ts (ADR-020) — entities and use-cases at 100% statements/branches/functions/lines, controllers at 100/95/100/100, with a baseline of 80/75/80/80 elsewhere. The four coverage layers (L0 thresholds → L1 diff coverage → L2 aggregate trend → L3 mutation) are documented in docs/guides/coverage.md.

Key coverage areas:

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

10.5 Playwright (included from day one)

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

10.6 Test obligations per layer

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

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

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


11. Docs + ADR strategy

Historical. This section captures the ADR strategy at the time of the vertical-feature refactor — 5 existing ADRs (001005), 4 new ADRs (006009), and the 2 post-spec ADRs (012013). The canonical, current ADR set lives in docs/decisions/ and now spans 25 ADRs — adding boundaries (010), TDD foundation (011), instrumentation + OpenTelemetry (014, 017), events / realtime / audit (015, 016, 018), Sandcastle (019), coverage (020), hybrid versioning (021), library policy + CI security (022, 023), product analytics (024), and the EU compliance baseline (025). Read docs/decisions/ for the authoritative list; the tables below are preserved as the refactor's original record.

11.1 Existing ADRs

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

11.2 New ADRs

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

11.3 Rewritten docs

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

11.4 AGENTS.md

All rewritten:

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

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

11.6 Post-spec ADRs

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

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

12. Out of scope (deferred)

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

13. Success criteria

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

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

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

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

File additions per feature:

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

Required exports (per feature root): unchanged.

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

Test patterns:

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

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