Files
agentic-dev-template/docs/architecture/vertical-feature-spec.md
Danijel Martinek 2edc76002a refactor(docs): strip residual Phase/Plan setup-history references
Final sweep for setup-process bookkeeping not caught by template-reset-v1.
ADRs drop Plan-N qualifiers; spec collapses the historical 11-phase
migration table; scaffolding guide drops "Phase added" column; comment
prefixes referencing R-numbers in test describes / eslint inline comments
are normalized. Architecture-level rule IDs (R40, R52, E0, J0, etc.) are
preserved where they serve as stable cross-references in ADRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:28:31 +02:00

39 KiB

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

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

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

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

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

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


6. Feature package internal shape

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

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

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

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

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

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

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

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

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

    __factories__/
      article.factory.ts                            # test data factories

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

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

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

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

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

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

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

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

Request flow:

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

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


7. Core package responsibilities

core-shared/

Generic reusable primitives. Zero business knowledge.

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

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

Forbidden: importing any feature package.

core-cms/

Payload composition only.

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

Exports: ., ./generated-types.

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

core-api/

tRPC composition only.

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

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

core-trpc/

Frontend tRPC platform with framework-specific provider shims.

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

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

Forbidden: importing any feature package.

core-ui/

Design-system primitives only.

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

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

Forbidden: importing any feature package.


8. Payload collection & global ownership

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

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

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

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

Hook routing policy:

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

9. Boundaries + enforcement

9.1 Five tags

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

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

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

9.2 Allowed dependency directions

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

9.3 Composition exceptions

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

9.4 Four enforcement layers

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

9.5 Root turbo.json (unchanged concept)

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

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

Key coverage areas:

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

10.5 Playwright (included from day one)

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

10.6 Test obligations per layer

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

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

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


11. Docs + ADR strategy

11.1 Existing ADRs

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

11.2 New ADRs

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

11.3 Rewritten docs

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

11.4 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

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.