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,77 +150,87 @@ repo/
## 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/
src/
config.ts # constants if needed
entities/
article.ts # Zod schema + Article type
article.test.ts
errors.ts
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
articles.repository.interface.ts # IArticlesRepository
use-cases/
get-article.use-case.ts
get-article.use-case.test.ts
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/
payload-articles.repository.ts # @injectable, calls getPayload({ config }) from core-cms
mock-articles.repository.ts # @injectable, for tests
payload-articles.repository.test.ts
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)
interface-adapters/ # Clean Arch grouping (controllers now; presenters/gateways later)
controllers/
articles.controller.ts # Zod safeParse → InputParseError → use case
articles.controller.test.ts
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
container.ts # blogContainer + getInjection<T>()
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/
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
articles.ts # Payload CollectionConfig
hooks/
after-post-change.ts # Payload lifecycle adapter → calls effects
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
<lifecycle-hook>.ts # if needed
index.ts # exports: articles (for core-cms composition)
ui/
query.ts # trpc.blog.articleBySlug.queryOptions(...)
query.test.ts
article-client.tsx
article-client.test.tsx
page.tsx
index.ts # re-exports query builders (Plan 9 — apps import from @repo/blog/ui)
query.ts # trpc.blog.articleBySlug.queryOptions(...)
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/
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
turbo.json # tags: ["feature"]
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"):
@@ -228,33 +238,47 @@ Small-feature variant (e.g., `packages/navigation/`) omits folders without meani
```
packages/navigation/
src/
entities/ nav.ts
infrastructure/repositories/ payload-navigation.repository.ts
di/ symbols.ts module.ts container.ts
interface-adapters/controllers/ navigation.controller.ts
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/ router.ts
ui/ query.ts
api/ procedures.ts router.ts router.test.ts index.ts
ui/ index.ts query.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:**
```
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
.input(zod).query(...)
articlesController.getBySlug(input) interface-adapters/controllers/
↓ safeParse → delegate
getArticleUseCase(slug) application/use-cases/
getInjection(BLOG_SYMBOLS.IArticlesRepository)
PayloadArticlesRepository.getBySlug infrastructure/repositories/ (@injectable)
↓ getPayload({ config }) from @repo/core-cms
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.
@@ -477,28 +501,49 @@ Tags govern architectural boundaries; `dependsOn: ["^build"]` governs task execu
### 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
import { blogContainer, BLOG_SYMBOLS } from '../../di/container'
import { MockArticlesRepository } from '../../infrastructure/repositories/mock-articles.repository'
// 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:
```ts
// Router test (only here is container rebinding still appropriate)
beforeEach(() => {
blogContainer.unbindAll()
blogContainer.bind(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository)
})
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 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)
- `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
After Plan 8 (Lazar conformance) and Plan 9 (I/O unification + presenter + error middleware):
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)
@@ -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
- 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
@@ -558,6 +618,13 @@ All rewritten:
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