Skip to content

Plan 011 — NestJS conventions & architecture guards ​

Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.

Produced alone — tasks.md stays untouched until this plan is approved in turn.

Goal ​

After this plan, apps/api's mechanical NestJS conventions are executable. A pure Vitest architecture-test suite parses every api source file and fails when a convention is broken — a class-validator import, an injected service written import type, an @Global() module, a GW2 call outside its client, an HTTP exception thrown from a service, a feature folder with no module, or an untested provider. The seven conventions are documented in one committed guide (docs/architecture/nestjs.md), and the single drift the guards find today — HealthService has no colocated test — is fixed. No product feature, HTTP route, openapi.json, or apps/web change.

Approach ​

The suite is built from three pure, independently testable pieces plus two consumers, all under apps/api/src/conventions/:

  1. A scanner (source-model.ts). It reads apps/api/src/**/*.ts from disk and parses each file with @swc/core.parseSync ({ syntax: 'typescript', decorators: true }) — not the TypeScript compiler API, which TS 7 (the native port) removed (research.md V2/F3). It returns a typed TreeModel: one FileModel per non-test source file (its imports with value/type-only kind, its @Injectable/@Controller classes with constructor param types, its @Module object keys, its decorator names, its raw text), plus the flat list of every .ts path (tests included) so the colocated-test guard can check for siblings. The scanner is pure input→output; it is the trust anchor and gets its own unit test on inline source snippets (spec R2/SC8).

  2. Seven guard predicates (guards.ts). Each guard is a pure function (tree: TreeModel) => Violation[] — it never reads disk or the network; it only inspects the model. Expressing the guards as pure predicates (the project's "pure functions for domain logic" pattern) is what lets each one be unit-tested both ways: fed a violating snippet it must report a violation, fed a compliant snippet it must report none.

  3. The arch-test (conventions.arch.test.ts). It scans the real apps/api/src tree once and asserts every guard returns zero violations — the day-to-day enforcement, offline, in the existing CI test step (spec R1/SC10). Each assertion is a named test tracing to a success criterion.

Alongside the suite: fold in the guide (docs/architecture/nestjs.md, already drafted on main) and add its CLAUDE.md Reference line, guarded by a repo-level doc test (SC9); and fix the drift by writing health.service.test.ts (spec R11/SC7). The order is scanner → guards (TDD, one guard at a time, each proven to catch and to pass) → arch-test → drift fix → doc + doc test.

Architecture ​

apps/api/src/conventions/
  source-model.ts          scanTree(srcDir) → TreeModel   (pure: fs read + @swc/core parse)
  source-model.test.ts     unit tests for the parser/queries on inline source        (SC8)
  guards.ts                G1..G7 : (tree: TreeModel) => Violation[]   (pure predicates)
  guards.test.ts           each guard flags a violating snippet AND passes a clean one (P1 #2..#8)
  conventions.arch.test.ts runs G1..G7 over the real apps/api/src tree, asserts 0 violations (P1 #1, SC1..7, SC10)

apps/api/src/health/health.service.test.ts   drift fix — HealthService.getStatus() (R11 / SC7)

docs/architecture/nestjs.md          the committed guide (folded in from main)         (R10 / P2 #1)
CLAUDE.md                            + one Reference line linking the guide             (R10 / P2 #2)
tests/docs/nestjs-guide.test.ts      repo-level: guide exists & CLAUDE.md links it      (SC9)

Dependency direction is one-way and Nest-free: conventions.arch.test.ts and guards.test.ts → guards.ts → source-model.ts → @swc/core + node:fs. The suite imports no application code and constructs no Nest module — it only reads source text, so it never touches the network or the DI container (the structural proof of SC10). The conventions/ directory is itself excluded from the guards (it declares no @Controller/@Injectable and needs no *.module.ts).

Tech stack ​

  • @swc/core 1.15.46 — the parser. Already an apps/api devDependency (it is the api's compiler) and already in pnpm-workspace.yaml's allowBuilds; parseSync(src, { syntax: 'typescript', decorators: true }) returns an AST exposing import typeOnly / per-specifier isTypeOnly, class decorators, and constructor parameter types (confirmed, research.md V2). No new dependency.
  • node:fs — readdirSync(dir, { recursive: true }) + readFileSync; paths anchored via import.meta.dirname so cwd is irrelevant (confirmed, research.md V1). No network, no app boot.
  • Vitest 4 — the api's existing runner; apps/api/vitest.config.mts already includes src/**/*.test.ts, so *.arch.test.ts and guards.test.ts are picked up with no config change. The repo-level doc test runs under the root workspace project (tests/**).
  • TypeScript 7.0.2 typechecks the suite (no any); it is not used as a parser (research.md F3).

New dependencies: none. The scanner is ~120 lines over @swc/core; a dedicated parser dep (oxc-parser, typescript-estree) was rejected because SWC is already present and sufficient (Alternatives), and "no new dependency without justification" is a Global Constraint.

Global Constraints ​

Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.

From docs/architecture/typescript.md:

  • No any. Not in app code, not in tests. Use unknown plus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why.
  • No non-null assertions (!) to silence the compiler.
  • No @ts-expect-error without a comment explaining what is expected and when it can be removed.
  • Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
  • Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
  • Match the style of surrounding code. No new dependency without justification in the spec or plan.
  • moduleResolution: "node" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.md's api build model.

From docs/architecture/stack.md:

  • Monorepo, pnpm workspaces.
  • apps/api — NestJS (TypeScript).
  • apps/web — React (TypeScript).
  • packages/* — shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.
  • Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
  • Vitest everywhere, both apps.
  • Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
  • Static data (items, station recipes) is immutable: cache hard.
  • Prices are volatile: short TTL, recomputed live.
  • GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec, 429 on overflow. Batch up to 200 ids per ?ids= call.
  • All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
  • API keys are user secrets: encrypted at rest, never logged, never returned to the client.

From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.

File Structure ​

Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.

PathChangeResponsibility
apps/api/src/conventions/source-model.tsnewscanTree(srcDir) — read + @swc/core parse; return TreeModel (per-file imports, injected classes, @Module keys, decorator names, text; plus all .ts paths). Pure.
apps/api/src/conventions/source-model.test.tsnewUnit-test the parser queries on inline source: value vs type-only import, decorators, ctor param types, @Module keys, file enumeration (SC8).
apps/api/src/conventions/guards.tsnewG1..G7, each (tree: TreeModel) => Violation[]. Pure predicates; no fs, no network.
apps/api/src/conventions/guards.test.tsnewEach guard flags a violating snippet and passes a compliant one (P1 #2..#8; catch-side of SC1..SC7).
apps/api/src/conventions/conventions.arch.test.tsnewScan the real apps/api/src; assert each guard returns zero violations (P1 #1; pass-side SC1..SC7; SC10 offline).
apps/api/src/health/health.service.test.tsnewDrift fix: new HealthService().getStatus() returns { status: 'ok' } (R11 / SC7).
docs/architecture/nestjs.mdnewThe committed conventions guide: G1–G7 + the documented-only rules (R10 / P2 #1).
CLAUDE.mdmodifiedOne line in the Reference list linking docs/architecture/nestjs.md (R10 / P2 #2).
tests/docs/nestjs-guide.test.tsnewRepo-level: the guide file exists and CLAUDE.md's Reference list links it, and the link resolves (SC9).

Data & contracts ​

Internal to the suite; nothing is exposed on any route. Types (final names live in source-model.ts):

ts
interface ImportInfo {
  source: string;                                   // module specifier
  typeOnlyImport: boolean;                           // `import type { … }`
  names: { local: string; isTypeOnly: boolean }[];   // per-specifier `type` marker
}
interface InjectedClass {
  name: string;
  decorators: string[];                              // e.g. ['Controller'] / ['Injectable']
  ctorParams: { name: string; typeName: string | null }[];  // typeName = the type reference identifier
}
interface FileModel {
  path: string;              // relative to apps/api/src, POSIX
  imports: ImportInfo[];
  injectedClasses: InjectedClass[];   // classes decorated @Injectable/@Controller
  moduleObjectKeys: string[] | null;  // keys of the @Module({…}) literal, if the file has one
  decoratorNames: string[];           // every decorator identifier used in the file (for @Global)
  text: string;                       // raw source (base-URL / `throw new *Exception` scans)
}
interface TreeModel {
  models: FileModel[];       // non-test source files
  files: string[];           // every .ts path under src (tests included), relative POSIX
}
interface Violation { guard: string; file: string; detail: string; }
type Guard = (tree: TreeModel) => Violation[];

Guard definitions (asserted behaviour; exact code is in tasks.md):

  • G1 noClassValidator — any ImportInfo.source matching class-validator/class-transformer → violation.
  • G2 injectedParamsAreValueImports — for each InjectedClass ctor param whose typeName resolves to a local import, that import's specifier must have typeOnlyImport === false and isTypeOnly === false; otherwise violation naming the param.
  • G3 noGlobalModule — any decoratorNames entry equal to Global → violation.
  • G4 gw2AccessOnlyInGw2Dir — a FileModel whose path is not under gw2/ whose text contains the GW2 host api.guildwars2.com or imports Gw2Client from ../gw2/gw2-client → violation.
  • G5 httpExceptionsOnlyInControllers — a non-*.controller.ts FileModel that imports a name matching /Exception$/ from @nestjs/common and whose text contains throw new <Name>( → violation.
  • G6 featureModuleStructure — any directory under src/ containing a non-test file with an InjectedClass but no *.module.ts sibling → violation; and app.module.ts's moduleObjectKeys containing anything other than imports → violation.
  • G7 colocatedTests — any non-test file with a non-empty injectedClasses whose sibling <base>.test.ts is absent from tree.files → violation.

Test strategy ​

Three layers, all offline; traceability names every test after its criterion so spec.md's table fills during implementation.

  • Scanner unit (SC8) — source-model.test.ts feeds inline TypeScript strings (a value import, an import type, a per-specifier type import, an @Injectable class with a typed ctor, a @Module literal) and asserts the returned FileModel fields. Proves the trust anchor before any guard leans on it.
  • Guard catch/pass (P1 #2..#8) — guards.test.ts builds a tiny TreeModel from inline snippets: each guard gets one snippet it must flag and one it must pass. This is where "a violation is detected" is proven — the arch-test can't prove detection because the real tree is (intentionally) clean.
  • Arch-test over real source (P1 #1, SC1–SC7 pass-side, SC10) — conventions.arch.test.ts scans apps/api/src once and asserts each guard returns []. Runs with no network available (structural: the suite only reads files), which is SC10.
  • Drift fix (R11 / SC7) — health.service.test.ts unit-tests HealthService.getStatus(); after it lands, G7 over the real tree is green.
  • Doc (SC9) — tests/docs/nestjs-guide.test.ts asserts docs/architecture/nestjs.md exists and CLAUDE.md contains a Reference link to it that resolves to a real file.

Not tested directly: the documented-only rules (thin controllers, named constants, error-class placement) — by decision (spec R12) they are prose in the guide, verified by human review, not by a guard. That a guard's detail message is human-legible is checked by eye in review, not asserted.

Task outline ​

The header-half decomposition (bite-sized TDD steps land in tasks.md at step 3). Each task ends at an independently testable, committable deliverable.

  • T1 — Scanner (source-model.ts + test). scanTree + queries; unit-tested on inline source (SC8). Prerequisite: pnpm install in this worktree (research.md F4).
  • T2 — Guards G1, G3, G4 (import/decorator/text). The three that need only imports/decorators/text; catch+pass snippet tests (P1 #2/#4/#5).
  • T3 — Guard G2 (DI value-import, the flagship). Ctor-param → import-kind resolution; catch+pass (P1 #3).
  • T4 — Guards G5, G6 (exceptions, module structure). *Exception throw-site + @Module keys / module-per-folder; catch+pass (P1 #6/#7).
  • T5 — Guard G7 + drift fix. Colocated-test guard (P1 #8); then health.service.test.ts so the real tree passes G7 (R11/SC7).
  • T6 — Arch-test over real source. conventions.arch.test.ts asserts all guards return [] on apps/api/src (P1 #1, SC1–SC7 pass-side, SC10).
  • T7 — Guide + doc test. Commit docs/architecture/nestjs.md, add the CLAUDE.md Reference line, and tests/docs/nestjs-guide.test.ts (R10, SC9, P2). Fold in the version drafted on main.
  • T8 — Fill traceability + set status. Complete spec.md's traceability table with the real test names; the human moves the spec status to implemented inside the branch (Definition of Done).

Alternatives considered ​

  • A dedicated parser dependency (oxc-parser, @typescript-eslint/typescript-estree) — rejected: @swc/core is already present and exposes everything the guards need; a new parser (oxc pulls a native binary needing another allowBuilds entry) violates "no new dependency without justification."
  • The TS 7 unstable compiler API (typescript/unstable/sync + /ast) — rejected: heavier project/tsconfig setup and an explicitly unstable surface (research.md V2); SWC is simpler and already a build dependency.
  • Regex-only guards (no AST) — rejected for G2/G6: constructor-param import-kind and @Module key inspection are not reliably regex-able; a real AST removes a class of false positives. G1/G3/G4/G5/G7 could be regex but share the one parse for consistency.
  • Biome custom lint rules — rejected: Biome can express at most G1 (noRestrictedImports) and none of G2–G7; splitting enforcement across two systems (spec §Out of scope, R12) is worse than one Vitest suite. No biome.json change.
  • A standalone CI script — rejected: it would not produce the named tests the Definition of Done's traceability table requires.
  • One file per guard — rejected: seven ~15-line predicates are more navigable in one cohesive guards.ts than seven files; they share the Violation/TreeModel types.

Risks ​

  • SWC AST shape coupling. The scanner reads SWC's node shapes (Constructor, TsTypeReference, ImportDeclaration.typeOnly, specifier isTypeOnly), not TypeScript's. Mitigation: all shape knowledge is isolated in source-model.ts and pinned by source-model.test.ts (SC8), so an SWC upgrade that moves a field fails there loudly, not silently across the guards.
  • Worktree has no installed deps (research.md F4). @swc/core/vitest don't resolve here until pnpm install runs in the worktree; without it T1 fails spuriously. Mitigation: T1's first step is pnpm install; recorded as a Global prerequisite, not a code change.
  • Guard false positives on the suite's own files. conventions/, generated files, and fixtures must be out of scope for structure/test guards. Mitigation: scanTree excludes *.test.ts and the conventions/ dir from models; G6/G7 only consider files with an InjectedClass, and the suite has none.
  • G5 under-detection. A service could construct-and-store an exception without throw new on one line. Mitigation: the convention is specifically about throwing HTTP exceptions from services; the throw new <Name>( + import check covers the real pattern, and the guide documents the intent for the rare case review must catch.

Open questions ​

None blocking — research.md is complete and every [NEEDS VERIFICATION] has a verdict. Two implementation-detail defaults, decidable by the implementer without a gate (recorded so they are not mistaken for oversights):

  • Where SC9's doc test lives — proposed tests/docs/nestjs-guide.test.ts under the root workspace Vitest project; if an existing repo-invariant test is the better home, it may move there.
  • G4 host constant — the guard scans for the literal api.guildwars2.com; if gw2-client.ts ever parameterises the host, the guard reads the same constant rather than duplicating the string.