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>
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 containerpackages/api— single tRPC aggregator + per-domain routerspackages/api-client— React Query hooks +ApiProvider+useTRPCpackages/cms-core— Payload config + all collections (Users, Articles, Media, SiteSettings global)packages/cms-client— dual-mode Payload client wrapper; defined but unusedpackages/ui— atomic-design component libraryapps/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 viapnpm turbo gen core-package <name>:core-trpc,core-ui,core-realtime(ADR-016),core-events(ADR-015),core-audit(ADR-018). Seedocs/architecture/template-tiers.md. - Five feature packages:
auth,blog,media,marketing-pages,navigation - Two tooling packages renamed:
eslint-config→core-eslint,typescript-config→core-typescript - Three apps unchanged in name:
apps/web-next,apps/web-tanstack,apps/cms - Packages deleted:
packages/core,packages/api,packages/api-client,packages/cms-core,packages/cms-client,packages/ui - Per-feature InversifyJS containers (no shared container)
- Clean Architecture controllers retained inside each feature (
interface-adapters/controllers/) - Spec's
adapters/renamed tointegrations/to avoid collision withinterface-adapters/ - Boundary enforcement via
eslint-plugin-boundaries+package.jsondeps +exportsmaps + Turborepo tags - Playwright e2e set up from day one in
web-nextandweb-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/tointegrations/ - Omit
core-payload-clientwrapper (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 incore-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'seffects/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-cmsmay import@repo/<feature>/cmssubpath exports only.core-apimay import@repo/<feature>/apisubpath exports only.- No other package may deviate from the five-tag rules.
9.4 Four enforcement layers
package.jsondependencies — only allowed deps declared.exportsmaps — feature packages expose.,./ui,./cms,./api,./di/bind-productiononly (no deep source paths).- ESLint
eslint-plugin-boundaries(lint-time) — configured inpackages/core-eslint/flat config:- Enforces the five-tag rules (same rules as Turbo boundaries)
- File-specific exemptions via
// @boundaries-ignorecomments
- Turborepo
boundaries(build-graph time) — configured in rootturbo.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 boundariesto 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 pickenvironment: 'jsdom' | 'node'per need. - Turbo
testtask runsvitest runper 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 testgreen as of 2026-05-06)
Key coverage areas:
- Output-validation: every non-void use case has a test asserting
xOutputSchema.parsethrows on malformed repository data - Router error-mapping: every feature has a router test asserting domain error → correct
TRPCError.codetranslation - Presenter shape:
authsign-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 viawebServer, chromium only initiallyblog-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:e2evia Turbo - ESLint config adds
eslint-plugin-playwrightfor e2e folders - Playwright's
globalSetupverifies 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 specadr-007-drop-cms-client-wrapper.md— rationale for removingpackages/cms-clientadr-008-per-feature-di-containers.md— why each feature owns its InversifyJS containeradr-009-integrations-folder-naming.md— why spec'sadapters/is renamedintegrations/
11.3 Rewritten docs
docs/architecture/overview.md— new diagram, new flow, vertical package organizationdocs/architecture/dependency-flow.md— new graph, three-tag boundary modeldocs/architecture/vertical-feature-spec.md— copy of source spec for offline referencedocs/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 forauth, fullmediascaffold. 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 forxInputSchema/xOutputSchema; runtime output validation (xOutputSchema.parse(result)); co-locatedfunction presenterin every non-void controller; per-featureprocedures.tsfor domain error →TRPCErrormapping viadefineErrorMiddlewarefromcore-shared;./uisubpath 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 buildall greenpnpm test:e2egreen against running dev serverspnpm dev --filter @repo/web-nextserves 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 APIpnpm dev --filter @repo/web-tanstackrenders the same blog post using the same feature packages- Storybook builds showing
core-uiprimitives - Any deep import (e.g.,
import x from '@repo/blog/src/...') failspnpm lint - Cross-feature imports are restricted to event contracts (ADR-015): the
featureboundary tag accepts otherfeaturetags, 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 failspnpm lint. - Root
AGENTS.mdand 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 intracer.startSpan(...)and anycatchblock callslogger.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 }fromctx. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller viawithSpan(withCapture(factory(deps)))at bind time.withSpanis outermost so an errored span's timing reflects the capture-and-rethrow;withCapturehonours the__sentryReportedflag 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 callsctx.auditLog?.record({...}); the?.makes it safe whether or not@repo/core-auditis scaffolded).di/bind-dev-seed.ts— signature(ctx: BindContext)(noconfig). 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/RecordingLoggerfrom@repo/core-testing/instrumentationfor span/capture assertions. - Contract suite span assertions — every repo's contract suite (
__contracts__/*-repository.contract.ts) includes aspan 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.