docs(spec): vertical-feature-spec.md — Plan 8 + Plan 9 file shape and testing updates

§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>
This commit is contained in:
2026-05-06 16:49:28 +02:00
parent ce294b3041
commit d1d792c72d

View File

@@ -150,75 +150,85 @@ repo/
## 6. Feature package internal shape ## 6. Feature package internal shape
Canonical mature shape (e.g., `packages/blog/`): Canonical mature shape (e.g., `packages/blog/`)**post-Plan-9 layout**:
``` ```
packages/blog/ packages/blog/
src/ src/
config.ts # constants if needed
entities/ entities/
models/
article.ts # Zod schema + Article type article.ts # Zod schema + Article type
article.test.ts article.test.ts
errors.ts errors/
article.ts # ArticleNotFoundError (sets this.name)
common.ts # InputParseError
errors.test.ts
application/ application/
repositories/ repositories/
articles-repository.interface.ts # IArticlesRepository articles.repository.interface.ts # IArticlesRepository
use-cases/ use-cases/
get-article.use-case.ts get-articles.use-case.ts # factory + getArticlesInputSchema + getArticlesOutputSchema + parse
get-article.use-case.test.ts 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.ts
create-article.use-case.test.ts
infrastructure/ infrastructure/
repositories/ repositories/
payload-articles.repository.ts # @injectable, calls getPayload({ config }) from core-cms articles.repository.ts # real Payload-backed impl (getPayload({ config }) from core-cms)
mock-articles.repository.ts # @injectable, for tests articles.repository.mock.ts # MockArticlesRepository
payload-articles.repository.test.ts articles.repository.test.ts
articles.repository.mock.test.ts
interface-adapters/ # Clean Arch grouping (controllers now; presenters/gateways later) interface-adapters/ # Clean Arch grouping (controllers now; presenters/gateways later)
controllers/ controllers/
articles.controller.ts # Zod safeParse → InputParseError → use case get-articles.controller.ts # factory + safeParse(getArticlesInputSchema) + function presenter
articles.controller.test.ts 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 di/ # feature-local InversifyJS container
symbols.ts # BLOG_SYMBOLS symbols.ts # BLOG_SYMBOLS
module.ts # ContainerModule module.ts # ContainerModule — .toDynamicValue() for use cases + controllers
container.ts # blogContainer + getInjection<T>() container.ts # blogContainer singleton
bind-production.ts # swaps mock → real Payload impls at app boot
container.test.ts container.test.ts
integrations/ # renamed from spec's adapters/ 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/ cms/
collections/ collections/
articles.ts # Payload CollectionConfig articles.ts # Payload CollectionConfig
hooks/ hooks/
after-post-change.ts # Payload lifecycle adapter → calls effects <lifecycle-hook>.ts # if needed
index.ts # exports: articles (for core-cms composition) index.ts # exports: articles (for core-cms composition)
api/
router.ts # tRPC procedures → controllers
router.test.ts
effects/ # only when needed
revalidate-post.ts
sync-post-search.ts
jobs/ # only when needed
publish-scheduled-posts.ts
events/ # only when needed
post-updated.ts
ui/ ui/
index.ts # re-exports query builders (Plan 9 — apps import from @repo/blog/ui)
query.ts # trpc.blog.articleBySlug.queryOptions(...) query.ts # trpc.blog.articleBySlug.queryOptions(...)
query.test.ts
article-client.tsx
article-client.test.tsx
page.tsx
index.ts # re-exports ui components + public types __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/ tests/
article-by-slug.feature.test.ts # cross-layer feature test article-by-slug.feature.test.ts # cross-layer integration test
package.json # exports: ".", "./cms", "./api" package.json # exports: ".", "./ui", "./api", "./cms", "./di/bind-production"
tsconfig.json tsconfig.json
turbo.json # tags: ["feature"] turbo.json # tags: ["feature"]
``` ```
@@ -228,33 +238,47 @@ Small-feature variant (e.g., `packages/navigation/`) omits folders without meani
``` ```
packages/navigation/ packages/navigation/
src/ src/
entities/ nav.ts entities/
infrastructure/repositories/ payload-navigation.repository.ts models/ header.ts
di/ symbols.ts module.ts container.ts errors/ header.ts common.ts
interface-adapters/controllers/ navigation.controller.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/ integrations/
cms/ globals/ header.ts + index.ts cms/ globals/ header.ts + index.ts
api/ router.ts api/ procedures.ts router.ts router.test.ts index.ts
ui/ query.ts ui/ index.ts query.ts
index.ts index.ts
``` ```
No `application/use-cases/`, `effects/`, `jobs/`, `events/` unless the feature grows them. No `effects/`, `jobs/`, `events/` unless the feature grows them.
**Request flow:** **Request flow:**
``` ```
useQuery(articleQuery(slug)) ui/query.ts (typed tRPC client) useQuery(articleBySlugQuery({ slug })) ui/index.ts (typed tRPC client, via @repo/blog/ui)
tRPC router.articleBySlug integrations/api/router.ts tRPC router.articleBySlug integrations/api/router.ts
.input(zod).query(...) blogProcedure has defineErrorMiddleware applied
articlesController.getBySlug(input) interface-adapters/controllers/ ↓ .input(getArticleBySlugInputSchema)
↓ safeParse → delegate articlesController.getBySlug(input: unknown) interface-adapters/controllers/
getArticleUseCase(slug) application/use-cases/ getArticleBySlugInputSchema.safeParse(input)
getInjection(BLOG_SYMBOLS.IArticlesRepository) throws InputParseError on failure
PayloadArticlesRepository.getBySlug infrastructure/repositories/ (@injectable) ↓ 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 ↓ getPayload({ config }) from @repo/core-cms
Payload Local API → PostgreSQL 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. **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.
@@ -477,28 +501,49 @@ Tags govern architectural boundaries; `dependsOn: ["^build"]` governs task execu
### 10.3 DI in tests (per-feature container) ### 10.3 DI in tests (per-feature container)
Each feature's tests import the feature's own container and rebind per test: **Default (use case + controller tests) — direct factory injection.** Construct mock dependencies and pass them into the factory function. No container involvement:
```ts ```ts
import { blogContainer, BLOG_SYMBOLS } from '../../di/container' // Use case test — direct factory injection (Plan 8 / ADR-012)
import { MockArticlesRepository } from '../../infrastructure/repositories/mock-articles.repository' 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:
```ts
// Router test (only here is container rebinding still appropriate)
beforeEach(() => { beforeEach(() => {
blogContainer.unbindAll() if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository) blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}) }
blogContainer.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(new MockArticlesRepository());
});
``` ```
No shared `initializeContainer()` / `destroyContainer()`. No shared `initializeContainer()` / `destroyContainer()`.
### 10.4 Starter test coverage (end of refactor) ### 10.4 Actual test coverage (post-Plan-9)
- `core-shared`: 3 tests (slug-field, set-published-at, is-admin) After Plan 8 (Lazar conformance) and Plan 9 (I/O unification + presenter + error middleware):
- `auth`: 6 tests (3 controllers + 3 use-cases) — replaces current auth unit tests
- `blog`: 3 tests (2 use-cases + 1 feature test for `articleBySlug`)
- `marketing-pages`, `navigation`, `media`: minimum one feature test each
Total ~15 unit/integration tests + 4 e2e = replaces current 9 tests with full new layout. - **360 tests across 26 packages** (`pnpm test` green as of 2026-05-06)
- Plan 8 grew the suite from 244 → 325 tests (+81, +33%) — factory refactor + media scaffold
- Plan 9 grew the suite from 325 → 360 tests (+35, +11%) — R25 output-validation tests + R26 router error-mapping tests + R27/R28 presenter shape tests
Key coverage areas added in these plans:
- R25 (output-validation): every non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repository data
- R26 (router error-mapping): every feature has a router test asserting domain error → correct `TRPCError.code` translation
- R27/R28 (presenter shape): `auth` sign-in/sign-up controllers assert the presenter-reshaped view (cookie, not full session object)
Cross-reference: Plan 8 refactor log Summary at `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md` and Plan 9 refactor log Summary at `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`.
### 10.5 Playwright (included from day one) ### 10.5 Playwright (included from day one)
@@ -513,6 +558,21 @@ Total ~15 unit/integration tests + 4 e2e = replaces current 9 tests with full ne
- ESLint config adds `eslint-plugin-playwright` for e2e folders - ESLint config adds `eslint-plugin-playwright` for e2e folders
- Playwright's `globalSetup` verifies Postgres is running; fails fast with a helpful message otherwise - Playwright's `globalSetup` verifies Postgres is running; fails fast with a helpful message otherwise
### 10.6 Test obligations per layer (Plan 9)
Every new use case and controller is expected to satisfy these rules. The rule IDs correspond to the spec `docs/superpowers/specs/2026-05-06-input-output-unification-design.md` §3.
| Rule | Description | Layer | Where the test lives |
|---|---|---|---|
| R10 | Controller input must be typed `unknown`; schema is the gate | `interface-adapters/controllers/` | `*.controller.test.ts` — assert `InputParseError` on invalid input |
| R24 | Use-case + controller tests use direct factory injection; no `container.unbind/bind` | `application/use-cases/` + `interface-adapters/controllers/` | `*.use-case.test.ts`, `*.controller.test.ts` |
| R25 | Non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repo data | `application/use-cases/` | `*.use-case.test.ts` |
| R26 | Every feature has a router test asserting domain error → expected `TRPCError.code` | `integrations/api/` | `router.test.ts` — call via `xRouter.createCaller({})` and assert `TRPCError.code` |
| R27 | When presenter strips/renames/transforms, controller test asserts the resulting view shape | `interface-adapters/controllers/` | `*.controller.test.ts` — assert omitted fields absent, transformed fields present |
| R28 | When controller has a non-identity presenter, tests assert the *view* shape (not `XOutput`) | `interface-adapters/controllers/` | `*.controller.test.ts` — catches regressions where presenter short-circuits to identity |
Identity presenters do not require R27/R28 tests. Void-output controllers (e.g., `signOutController`, `deleteMediaController`) are exempt from R11 (presenter), R25, R27, and R28.
--- ---
## 11. Docs + ADR strategy ## 11. Docs + ADR strategy
@@ -558,6 +618,13 @@ All rewritten:
Root `CLAUDE.md` — updated "Read First" pointers, unchanged port table, added boundary-enforcement note. Root `CLAUDE.md` — updated "Read First" pointers, unchanged port table, added boundary-enforcement note.
### 11.6 Post-spec ADRs (Plans 8 + 9)
Two additional ADRs were added after the initial vertical-feature refactor and now form part of the permanent decision record:
- `adr-012-lazar-conformance.md` — Plan 8: factory-function use cases + controllers, one-per-use-case controllers, Lazar file-layout conventions, real Payload implementations for `auth`, full `media` scaffold. Accepts the pattern with four intentional divergences (inversify retained, per-feature DI containers, colocated tests, no Sentry wrapping).
- `adr-013-input-output-unification.md` — Plan 9: use-case file is the single source of truth for `xInputSchema`/`xOutputSchema`; runtime output validation (`xOutputSchema.parse(result)`); co-located `function presenter` in every non-void controller; per-feature `procedures.ts` for domain error → `TRPCError` mapping via `defineErrorMiddleware` from `core-shared`; `./ui` subpath separates UI artifacts from contracts.
--- ---
## 12. Migration sequencing ## 12. Migration sequencing