docs(adr): rename ADR-012 — drop Lazar; update title + content + cross-refs

- Rename docs/decisions/adr-012-lazar-conformance.md → adr-012-feature-conventions.md
- Strip "Lazar", "Plan 8/9/10/11", "refactor-logs" refs from all ADRs,
  architecture docs, HTML explainers, and feature/core AGENTS.md files
- Update all incoming links in docs/, packages/*/AGENTS.md, HTML explainers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 10:07:37 +02:00
parent 06da37f723
commit 841655573b
18 changed files with 420 additions and 435 deletions

View File

@@ -1401,7 +1401,7 @@ footer .colophon {
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.32s ease; }
/* ─── §06 Tracing & error capture (Plan 10) ─────────────────────────── */
/* ─── §06 Tracing & error capture ───────────────────────────────────── */
.trace-h3 {
font-family: "Fraunces", serif;
@@ -2533,7 +2533,7 @@ const wrappedCtrl = withSpan(
<div class="badge-row">
<div class="badge"><strong>360 tests</strong>across 15 suites · contracts run 2× per repo</div>
<div class="badge"><strong>R25 + R26</strong>output-validation + error-mapping (Plan 9)</div>
<div class="badge"><strong>output-validation + error-mapping</strong>use-case schemas + router middleware</div>
<div class="badge"><strong>defineFactory · defineContractSuite</strong>both live in @repo/core-testing</div>
</div>
</div>
@@ -2558,14 +2558,9 @@ const wrappedCtrl = withSpan(
<h5>Decisions</h5>
<a href="../decisions/adr-008-per-feature-di-containers.md">adr-008 · per-feature DI</a>
<a href="../decisions/adr-011-tdd-foundation.md">adr-011 · TDD foundation</a>
<a href="../decisions/adr-012-lazar-conformance.md">adr-012 · Lazar conformance</a>
<a href="../decisions/adr-012-feature-conventions.md">adr-012 · feature conventions</a>
<a href="../decisions/adr-013-input-output-unification.md">adr-013 · I/O unification</a>
</div>
<div class="foot-col">
<h5>Refactor logs</h5>
<a href="../superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md">2026-05-05 · Plan 8</a>
<a href="../superpowers/refactor-logs/2026-05-06-input-output-unification.md">2026-05-06 · Plan 9</a>
</div>
<div class="foot-col">
<h5>Guides</h5>
<a href="../guides/adding-a-feature.md">adding-a-feature.md</a>
@@ -2585,13 +2580,13 @@ const LAYERS = {
entities: {
tag: "domain",
title: "Entities — the <em>nouns</em>",
body: "Pure domain shapes with zero outside knowledge. Every model is a Zod schema with a co-located <code>z.infer</code> type. Errors live one folder over and set <code>this.name = \"&lt;ClassName&gt;\"</code> in the constructor — that's R6 from Plan 9.",
body: "Pure domain shapes with zero outside knowledge. Every model is a Zod schema with a co-located <code>z.infer</code> type. Errors live one folder over and set <code>this.name = \"&lt;ClassName&gt;\"</code> in the constructor.",
meta: "Examples · <strong>Article, User, Cookie, Page, Header, Media · ArticleNotFoundError, AuthenticationError, InputParseError</strong>"
},
application: {
tag: "rules",
title: "Application — the <em>verbs</em>",
body: "Use cases live here, plus the interfaces they consume. After Plan 9 every use case is a factory: <code>(deps) ⇒ async (input) ⇒ result</code>. The file owns its <code>xInputSchema</code>, its <code>xOutputSchema</code>, and the runtime <code>.parse(...)</code> call before returning. Repository and service interfaces sit next door, blissfully unaware of who implements them.",
body: "Use cases live here, plus the interfaces they consume. Every use case is a factory: <code>(deps) ⇒ async (input) ⇒ result</code>. The file owns its <code>xInputSchema</code>, its <code>xOutputSchema</code>, and the runtime <code>.parse(...)</code> call before returning. Repository and service interfaces sit next door, blissfully unaware of who implements them.",
meta: "Examples · <strong>getArticlesUseCase, signInUseCase, deleteMediaUseCase</strong> · IArticlesRepository, IAuthenticationService"
},
infrastructure: {
@@ -2603,7 +2598,7 @@ const LAYERS = {
adapters: {
tag: "transport boundary",
title: "Interface adapters — <em>controllers</em>",
body: "One controller per use case (Lazar's rule). It takes <code>unknown</code> input, runs <code>safeParse</code> against the use-case's schema, throws <code>InputParseError</code> on failure, calls the use case, then runs the result through a top-level <code>function presenter(value)</code> defined in the same file. Identity is fine — <code>return value;</code> — but the function form always exists so adding a transform later is one edit.",
body: "One controller per use case. It takes <code>unknown</code> input, runs <code>safeParse</code> against the use-case's schema, throws <code>InputParseError</code> on failure, calls the use case, then runs the result through a top-level <code>function presenter(value)</code> defined in the same file. Identity is fine — <code>return value;</code> — but the function form always exists so adding a transform later is one edit.",
meta: "Examples · <strong>getArticlesController · signInController · deleteMediaController</strong> (void return — no presenter)"
},
di: {
@@ -2621,13 +2616,13 @@ const LAYERS = {
integrations: {
tag: "outside world",
title: "Integrations — <em>tRPC + CMS</em>",
body: "Where the feature meets the framework. <code>integrations/api/procedures.ts</code> (Plan 9) builds an <code>xProcedure</code> with <code>defineErrorMiddleware</code> applied — it owns the feature's domain-error → <code>TRPCError</code> mapping. <code>router.ts</code> uses that procedure plus <code>.input(xInputSchema)</code> imported from the use case. <code>cms/</code> exports Payload collection / global definitions consumed by <code>core-cms</code>.",
body: "Where the feature meets the framework. <code>integrations/api/procedures.ts</code> builds an <code>xProcedure</code> with <code>defineErrorMiddleware</code> applied — it owns the feature's domain-error → <code>TRPCError</code> mapping. <code>router.ts</code> uses that procedure plus <code>.input(xInputSchema)</code> imported from the use case. <code>cms/</code> exports Payload collection / global definitions consumed by <code>core-cms</code>.",
meta: "api/procedures.ts · api/router.ts · cms/collections/&lt;x&gt;.ts · cms/globals/&lt;x&gt;.ts"
},
ui: {
tag: "frontend surface",
title: "UI — <em>queries &amp; components</em>",
body: "Plan 9 split the public surface: feature root (<code>@repo/blog</code>) exports <em>contracts only</em> — types, errors, schemas, IUseCase aliases, router type, constants. <code>./ui</code> exports React Query option builders and (eventually) components. Apps import schemas from the root for forms, queries from <code>./ui</code> for hooks. No mixing.",
body: "Feature root (<code>@repo/blog</code>) exports <em>contracts only</em> — types, errors, schemas, IUseCase aliases, router type, constants. <code>./ui</code> exports React Query option builders and (eventually) components. Apps import schemas from the root for forms, queries from <code>./ui</code> for hooks. No mixing.",
meta: "ui/index.ts · ui/query.ts · @repo/&lt;feature&gt;/ui subpath in package.json"
},
testing: {
@@ -2898,7 +2893,7 @@ ${F.errorMap.map(([err, code]) => ` [${err}, "${code}"],`).join("\n")}
file: `packages/${F.name}/src/${useCaseFile}`,
tag: "business logic",
prose: F.outputSchemaName ?
`The use case is a curried factory — <code>(deps) ⇒ async (input) ⇒ result</code>. It calls the repository, then runs <code>${F.outputSchemaName}.parse(result)</code> before returning. That last <code>.parse</code> is a Plan 9 contract: the use case <em>guarantees</em> what comes out, so a misbehaving repo fails loudly here, not silently downstream.` :
`The use case is a curried factory — <code>(deps) ⇒ async (input) ⇒ result</code>. It calls the repository, then runs <code>${F.outputSchemaName}.parse(result)</code> before returning. That last <code>.parse</code> is the output contract: the use case <em>guarantees</em> what comes out, so a misbehaving repo fails loudly here, not silently downstream.` :
`Void-output use case: takes input, calls the repository, returns nothing. No <code>xOutputSchema</code> means no <code>parse</code> at the end — there's nothing to validate. The presenter rule (R11) carves out an exception for these: void in, void out, no presenter.`,
code: F.outputSchemaName ?
`export const ${F.useCase}UseCase =

View File

@@ -27,12 +27,12 @@
core → core, core-composition, tooling
core-composition → core, core-composition, feature, tooling
tooling → tooling
Composition exceptions:
core-api → @repo/<feature>/api (subpath only)
core-cms → @repo/<feature>/cms (subpath only)
App-side feature subpaths (Plan 9):
App-side feature subpaths:
@repo/<feature> — contracts (types, errors, schemas, IUseCase aliases, router type, constants)
@repo/<feature>/ui — UI artifacts (query builders, components)
```
@@ -40,46 +40,48 @@
## Concrete examples
Allowed:
```ts
// in apps/web-next
import { appRouter } from "@repo/core-api";
import { NextTrpcProvider } from "@repo/core-trpc/next";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { signInInputSchema, type SignInInput } from "@repo/auth"; // contracts (Plan 9)
import { articleBySlugQuery } from "@repo/blog/ui"; // queries (Plan 9)
import { signInInputSchema, type SignInInput } from "@repo/auth"; // contracts
import { articleBySlugQuery } from "@repo/blog/ui"; // queries
// in packages/blog
import { slugifyIfMissing } from "@repo/core-shared/payload";
// in packages/core-api
import { blogRouter } from "@repo/blog/api"; // composition exception
import { blogRouter } from "@repo/blog/api"; // composition exception
import { router } from "@repo/core-shared/trpc/init"; // core → core fine
// in packages/core-cms
import { articles } from "@repo/blog/cms"; // composition exception
import { articles } from "@repo/blog/cms"; // composition exception
```
Disallowed:
```ts
// in packages/blog (cross-feature)
import { Article } from "@repo/marketing-pages"; // ❌ feature → feature
import { Article } from "@repo/marketing-pages"; // ❌ feature → feature
// in packages/blog (deep import past public exports)
import { articles } from "@repo/blog/src/integrations/cms/collections/articles"; // ❌ no-private
// in packages/core-shared
import { blogRouter } from "@repo/blog/api"; // ❌ core → feature
import { ArticleNotFoundError } from "@repo/blog"; // ❌ core → feature
// (defineErrorMiddleware takes Error
// constructors as args from features —
// core-shared never imports them)
import { blogRouter } from "@repo/blog/api"; // ❌ core → feature
import { ArticleNotFoundError } from "@repo/blog"; // ❌ core → feature
// (defineErrorMiddleware takes Error
// constructors as args from features —
// core-shared never imports them)
// in packages/core-shared (or any non-composition core package)
import { someBlogThing } from "@repo/blog"; // ❌ core → feature (only core-api/core-cms have exception)
import { someBlogThing } from "@repo/blog"; // ❌ core → feature (only core-api/core-cms have exception)
// in apps (using the wrong subpath)
import { articleBySlugQuery } from "@repo/blog"; // ❌ queries live on ./ui
import { Article } from "@repo/blog/ui"; // ❌ types live on the root subpath
import { articleBySlugQuery } from "@repo/blog"; // ❌ queries live on ./ui
import { Article } from "@repo/blog/ui"; // ❌ types live on the root subpath
```
## Enforcement strategy
@@ -145,17 +147,17 @@ apps/web-next/src/server/bind-production.ts (bindAll)
**`BindContext` shape (from `@repo/core-shared/di`):**
| Field | Type | Required | Notes |
|---|---|---|---|
| `tracer` | `ITracer` | always | Resolved by Rule 0 (OTel+Sentry vs Noop) |
| `logger` | `ILogger` | always | Resolved by Rule 0 |
| `metrics` | `MetricsProtocol?` | optional | Resolved by Rule 0; per-feature adoption is opportunistic |
| `config` | `SanitizedConfig` | production only | Present in `BindProductionContext`, absent in `BindContext` |
| `bus` | `EventBusProtocol?` | optional | `IEventBus` at the aggregator; protocol surface at binders |
| `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired |
| `realtime` | `RealtimeBroadcasterProtocol?` | optional | `IRealtimeBroadcaster` at the aggregator |
| `realtimeRegistry` | `RealtimeRegistryProtocol?` | optional | `IRealtimeHandlerRegistry` at the aggregator |
| `auditLog` | `AuditLogProtocol?` | optional | `IAuditLog` at the aggregator; pre-wrapped in `TraceIdEnrichingAuditLog` so callers don't supply `correlationId` (ADR-018) |
| Field | Type | Required | Notes |
| ------------------ | ------------------------------ | --------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `tracer` | `ITracer` | always | Resolved by Rule 0 (OTel+Sentry vs Noop) |
| `logger` | `ILogger` | always | Resolved by Rule 0 |
| `metrics` | `MetricsProtocol?` | optional | Resolved by Rule 0; per-feature adoption is opportunistic |
| `config` | `SanitizedConfig` | production only | Present in `BindProductionContext`, absent in `BindContext` |
| `bus` | `EventBusProtocol?` | optional | `IEventBus` at the aggregator; protocol surface at binders |
| `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired |
| `realtime` | `RealtimeBroadcasterProtocol?` | optional | `IRealtimeBroadcaster` at the aggregator |
| `realtimeRegistry` | `RealtimeRegistryProtocol?` | optional | `IRealtimeHandlerRegistry` at the aggregator |
| `auditLog` | `AuditLogProtocol?` | optional | `IAuditLog` at the aggregator; pre-wrapped in `TraceIdEnrichingAuditLog` so callers don't supply `correlationId` (ADR-018) |
Feature binders destructure `ctx` and use optional fields with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. `bus as IEventBus` when a use case always needs the event bus).

View File

@@ -641,7 +641,7 @@ footer .colophon {
font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em;
}
/* ─── §08 Instrumentation symbols (Plan 10) ──────────────────────── */
/* ─── §08 Instrumentation symbols ──────────────────────────────── */
.instrumentation-grid {
display: grid;
@@ -1164,7 +1164,7 @@ footer .colophon {
</div>
</section>
<!-- ─── 08 ─ INSTRUMENTATION SYMBOLS (Plan 10) ────────────────────────── -->
<!-- ─── 08 ─ INSTRUMENTATION SYMBOLS ──────────────────────────────────── -->
<section id="instrumentation">
<div class="section-head">
<div class="section-num">§ 08</div>
@@ -1245,7 +1245,7 @@ footer .colophon {
<div>
<h5>Decisions</h5>
<a href="../decisions/adr-008-per-feature-di-containers.md">adr-008 · per-feature DI</a>
<a href="../decisions/adr-012-lazar-conformance.md">adr-012 · Lazar conformance</a>
<a href="../decisions/adr-012-feature-conventions.md">adr-012 · feature conventions</a>
<a href="../decisions/adr-013-input-output-unification.md">adr-013 · I/O unification</a>
</div>
<div>

View File

@@ -67,21 +67,21 @@ as arguments and return the callable. The container wires them via
`I*Controller`) so consumers can depend on the type without importing the impl.
Controllers are **one per use case** — no multi-method controller files.
**Schemas live in the use-case file** (Plan 9): every use case exports
**Schemas live in the use-case file**: every use case exports
`xInputSchema` (a `z.ZodObject` with `.strict()`; `z.object({}).strict()` for
void inputs) and, for non-void use cases, `xOutputSchema`. Controllers and tRPC
procedures import the schema — never redefine it. The use case body validates
its output via `xOutputSchema.parse(...)` before returning, so a misbehaving
repository fails loudly at the layer that owns the contract.
**Controllers reshape via a co-located presenter** (Plan 9): every non-void
**Controllers reshape via a co-located presenter**: every non-void
controller defines a top-level `function presenter(value: XOutput)` and returns
`Promise<ReturnType<typeof presenter>>`. Identity is fine — `return value;`
but the function form is always present so adding a transform later is a
one-line edit. Void controllers (e.g. `signOutController`,
`deleteMediaController`) return `Promise<void>` and skip the presenter.
**Domain errors map to `TRPCError` per feature** (Plan 9): each feature owns
**Domain errors map to `TRPCError` per feature**: each feature owns
`integrations/api/procedures.ts` exporting `xProcedure = t.procedure.use(
defineErrorMiddleware([[Ctor, "TRPC_CODE"], ...]))`. Routers use `xProcedure`
instead of bare `publicProcedure`. `core-shared` provides the
@@ -112,15 +112,16 @@ See `docs/architecture/template-tiers.md` for the must-have/optional split and t
**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 |
| 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 |
**Composition exceptions:**
- `core-api` may import from `@repo/<feature>/api` subpath exports only
- `core-cms` may import from `@repo/<feature>/cms` subpath exports only
- `core-trpc` reaches features transitively through `core-api`'s `AppRouter` type

View File

@@ -1,6 +1,6 @@
# Vertical Feature Architecture Spec
> **Source of truth.** Copied from `docs/superpowers/specs/2026-04-21-vertical-monorepo-refactor-design.md` for in-tree reference. Edits here should be backported to the design spec.
> **Architecture reference.** This document is the canonical design spec for the vertical-feature architecture.
---
@@ -17,7 +17,7 @@
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.
The refactor preserves Clean Architecture layering _inside_ each feature (the existing rigor) while reorganizing _between_ packages by business capability.
---
@@ -55,22 +55,23 @@ The refactor preserves Clean Architecture layering *inside* each feature (the ex
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 |
| # | 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/` to `integrations/`
@@ -138,7 +139,7 @@ repo/
guides/
adding-a-feature.md # rewritten
testing-strategy.md # rewritten
superpowers/specs/ # this file + implementation plan
work/ # epic + story tracking
CLAUDE.md AGENTS.md docker-compose.yml package.json pnpm-lock.yaml
pnpm-workspace.yaml tsconfig.base.json turbo.json
@@ -150,7 +151,7 @@ repo/
## 6. Feature package internal shape
Canonical mature shape (e.g., `packages/blog/`)**post-Plan-9 layout**:
Canonical mature shape (e.g., `packages/blog/`):
```
packages/blog/
@@ -198,13 +199,13 @@ packages/blog/
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 (post-Plan-9)
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([...])) — Plan 9
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
@@ -216,7 +217,7 @@ packages/blog/
index.ts # exports: articles (for core-cms composition)
ui/
index.ts # re-exports query builders (Plan 9 — apps import from @repo/blog/ui)
index.ts # re-exports query builders (apps import from @repo/blog/ui)
query.ts # trpc.blog.articleBySlug.queryOptions(...)
__factories__/
@@ -226,7 +227,7 @@ packages/blog/
articles-repository.contract.ts # repo interface contract suite (Plan 7)
__seeds__/
dev.ts # buildDev<Entities>() — uses factory; consumed by bind-dev-seed (post-Plan-9)
dev.ts # buildDev<Entities>() — uses factory; consumed by bind-dev-seed
index.ts # contracts only: types, errors, schemas, IUseCase/IController aliases, router type, constants
@@ -383,37 +384,38 @@ 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` | |
| 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`:
```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'
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,
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 in `core-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's `effects/` for reusable side effects.
@@ -425,25 +427,25 @@ export default buildConfig({
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` |
| 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 |
| 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
@@ -454,7 +456,7 @@ Note: `core-trpc` is `core-composition` (not plain `core`) because it transitive
### 9.4 Four enforcement layers
1. **`package.json` dependencies** — only allowed deps declared.
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production` only (no deep source paths). `./ui` was added in Plan 9; `./di/bind-production` was added in Plan 5.
2. **`exports` maps** — feature packages expose `.`, `./ui`, `./cms`, `./api`, `./di/bind-production` only (no deep source paths).
3. **ESLint `eslint-plugin-boundaries`** (lint-time) — configured in `packages/core-eslint/` flat config:
- Enforces the five-tag rules (same rules as Turbo boundaries)
- File-specific exemptions via `// @boundaries-ignore` comments
@@ -468,11 +470,11 @@ Note: `core-trpc` is `core-composition` (not plain `core`) because it transitive
```json
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] },
"lint": { "dependsOn": ["^lint"] },
"build": { "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] },
"lint": { "dependsOn": ["^lint"] },
"typecheck": { "dependsOn": ["^typecheck"] },
"test": { "dependsOn": ["^build"] },
"test:e2e": { "dependsOn": ["^build"], "cache": false }
"test": { "dependsOn": ["^build"] },
"test:e2e": { "dependsOn": ["^build"], "cache": false }
}
}
```
@@ -485,18 +487,18 @@ Tags govern architectural boundaries; `dependsOn: ["^build"]` governs task execu
### 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` |
| 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
@@ -509,7 +511,7 @@ Tags govern architectural boundaries; `dependsOn: ["^build"]` governs task execu
**Default (use case + controller tests) — direct factory injection.** Construct mock dependencies and pass them into the factory function. No container involvement:
```ts
// Use case test — direct factory injection (Plan 8 / ADR-012)
// Use case test — direct factory injection (ADR-012)
const repo = new MockArticlesRepository();
const useCase = getArticleBySlugUseCase(repo);
const result = await useCase({ slug: "hello-world" });
@@ -529,26 +531,23 @@ beforeEach(() => {
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
}
blogContainer.bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).toConstantValue(new MockArticlesRepository());
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):
### 10.4 Actual test coverage
- **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)
Key coverage areas:
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`.
- Output-validation: every non-void use case has a test asserting `xOutputSchema.parse` throws on malformed repository data
- Router error-mapping: every feature has a router test asserting domain error → correct `TRPCError.code` translation
- Presenter shape: `auth` sign-in/sign-up controllers assert the presenter-reshaped view (cookie, not full session object)
### 10.5 Playwright (included from day one)
@@ -563,18 +562,18 @@ Cross-reference: Plan 8 refactor log Summary at `docs/superpowers/refactor-logs/
- 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)
### 10.6 Test obligations per layer
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.
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 |
| 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.
@@ -584,13 +583,13 @@ Identity presenters do not require R27/R28 tests. Void-output controllers (e.g.,
### 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 |
| 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
@@ -623,12 +622,12 @@ All rewritten:
Root `CLAUDE.md` — updated "Read First" pointers, unchanged port table, added boundary-enforcement note.
### 11.6 Post-spec ADRs (Plans 8 + 9)
### 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-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.
- `adr-012-feature-conventions.md` factory-function use cases + controllers, one-per-use-case controllers, canonical 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` — 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.
---
@@ -636,19 +635,19 @@ Two additional ADRs were added after the initial vertical-feature refactor and n
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 |
| # | 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.
@@ -687,7 +686,7 @@ Invoke the `superpowers:writing-plans` skill to produce a detailed, executable i
## 16. Instrumentation & error capture (ADR-014, ADR-017)
**Spec:** `docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md` (R31R55 interfaces); `docs/decisions/adr-017-opentelemetry-migration.md` (OTel substrate, supersedes ADR-014 impl section).
**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.

View File

@@ -15,7 +15,7 @@ InversifyJS with symbol-based resolution + targeted agent documentation.
- Scales to 20+ services with automatic dependency chain resolution
- Built-in singleton/transient/request scopes
- Middleware support for logging/tracing (Sentry integration)
- Matches the reference implementation by Lazar Nikolov
- Matches the Clean Architecture reference implementation
- Agent readability gap (4/10 → 7/10) mitigated by resolution tables and step-by-step recipes in di/AGENTS.md
- Familiar to developers from Java/C# backgrounds

View File

@@ -1,19 +1,15 @@
# ADR-012: Lazar Nikolov Pattern Conformance
# ADR-012: Feature Conventions
**Status:** Accepted
**Date:** 2026-05-05
**Supersedes:** none — extends ADR-006 (vertical-feature-packages) and ADR-008 (per-feature DI containers)
**Spec:** `docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md`
**Plan:** `docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md`
**Refactor log:** `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`
## Context
The vertical-feature monorepo refactor (ADRs 001-010) and the TDD
foundation (ADR-011) established Clean Architecture per feature, but
the per-layer code shape diverged from the canonical reference
implementation by Lazar Nikolov
([nikolovlazar/nextjs-clean-architecture](https://github.com/nikolovlazar/nextjs-clean-architecture)).
the per-layer code shape was inconsistent across features and needed
to be standardized.
Specifically, before this ADR:
@@ -34,8 +30,8 @@ Specifically, before this ADR:
## Decision
Bring every feature into structural conformance with Lazar's reference
pattern, with four intentional divergences (§Adaptations below).
Bring every feature into structural conformance with the canonical
Clean Architecture pattern, with four intentional divergences (§Adaptations below).
### What we adopted
@@ -60,17 +56,21 @@ pattern, with four intentional divergences (§Adaptations below).
`get-articles.controller.ts`, `delete-media.controller.ts`.
5. **InversifyJS `.toDynamicValue()` for factory bindings:**
```typescript
bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase).toDynamicValue((ctx) =>
signInUseCase(
ctx.container.get<IUsersRepository>(AUTH_SYMBOLS.IUsersRepository),
ctx.container.get<IAuthenticationService>(AUTH_SYMBOLS.IAuthenticationService),
ctx.container.get<IAuthenticationService>(
AUTH_SYMBOLS.IAuthenticationService,
),
),
);
```
6. **Direct injection in tests** — construct mocks and pass them into the
factory; no container rebinding for unit/use-case/controller tests:
```typescript
const users = new MockUsersRepository();
const auth = new MockAuthenticationService(users);
@@ -81,21 +81,21 @@ pattern, with four intentional divergences (§Adaptations below).
for `auth`** — previously only mocks existed. Some methods on
`AuthenticationService` (session create/validate/invalidate) are
deferred behind `NotImplementedError` until the cookie-strategy
decision is finalized; see refactor log §7.
decision is finalized.
8. **`media` is now a complete Clean Architecture feature** — entities,
application, infrastructure, interface-adapters, DI, integrations/api,
factories, contract, feature test. Previously it was just a Payload
collection.
### Intentional divergences from the reference (kept from prior ADRs)
### Intentional divergences (kept from prior ADRs)
| Aspect | Reference | Ours | Reason |
|---|---|---|---|
| DI library | `@evyweb/ioctopus` | `inversify` | Already integrated; equivalent expressive power via `.toDynamicValue()`. |
| DI scope | One global `ApplicationContainer` | One per feature (`authContainer`, `blogContainer`, …) | Vertical-feature isolation (ADR-008). |
| Test placement | `tests/unit/...` mirror | Colocated `*.test.{ts,tsx}` | Established by ADR-011 / Plan 7; clearer per-file ownership. |
| Instrumentation | Sentry/observability service wrapping | Not adopted | Out of scope; revisit when observability becomes a requirement. |
| Aspect | Community default | Ours | Reason |
| --------------- | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------ |
| DI library | `@evyweb/ioctopus` | `inversify` | Already integrated; equivalent expressive power via `.toDynamicValue()`. |
| DI scope | One global `ApplicationContainer` | One per feature (`authContainer`, `blogContainer`, …) | Vertical-feature isolation (ADR-008). |
| Test placement | `tests/unit/...` mirror | Colocated `*.test.{ts,tsx}` | Established by ADR-011 / Plan 7; clearer per-file ownership. |
| Instrumentation | Sentry/observability service wrapping | Not adopted | Out of scope; revisit when observability becomes a requirement. |
`InputParseError` is also duplicated per feature (~6 lines × 5
features) instead of sharing a global class — feature independence
@@ -113,8 +113,8 @@ beats DRY for a class this small.
- **Type aliases (`I*UseCase`/`I*Controller`) decouple consumers.**
The tRPC router and any other caller depends on the type, not the
factory impl.
- **Naming consistency** with widely-shared community pattern, lowering
ramp-up cost for engineers familiar with the reference.
- **Naming consistency** with widely-shared community convention, lowering
ramp-up cost for engineers familiar with Clean Architecture patterns.
- **Real auth + media** complete the architectural symmetry — every
feature now demonstrates the full layer stack.
@@ -149,9 +149,9 @@ beats DRY for a class this small.
for session methods is the better trade because it unblocks the
`auth` integration without forcing a premature cookie-strategy choice.
## Acceptance verification (Task 10, 2026-05-05)
## Acceptance criteria
- All 325 tests passing (was 244 pre-Plan-8; +81 net, +33%).
- All tests passing.
- `pnpm typecheck`, `pnpm lint`, `pnpm turbo boundaries` clean.
- No `entities/<x>.ts` files at root level.
- No `mock-*.ts` files in feature packages.
@@ -159,19 +159,14 @@ beats DRY for a class this small.
- Every use case has `export type I*UseCase = ReturnType<typeof ...>`.
- Every controller has `export type I*Controller = ReturnType<typeof ...>`.
See refactor log for full file-by-file inventory.
## References
- Spec: `docs/superpowers/specs/2026-05-05-lazar-pattern-conformance-design.md`
- Plan: `docs/superpowers/plans/2026-05-05-plan-8-lazar-conformance.md`
- Refactor log: `docs/superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md`
- Reference repo: https://github.com/nikolovlazar/nextjs-clean-architecture
- Prior ADRs: ADR-006 (vertical-feature-packages), ADR-008 (per-feature DI containers), ADR-011 (TDD foundation)
## Update — 2026-05-06
Plan 9 (ADR-013) further unifies the input/output schema story:
ADR-013 further unifies the input/output schema story:
schemas now live in the use-case file (a refinement of §What we
adopted #1's "factory-function use cases"); controllers gain a
co-located `function presenter` (extending §What we adopted #4's

View File

@@ -2,14 +2,11 @@
**Status:** Accepted
**Date:** 2026-05-06
**Supersedes:** none — extends ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
**Spec:** docs/superpowers/specs/2026-05-06-input-output-unification-design.md
**Plan:** docs/superpowers/plans/2026-05-06-plan-9-io-unification.md
**Refactor log:** docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md
**Supersedes:** none — extends ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (feature conventions)
## Context
Plan 8 (ADR-012) established factory-function use cases and one-controller-
ADR-012 established factory-function use cases and one-controller-
per-use-case. But the input contract was still defined three times — once
in the tRPC procedure's `.input(z.object({...}))`, once in the controller's
local `const inputSchema`, and once implicitly in the use case's TypeScript
@@ -44,7 +41,7 @@ spec:
failure, then call the use case and pass the result through a
top-level `function presenter(value: XOutput)` defined in the same
file. The controller's return type is `Promise<ReturnType<typeof
presenter>>`. Identity presenters are permitted and expected for
presenter>>`. Identity presenters are permitted and expected for
pass-through cases — the function form must always exist (R11) so
adding a transform is a one-line edit. Void-output controllers
(e.g., `signOutController`, `deleteMediaController`) skip the
@@ -53,7 +50,7 @@ spec:
3. **Feature-scoped error→TRPCError middleware.** Each feature's
`integrations/api/procedures.ts` exports an `xProcedure` built from
`t.procedure.use(defineErrorMiddleware([[ErrorCtor, "TRPC_CODE"],
...]))`. The factory `defineErrorMiddleware` lives in
...]))`. The factory `defineErrorMiddleware` lives in
`core-shared/trpc/`; it discriminates by `instanceof` and preserves
the original error as `TRPCError.cause`. **`core-shared` never
enumerates feature-specific error classes** — each feature passes its
@@ -87,7 +84,7 @@ spec:
- **Clean public surface.** Feature root packages no longer pretend to
be UI packages; apps make explicit choices about what they need.
- **Frontend gets schemas for free.** Forms can `import { signInInputSchema
} from "@repo/auth"` and feed it into `react-hook-form` + `zodResolver`
} from "@repo/auth"` and feed it into `react-hook-form` + `zodResolver`
with the same constraints the backend enforces.
### Negative
@@ -105,18 +102,18 @@ spec:
controller is invoked from non-tRPC entry points.
- **Apps with existing imports may need updating** — `articleBySlugQuery`,
`pageBySlugQuery`, etc. now live behind `@repo/<feature>/ui`.
(At Plan 9 land time, no apps consume these yet, so the cost is
(At the time of this ADR, no apps consume these yet, so the cost is
forward-only.)
## Alternatives considered
- **Keep schemas in controllers (Lazar's reference pattern).** Lazar
has only one validation layer (server actions skip `.input()`), so
one schema is sufficient. Our entry point is tRPC, which insists on
a schema for type inference — putting the canonical schema in the
controller and exporting it for the router was considered. Rejected
because the use case is the contract owner; schemas describe the
*operation*, not the *transport*.
- **Keep schemas in controllers.** The reference pattern has only one
validation layer (server actions skip `.input()`), so one schema is
sufficient. Our entry point is tRPC, which insists on a schema for
type inference — putting the canonical schema in the controller and
exporting it for the router was considered. Rejected because the use
case is the contract owner; schemas describe the _operation_, not the
_transport_.
- **Centralized error-name → code map in `core-shared`.** Considered
using `error.name` discrimination with a small global registry.
@@ -132,34 +129,29 @@ spec:
return is trivial and the bug-catching value at runtime is real
(Payload integrations have surprised us before).
- **Presenters only when reshaping.** Considered Lazar's actual rule
(presenter only when there's a transform). Rejected (R11) because
the discoverable hook for future shaping is worth the trivial
identity-function boilerplate.
- **Presenters only when reshaping.** Considered limiting presenters to
cases with actual transforms. Rejected because the discoverable hook
for future shaping is worth the trivial identity-function boilerplate.
- **Presenters in a separate `presenters/` folder.** Considered as a
concession to "controllers = thin orchestration". Rejected because
Lazar's reference co-locates the presenter with its consumer — the
controller — keeping the contract visible in one file.
co-locating the presenter with its controller keeps the contract
visible in one file.
- **Shared `./schemas` subpath.** Considered exposing schemas only via
a dedicated subpath instead of the feature root. Rejected because
schemas ARE feature contracts — they belong with the other contracts
(types, errors). Adding a fourth subpath felt like ceremony.
## Acceptance verification (Task 8, 2026-05-06)
## Acceptance criteria
- All Plan 9 acceptance criteria from spec §8 met.
- Tests: 360 total. Spec coverage: every R1R28 represented.
- Tests: 360 total. Coverage: every acceptance rule represented.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm turbo boundaries
&& pnpm build` green.
- Five feature-level R26 router-error-mapping tests demonstrate domain
&& pnpm build` green.
- Five feature-level router error-mapping tests demonstrate domain
error → `TRPCError.code` translation works end-to-end.
## References
- Spec: `docs/superpowers/specs/2026-05-06-input-output-unification-design.md`
- Plan: `docs/superpowers/plans/2026-05-06-plan-9-io-unification.md`
- Refactor log: `docs/superpowers/refactor-logs/2026-05-06-input-output-unification.md`
- Reference (Lazar's blog post + repo): https://github.com/nikolovlazar/nextjs-clean-architecture
- Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (Lazar conformance)
- Reference repo: https://github.com/nikolovlazar/nextjs-clean-architecture
- Prior ADRs: ADR-008 (per-feature DI), ADR-011 (TDD foundation), ADR-012 (feature conventions)

View File

@@ -1,10 +1,8 @@
# ADR-014 — Instrumentation & Sentry Logging
**Status:** Accepted
**Status (revised):** Superseded by ADR-017 for the implementation layer. The interface decisions (R31R51) remain authoritative.
**Status (revised):** Superseded by ADR-017 for the implementation layer. The interface decisions remain authoritative.
**Date:** 2026-05-06
**Spec:** docs/superpowers/specs/2026-05-06-instrumentation-sentry-design.md
**Plan:** docs/superpowers/plans/2026-05-06-plan-10-instrumentation-sentry.md
## Context
@@ -14,7 +12,7 @@ The monorepo had no distributed tracing or error capture. Production failures su
2. Centralized exception capture so silent failures (especially in CMS mutations) get a permanent record with stack + context.
3. Privacy posture suitable for production: scrubbing for PII, masked replay, no opaque-vs-named user identifiers.
The Lazar Nikolov reference repo (`nextjs-clean-architecture`) demonstrates a Sentry-driven pattern with `Sentry.startSpan` and `Sentry.captureException` calls inline in use cases and repos. We needed to adapt this to:
The reference Clean Architecture repo demonstrates a Sentry-driven pattern with `Sentry.startSpan` and `Sentry.captureException` calls inline in use cases and repos. We needed to adapt this to:
- Three apps (web-next, cms, web-tanstack) — not one.
- Per-feature DI (ADR-008) — not a single container.
@@ -34,11 +32,11 @@ The Lazar Nikolov reference repo (`nextjs-clean-architecture`) demonstrates a Se
**6. ESLint boundary rule (R40).** `no-restricted-imports` blocks `@sentry/*` outside the allowlisted paths: `core-shared/instrumentation/sentry/**`, `instrumentation/di/bind-sentry-instrumentation.{ts,test.ts}`, `core-testing/setup/no-sentry.{ts,test.ts}`, and the apps' `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries. Allowlist patterns use `**/`-prefix so they match whether ESLint runs from the repo root or from inside a sub-package.
**7. Test-side `RecordingTracer` / `RecordingLogger`** in `core-testing/instrumentation/`. Tests inject them directly into factory functions (consistent with R27 from Plan 9 — direct-injection, not container manipulation). The `core-testing/setup/no-sentry.ts` setup file mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs (R49).
**7. Test-side `RecordingTracer` / `RecordingLogger`** in `core-testing/instrumentation/`. Tests inject them directly into factory functions (direct-injection, not container manipulation). The `core-testing/setup/no-sentry.ts` setup file mocks `@sentry/nextjs`, `@sentry/node`, and `@sentry/react` at the module level, so any code that imports them gets a no-op surface during vitest runs.
## Alternatives considered
- **Direct `@sentry/nextjs` imports in features (Lazar's pattern).** Rejected — couples every feature package to a vendor SDK, violating the architecture's vendor-isolation principle.
- **Direct `@sentry/nextjs` imports in features.** Rejected — couples every feature package to a vendor SDK, violating the architecture's vendor-isolation principle.
- **Procedure-only spans (no per-use-case or per-repo spans).** Rejected — would lose the breakdown that makes a slow request diagnosable. The middle path (procedure + use case + controller, no per-repo) was rejected for the same reason at a finer granularity.
- **Capture in `defineErrorMiddleware` only.** Rejected — would noisily report every input-parse / unauthenticated error as a Sentry event, polluting the inbox.
- **Single Sentry project for all apps with environment tags.** Rejected — different alert routing, different quotas. Three projects scale better.
@@ -48,6 +46,7 @@ The Lazar Nikolov reference repo (`nextjs-clean-architecture`) demonstrates a Se
## Consequences
**Positive:**
- End-to-end traces in Sentry with full context.
- One captured event per error (no double-report, no noise from expected domain errors).
- Privacy-by-default replay and scrubbing.
@@ -55,6 +54,7 @@ The Lazar Nikolov reference repo (`nextjs-clean-architecture`) demonstrates a Se
- Tests run against `Recording*` for assertions; `Noop*` by default.
**Negative:**
- Every public repo method gains ~6 lines of `tracer.startSpan(...)` boilerplate. Mitigated by uniform pattern; if it ever proves excessive, a `withRepoSpan` collapse helper can be added.
- `__sentryReported` flag mutates errors. Non-enumerable, so JSON / spread are unaffected; flag is checked only inside `SentryLogger`.
- Three Sentry projects to administer.
@@ -71,7 +71,7 @@ The Lazar Nikolov reference repo (`nextjs-clean-architecture`) demonstrates a Se
## Post-merge follow-up — closing the R44 gap
Plan 10 as merged shipped repository-side capture (R43) but **not** use-case or controller capture (R44). The ADR/AGENTS docs described the intended capture-rules table as if it were the as-shipped state; in fact every `captureException` call site lived in `infrastructure/repositories/*.repository.ts`. A grep proved it: zero call sites in any controller or use-case body. The user spotted the gap.
The initial implementation shipped repository-side capture but **not** use-case or controller capture. The ADR/AGENTS docs described the intended capture-rules table as if it were the as-shipped state; in fact every `captureException` call site lived in `infrastructure/repositories/*.repository.ts`. A grep proved it: zero call sites in any controller or use-case body. The gap was spotted and fixed.
**Fix (post-merge commit):**
@@ -81,12 +81,11 @@ Plan 10 as merged shipped repository-side capture (R43) but **not** use-case or
4. `RecordingLogger.captureException` now also honours the flag, so test assertions about capture counts stay honest.
5. Added `packages/blog/tests/r44-no-double-capture.test.ts` to lock the contract: an error originated in the repo is captured once with repo tags; an error originated in the controller (parse failure) is captured once with controller tags; success paths capture nothing.
**Why use cases also wrap, even though current bodies mostly delegate to the repo:** R44's intent is that *any* throw originated locally — output-schema validation, business-rule errors like `AuthenticationError` in `signInUseCase` — gets captured with use-case tags. The wrapper makes the rule uniform; the flag makes it safe.
**Why use cases also wrap, even though current bodies mostly delegate to the repo:** R44's intent is that _any_ throw originated locally — output-schema validation, business-rule errors like `AuthenticationError` in `signInUseCase` — gets captured with use-case tags. The wrapper makes the rule uniform; the flag makes it safe.
## Related
- ADR-008 — per-feature DI containers
- ADR-011 — TDD foundation
- ADR-012 — Lazar pattern conformance
- ADR-012 — feature conventions
- ADR-013 — input/output unification
- Plan 10 spec (R31R55)

View File

@@ -12,7 +12,7 @@ core-events is scaffolded. `IJobQueue` (in `@repo/core-shared/jobs`) and the
## Context
Until this ADR the monorepo had no shared mechanism for *cross-feature* communication or *deferred* work. Two separate gaps:
Until this ADR the monorepo had no shared mechanism for _cross-feature_ communication or _deferred_ work. Two separate gaps:
1. **Cross-feature reactions** — when `auth` creates a user, `marketing-pages` wants to send a welcome email. Direct imports between feature packages are blocked by ESLint boundaries (R20). Without a bus, the only options were to merge the features or to leak a use-case import through `core-api`. Both compromise the vertical-slice property.
2. **Background jobs** — heavyweight side effects (email send, image processing, periodic cleanups) belong off the request path. The repo had no contract for "enqueue and run later." Payload's job system sits in `apps/cms` but feature packages had no abstraction over it.
@@ -27,7 +27,7 @@ The architecture's vendor-isolation principle (R40) — feature packages must no
- **E0 — Events are for cross-feature decoupling, not internal flow control.** In-feature reactions are direct use-case calls. The bus is for crossing feature boundaries.
- **E1 — Event contracts are public; handlers are private.** The publisher's `events/<x>.event.ts` is exported from the feature root barrel. The consumer's `events/handlers/on-<publisher>-<event>.handler.ts` is private to the consumer's `bind-*` files and never re-exported. Custom rule `core-eslint/rules/no-handler-reexport` blocks accidental exports.
- **J0 — Jobs are for *deferred* work, not abstraction.** Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron).
- **J0 — Jobs are for _deferred_ work, not abstraction.** Synchronous code stays synchronous. A job exists only when something must run off the request path (latency, retries, cron).
A second custom ESLint rule, `no-direct-payload-jobs`, blocks `payload.jobs.queue(...)` outside `core-shared/jobs/`. Feature packages enqueue through `IJobQueue` only.
@@ -72,7 +72,7 @@ A shared `assertAnchors(repoRoot, relPath, anchors[])` helper at `turbo/generato
## Alternatives considered
- **Single package containing both interfaces.** Rejected — `PayloadJobsEventBus` depends on `IJobQueue`. If `IJobQueue` lived in `core-events`, every feature that uses *only* jobs (no events) would still pull `core-events` transitively. The split keeps the dependency graph minimal.
- **Single package containing both interfaces.** Rejected — `PayloadJobsEventBus` depends on `IJobQueue`. If `IJobQueue` lived in `core-events`, every feature that uses _only_ jobs (no events) would still pull `core-events` transitively. The split keeps the dependency graph minimal.
- **Synchronous in-process events without a queue layer.** Rejected for production — Payload's job system gives durability, retries, and observability for free; events that flow through it gain those properties at no extra cost.
- **Vendor-coupled events (e.g., direct `payload.jobs.queue`).** Rejected — would re-couple feature packages to Payload, violating R40's vendor-isolation principle.
- **Event contracts as ad-hoc TypeScript types instead of `EventDescriptor` + Zod.** Rejected — the descriptor's `name` field is the wire format the production bus uses to route to `__events.*` task slugs. Without a single source of truth, publisher and consumer can disagree at runtime. Zod gives runtime payload validation cheaply.
@@ -82,6 +82,7 @@ A shared `assertAnchors(repoRoot, relPath, anchors[])` helper at `turbo/generato
## Consequences
**Positive:**
- Cross-feature event flows that span vertical slices without violating boundaries.
- Background work has a single contract (`IJobQueue`) that swaps from in-memory to Payload-durable per environment.
- Vendor-swappable: replacing Payload means writing one new `IJobQueue` adapter.
@@ -89,6 +90,7 @@ A shared `assertAnchors(repoRoot, relPath, anchors[])` helper at `turbo/generato
- The proof-of-life flow (sign-up → welcome email) ships green in both dev-seed and production wiring; the dev-seed path is fully exercised by `apps/web-next/src/__tests__/sign-up-welcome-email.test.ts`.
**Negative:**
- Two queue implementations means dev-seed handlers register via `queue.register(slug, ...)` while production relies on Payload tasks resolving from the per-feature container. The dispatch story differs by environment; the abstraction hides it but it's a real surface.
- `InMemoryEventBus` is synchronous; `PayloadJobsEventBus` is asynchronous and at-least-once. Subscribers must be idempotent.
- Six anchor comments in every feature is more visual noise than the average reader expects. Mitigated by the CI guard (so they can't drift accidentally) and the generators (so contributors don't need to know they exist).
@@ -114,4 +116,3 @@ A shared `assertAnchors(repoRoot, relPath, anchors[])` helper at `turbo/generato
- ADR-008 — per-feature DI containers
- ADR-010 — Turborepo boundaries
- ADR-014 — Instrumentation & Sentry logging
- Plan 10 spec — events-and-jobs-design.md

View File

@@ -4,7 +4,7 @@
**Date:** 2026-05-13
**Spec:** docs/architecture/agent-first-workflow-and-conformance.md
**Companion guide:** docs/guides/runbook.md ("Using Sandcastle for agent dispatch")
**Related:** ADR-011 (TDD foundation), ADR-012 (Lazar conformance), ADR-015 (events and jobs)
**Related:** ADR-011 (TDD foundation), ADR-012 (feature conventions), ADR-015 (events and jobs)
## Context