§6 Feature package internal shape: now shows the post-Plan-9 layout
(entities/models, entities/errors, .repository.{mock.ts, ts,
interface.ts} naming, integrations/api/procedures.ts, ui/index.ts,
package.json ./ui subpath). Request flow box updated to show
xProcedure + xInputSchema + presenter + middleware lanes.
§10 Test placement + tooling: §10.3 now shows direct factory injection
for use-case + controller tests (the post-Plan-9 default). Router tests
retain container rebinding because tRPC resolves controllers via DI.
New §10.6 'Test obligations per layer' table maps R10, R24, R25, R26,
R27, R28 to their required layer.
§10.4 updated to reflect 360 tests across 26 packages; cross-links to
Plan 8 + Plan 9 refactor log Summary sections.
§11 doc note: ADR-012 + ADR-013 added as §11.6.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
34 KiB
Vertical Feature Architecture Spec
Source of truth. Copied from
docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.mdfor in-tree reference. Edits here should be backported to the design spec.
Vertical Feature Monorepo Refactor — Design Spec
Date: 2026-04-21
Status: Approved for implementation planning
Supersedes (partially): 2026-04-06-clean-architecture-monorepo-template-design.md
Source spec: monorepo-architecture-spec-detailed-v5.md (v1 + addenda v3/v4/v5)
1. Goal
Refactor the template from a horizontal Clean Architecture monorepo (single packages/core, single packages/api, etc.) into a vertical feature-package monorepo where business capabilities (auth, blog, media, marketing-pages, navigation) are the top-level organizing principle, supported by core-* foundation packages for non-business concerns.
The refactor preserves Clean Architecture layering inside each feature (the existing rigor) while reorganizing between packages by business capability.
2. Current state (summary)
packages/core— all domains (auth, content) share one Clean Architecture layout with InversifyJS DI 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)
- Five
core-*packages:core-shared,core-cms,core-api,core-trpc,core-ui - 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
superpowers/specs/ # this file + implementation plan
CLAUDE.md AGENTS.md docker-compose.yml package.json pnpm-lock.yaml
pnpm-workspace.yaml tsconfig.base.json turbo.json
Package count: 3 apps + 5 core + 5 feature + 2 tooling = 15 packages (was 12).
6. Feature package internal shape
Canonical mature shape (e.g., packages/blog/) — post-Plan-9 layout:
packages/blog/
src/
config.ts # constants if needed
entities/
models/
article.ts # Zod schema + Article type
article.test.ts
errors/
article.ts # ArticleNotFoundError (sets this.name)
common.ts # InputParseError
errors.test.ts
application/
repositories/
articles.repository.interface.ts # IArticlesRepository
use-cases/
get-articles.use-case.ts # factory + getArticlesInputSchema + getArticlesOutputSchema + parse
get-articles.use-case.test.ts # incl. R25 output-validation test
get-article-by-slug.use-case.ts
get-article-by-slug.use-case.test.ts
create-article.use-case.ts
create-article.use-case.test.ts
infrastructure/
repositories/
articles.repository.ts # real Payload-backed impl (getPayload({ config }) from core-cms)
articles.repository.mock.ts # MockArticlesRepository
articles.repository.test.ts
articles.repository.mock.test.ts
interface-adapters/ # Clean Arch grouping (controllers now; presenters/gateways later)
controllers/
get-articles.controller.ts # factory + safeParse(getArticlesInputSchema) + function presenter
get-articles.controller.test.ts # incl. R27/R28 if presenter reshapes
get-article-by-slug.controller.ts # one file per use case
get-article-by-slug.controller.test.ts
create-article.controller.ts
create-article.controller.test.ts
di/ # feature-local InversifyJS container
symbols.ts # BLOG_SYMBOLS
module.ts # ContainerModule — .toDynamicValue() for use cases + controllers
container.ts # blogContainer singleton
bind-production.ts # swaps mock → real Payload impls at app boot
container.test.ts
integrations/ # renamed from spec's adapters/
api/
procedures.ts # blogProcedure = t.procedure.use(defineErrorMiddleware([...])) — Plan 9
router.ts # blogProcedure.input(xInputSchema).query/mutation(...)
router.test.ts # incl. R26 router error-mapping test
index.ts
cms/
collections/
articles.ts # Payload CollectionConfig
hooks/
<lifecycle-hook>.ts # if needed
index.ts # exports: articles (for core-cms composition)
ui/
index.ts # re-exports query builders (Plan 9 — apps import from @repo/blog/ui)
query.ts # trpc.blog.articleBySlug.queryOptions(...)
__factories__/
article.factory.ts # test data factories (Plan 7)
__contracts__/
articles-repository.contract.ts # repo interface contract suite (Plan 7)
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"
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
No effects/, jobs/, events/ unless the feature grows them.
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, core-trpc |
core |
packages/core-shared, core-ui |
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.
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.,./cms,./apionly (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 (Plan 8 / ADR-012)
const repo = new MockArticlesRepository();
const useCase = getArticleBySlugUseCase(repo);
const result = await useCase({ slug: "hello-world" });
// Controller test — same pattern
const repo = new MockArticlesRepository();
const useCase = getArticleBySlugUseCase(repo);
const controller = getArticleBySlugController(useCase);
const result = await controller({ slug: "hello-world" });
Router tests — container rebinding still appropriate. tRPC routers resolve controllers via container.get<IXController>(SYMBOL), so router tests must rebind the container:
// Router test (only here is container rebinding still appropriate)
beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
blogContainer.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(new MockArticlesRepository());
});
No shared initializeContainer() / destroyContainer().
10.4 Actual test coverage (post-Plan-9)
After Plan 8 (Lazar conformance) and Plan 9 (I/O unification + presenter + error middleware):
- 360 tests across 26 packages (
pnpm testgreen as of 2026-05-06) - Plan 8 grew the suite from 244 → 325 tests (+81, +33%) — factory refactor + media scaffold
- Plan 9 grew the suite from 325 → 360 tests (+35, +11%) — R25 output-validation tests + R26 router error-mapping tests + R27/R28 presenter shape tests
Key coverage areas added in these plans:
- R25 (output-validation): every non-void use case has a test asserting
xOutputSchema.parsethrows on malformed repository data - R26 (router error-mapping): every feature has a router test asserting domain error → correct
TRPCError.codetranslation - R27/R28 (presenter shape):
authsign-in/sign-up controllers assert the presenter-reshaped view (cookie, not full session object)
Cross-reference: Plan 8 refactor log Summary at docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md and Plan 9 refactor log Summary at docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md.
10.5 Playwright (included from day one)
apps/web-next/e2e/:playwright.config.ts— starts dev server on port 3000 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 (Plan 9)
Every new use case and controller is expected to satisfy these rules. The rule IDs correspond to the spec docs/superpowers/specs/2026-05-06-input-output-unification-design.md §3.
| Rule | Description | Layer | Where the test lives |
|---|---|---|---|
| R10 | Controller input must be typed unknown; schema is the gate |
interface-adapters/controllers/ |
*.controller.test.ts — assert InputParseError on invalid input |
| R24 | Use-case + controller tests use direct factory injection; no container.unbind/bind |
application/use-cases/ + interface-adapters/controllers/ |
*.use-case.test.ts, *.controller.test.ts |
| R25 | Non-void use case has a test asserting xOutputSchema.parse throws on malformed repo data |
application/use-cases/ |
*.use-case.test.ts |
| R26 | Every feature has a router test asserting domain error → expected TRPCError.code |
integrations/api/ |
router.test.ts — call via xRouter.createCaller({}) and assert TRPCError.code |
| R27 | When presenter strips/renames/transforms, controller test asserts the resulting view shape | interface-adapters/controllers/ |
*.controller.test.ts — assert omitted fields absent, transformed fields present |
| R28 | When controller has a non-identity presenter, tests assert the view shape (not XOutput) |
interface-adapters/controllers/ |
*.controller.test.ts — catches regressions where presenter short-circuits to identity |
Identity presenters do not require R27/R28 tests. Void-output controllers (e.g., signOutController, deleteMediaController) are exempt from R11 (presenter), R25, R27, and R28.
11. Docs + ADR strategy
11.1 Existing ADRs
| File | Action | Notes |
|---|---|---|
adr-001-monorepo-tool.md |
Keep unchanged | Turborepo + pnpm still accurate |
adr-002-di-framework.md |
Keep; append note | InversifyJS kept, but now per-feature containers |
adr-003-cms-separation.md |
Mark v1 superseded, write v2 | New architecture splits cms-core into core-cms + feature-owned collections |
adr-004-dual-mode-client.md |
Mark superseded | cms-client deleted per spec §10 |
adr-005-atomic-design.md |
Keep; append scope note | Applies to core-ui/ only |
11.2 New ADRs
adr-006-vertical-feature-packages.md— the main architectural pivot; references source 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 (Plans 8 + 9)
Two additional ADRs were added after the initial vertical-feature refactor and now form part of the permanent decision record:
adr-012-lazar-conformance.md— Plan 8: factory-function use cases + controllers, one-per-use-case controllers, Lazar file-layout conventions, real Payload implementations 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— Plan 9: 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. Migration sequencing
Big-bang refactor executed as 11 internal phases; each phase ends with a verification gate (pnpm typecheck && pnpm test) before proceeding. Commit per phase (or per feature within Phase 5) so git bisect works.
| # | Phase | Key artifact | Gate |
|---|---|---|---|
| 1 | Scaffold core packages (empty shells) | packages/core-shared/, core-cms/, core-api/, core-trpc/, core-ui/ with stubs |
pnpm install + pnpm typecheck green |
| 2 | Populate core-shared |
Fields, blocks, access helpers, hooks, tRPC init/context | pnpm test --filter @repo/core-shared passes |
| 3 | Populate core-cms stub and repoint apps/cms |
payload.config.ts lifted from cms-core to core-cms; collections: [], globals: [] initially; apps/cms updates its import from @repo/cms-core to @repo/core-cms |
pnpm dev --filter @repo/cms boots admin UI; pnpm generate:types succeeds |
| 4 | Migrate blog feature end-to-end (first vertical — proves pattern) |
Full canonical shape; Articles collection; per-feature DI container; tRPC router; UI | pnpm typecheck && pnpm test --filter @repo/blog green |
| 5 | Migrate remaining features: auth, marketing-pages, navigation, media |
Each follows the blog template | Per-feature typecheck + tests + core-cms regenerates |
| 6 | Populate core-trpc + wire apps |
Client, per-framework providers; route handlers in web-next + web-tanstack; example pages |
pnpm dev serves pages; tRPC returns Payload data |
| 7 | Migrate core-ui |
Move packages/ui/ contents; relocate feature-shaped organisms into features; update Storybook imports |
Storybook builds; pnpm test green |
| 8 | Delete old packages | core/, api/, api-client/, cms-core/, cms-client/, ui/ |
pnpm install && pnpm typecheck && pnpm test green |
| 9 | Boundary enforcement | Install eslint-plugin-boundaries; add package-level turbo.json tags; write lint rules |
pnpm lint zero violations |
| 10 | Playwright setup | Configs, initial specs in both frontends; root test:e2e script |
pnpm test:e2e green |
| 11 | Docs rewrite | Copy spec; rewrite overview, dependency-flow, guides; new ADRs; all AGENTS.md; delete stale plans | Human review |
Commit strategy: one commit per phase. Phase 5 may be multiple commits (one per feature). Every commit builds + tests green.
13. Out of scope (deferred)
- Real Payload integration tests with a test database (stub with mock repos; write real integration tests later)
- Coverage reporting aggregation across packages (initial vitest setup per-package; aggregation is a follow-up)
- Multi-browser Playwright matrix (chromium only initially; add firefox/webkit later)
- Queue workers / CLI scripts (no
core-eventspackage yet; spec addendum v4's optionalcore-eventsstays deferred until a feature actually needs an event bus) - Payload subscriptions / realtime (spec addendum v4); no feature requires it yet
- CMS app Next.js 15.5 + Payload 3.81 stabilization concerns (recently patched; monitor; no specific action in this refactor)
14. Success criteria
pnpm install && pnpm typecheck && pnpm lint && pnpm test && pnpm 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 - Any cross-feature import fails
pnpm 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
15. Post-approval next step
Invoke the superpowers:writing-plans skill to produce a detailed, executable implementation plan based on the 11-phase sequencing in §12. Each phase becomes a plan section with concrete file-by-file steps, verification commands, and commit-message templates.