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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user