Skip to content

Spec 011 — NestJS conventions & architecture guards ​

Status: implemented Branch: 011-nestjs-conventions

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

apps/api already follows a consistent set of NestJS conventions — feature-module folders, thin controllers, a Zod-first contract instead of class-validator DTOs, one GW2 network chokepoint, and the unusual DI value-import rule the SWC build forces (a constructor-injected class must be a value import, or Nest DI silently breaks). But these conventions live only in the code and in reviewers' heads. Nothing prevents a new feature — or an AI agent working a later spec — from quietly violating one: adding class-validator, marking an injected service import type (which lints cleaner and breaks DI at runtime, not compile time), throwing an HTTP exception from a service, or calling the GW2 API outside its budgeted client. The conventions are also undocumented as a single source; a guide (docs/architecture/nestjs.md) was drafted ad hoc but is neither committed on this branch nor enforced.

This spec makes the mechanical conventions executable: a Vitest architecture-test suite inside apps/api that parses the source and fails CI when a convention is broken, plus the committed guide that documents them and the fix for the one drift the guards surface today. It builds no product feature — no HTTP route, no openapi.json change, no apps/web change. It is internal-quality tooling that keeps every later api feature honest.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.

P1 — Executable guards that fail on convention drift ​

As the developer (or agent) building a later api feature, I want the mechanical NestJS conventions enforced by tests that fail CI on violation, so that a broken DI value-import, a stray class-validator dependency, a service throwing HTTP exceptions, or a GW2 call outside the client is caught automatically instead of surviving to runtime or to review.

Independent test: with only P1 implemented, the arch-test suite runs offline in the existing CI test step; every guard passes against the current (drift-fixed) apps/api source; and a deliberately planted violation of each guard (a class-validator import, an import type on an injected service, an @Global() module, a GW2 base-URL reference outside gw2/, a NotFoundException thrown from a service, a feature folder with no *.module.ts, an untested @Injectable) makes its guard — and only its guard — fail.

Acceptance scenarios

  1. Given the arch-test suite and the scanner it depends on, when the api's test suite runs with no network access, then every guard G1–G7 passes against the current source and issues zero network requests.
  2. Given a file anywhere in apps/api/src that imports from class-validator or class-transformer, when the suite runs, then G1 fails and names the offending file.
  3. Given a class decorated @Injectable() or @Controller() whose constructor takes a parameter typed as a locally-imported class, when that import is written import type (or a type-only named specifier), then G2 fails and names the parameter — because Nest DI reads that type from SWC-emitted metadata a type-only import elides.
  4. Given a module decorated @Global(), when the suite runs, then G3 fails.
  5. Given a file outside src/gw2/ that references the GW2 API base URL or constructs a Gw2Client, when the suite runs, then G4 fails — the one budgeted client stays the sole network chokepoint.
  6. Given a Nest HTTP exception (an identifier imported from @nestjs/common whose name ends in Exception) thrown in a file that is not a *.controller.ts, when the suite runs, then G5 fails.
  7. Given a folder under src/ that contains a non-test @Controller/@Injectable file but no *.module.ts, or an app.module.ts whose @Module object declares keys other than imports, when the suite runs, then G6 fails.
  8. Given a non-test file declaring @Controller() or @Injectable() with no sibling *.test.ts, when the suite runs, then G7 fails.

P2 — One documented source for the conventions ​

As a developer or agent onboarding to apps/api, I want the conventions written down in one committed place the project already indexes, so that the guide and the guards describe the same rules and a reader learns why (e.g. the SWC-metadata reason behind the DI value-import rule), not only that a test failed.

Independent test: docs/architecture/nestjs.md exists on this branch, documents G1–G7 and the documented-only rules, and is listed in CLAUDE.md's Reference section; the repo's existing architecture-doc test (or an added assertion) confirms the reference resolves.

Acceptance scenarios

  1. Given the committed branch, when the docs are checked, then docs/architecture/nestjs.md exists and covers each hard guard G1–G7 and the documented-only rules.
  2. Given CLAUDE.md, when its Reference list is read, then it links docs/architecture/nestjs.md and the link resolves to a file that exists.

Requirements ​

  • R1 — The guards live in apps/api/src/conventions/ as a Vitest architecture-test suite that reads apps/api/src/**/*.ts from disk and asserts over their parsed form. The suite is pure: fs + parse only, no network and no running app, so it runs in the existing CI test step with no new pipeline wiring. (Confirmed, research.md V1.)
  • R2 — A single pure scanner, apps/api/src/conventions/source-model.ts, enumerates the source files (excluding *.test.ts and itself) and exposes the queries the guards need — classes carrying @Injectable/@Controller, each constructor parameter's type identifier and whether its import was value or type-only, @Module decorator metadata, import specifiers, decorator names — parsed with @swc/core.parseSync ({ syntax: 'typescript', decorators: true }), which is already an apps/api dependency and in pnpm-workspace.yaml's allowBuilds, and whose AST exposes import typeOnly / per-specifier isTypeOnly, decorators, and constructor parameter types — exactly the data G1–G7 need. The classic TypeScript compiler API is not available: this repo's typescript is 7.x, the native port, which removed ts.createSourceFile (confirmed, research.md V2/F3). The scanner carries its own unit test (source-model.test.ts): the guards are only as trustworthy as it is, and that test pins the queries to SWC's AST shapes.
  • R3 (G1) — No file in apps/api/src imports from class-validator or class-transformer; the contract is Zod-first via nestjs-zod.
  • R4 (G2) — Every constructor parameter of an @Injectable/@Controller class whose type is a locally-imported class is imported as a value (not import type, not a type-only named specifier). This is the DI value-import rule the SWC decoratorMetadata build depends on.
  • R5 (G3) — No module is decorated @Global(); dependencies are imported where used.
  • R6 (G4) — Only files under src/gw2/ reference the GW2 API base URL or construct the Gw2Client; no other code reaches the GW2 API directly.
  • R7 (G5) — Nest HTTP exceptions (identifiers imported from @nestjs/common whose name ends in Exception) are thrown only in *.controller.ts files, never in services or pure modules.
  • R8 (G6) — Every folder under src/ that holds a non-test @Controller/@Injectable file also holds a *.module.ts, and src/app.module.ts's @Module object declares only imports (no controllers/providers at the root).
  • R9 (G7) — Every non-test file declaring @Controller() or @Injectable() has a sibling *.test.ts.
  • R10 — docs/architecture/nestjs.md is committed on this branch (folding in the already-drafted guide), documents each hard guard G1–G7 and the documented-only rules, and is referenced from CLAUDE.md's Reference list; the reference is asserted to resolve.
  • R11 — The one drift the guards surface today is fixed: apps/api/src/health/health.service.ts gains a passing sibling health.service.test.ts so G7 is green. Any further violation surfaced by G2 once implemented is fixed the same way (fix the code, not the guard).
  • R12 — The guards enforce the mechanical conventions only. The documented-only rules — thin controllers / no business logic in controllers, named constants for magic numbers, domain errors as named Error subclasses in *.errors.ts — are described in the guide but not given hard tests, because mechanical enforcement of them produces false positives. This spec touches no apps/web file, no openapi.json, and no Biome configuration, and scopes guards to apps/api only.

Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:

  • [NEEDS CLARIFICATION: specific question] — only the human can answer. A product decision, a scope boundary, a preference. Blocks step 1.5.
  • [NEEDS VERIFICATION: specific question] — only reality can answer. Whether the codebase works that way, whether an endpoint returns that field, whether that number is achievable. Answered in research.md with cited evidence, never by assumption. Blocks the approval gate.

Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it. An unbacked number is a guess wearing a criterion's clothes.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 (G1) — A source file importing class-validator/class-transformer causes exactly one guard to fail, naming the file; with no such import, the guard passes.
  • SC2 (G2) — An @Injectable/@Controller constructor parameter typed by a type-only import fails the guard, naming the parameter; every current injected parameter passes.
  • SC3 (G3) — An @Global() module fails the guard; the current source (no @Global()) passes.
  • SC4 (G4) — A GW2 base-URL reference or Gw2Client construction outside src/gw2/ fails the guard; the current source (only gw2/) passes.
  • SC5 (G5) — A Nest *Exception thrown outside a *.controller.ts fails the guard; the current source (exceptions only in recipe-graph.controller.ts) passes.
  • SC6 (G6) — A feature folder missing a *.module.ts, or an app.module.ts declaring controllers/providers, fails the guard; the current structure passes.
  • SC7 (G7) — A non-test @Controller/@Injectable file with no sibling *.test.ts fails the guard; after R11's fix, every current such file passes.
  • SC8 — source-model.ts is covered by its own unit test asserting its queries return the expected shapes on known input, so a scanner regression is caught independently of the guards.
  • SC9 — docs/architecture/nestjs.md exists and is linked from CLAUDE.md's Reference list, and a test confirms the link resolves to an existing file.
  • SC10 — The complete apps/api test suite — guards, scanner test, and the new health.service test — passes with no network access available.

Out of scope ​

  • Enforcing the documented-only rules (thin controllers, named constants, error-class placement) with hard tests — they are documented in the guide, not guarded (R12).
  • Any apps/web change, openapi.json regeneration, or Orval client change.
  • Any change to biome.json / the Biome lint configuration; the guards are Vitest, not lint rules.
  • Extending the guards to packages/*; this spec is apps/api-scoped. The web side is 010-frontend-conventions's concern.
  • A lint-time or editor-time integration; enforcement is at test/CI time only.
  • Auto-fixing violations; guards report, humans (or a later spec) fix.

Assumptions ​

  • The current apps/api source is compliant with G1, G3, G4, G5 and structurally with G6 (confirmed by a source scan during brainstorming, 2026-08-05); the only known drift is the missing health.service test (R11). G2 compliance is assumed but confirmed only once the AST guard exists.
  • @swc/core is available to apps/api at test time (it is a devDependency and in allowBuilds) and its parseSync AST distinguishes value vs. type-only imports and exposes decorators and constructor parameter types (confirmed, research.md V2). The classic typescript compiler API is not used (TS 7 native port removed it — research.md F3).
  • Vitest and the api's existing test config already cover src/conventions/ with no new workspace wiring, exactly as they cover src/gw2/.
  • This worktree must have its dependencies installed (pnpm install) before the guard suite runs; per-package deps like @swc/core and vitest are not present until then (research.md F4). This is a one-time step-4 prerequisite, not a spec or CI change (CI installs as usual).
  • CI runs apps/api's Vitest suite as it does today; adding tests adds no pipeline step.
  • The already-drafted docs/architecture/nestjs.md content is the basis for R10 and needs only to be committed on this branch (it was written on main and does not travel into this worktree automatically).

Traceability ​

Each acceptance scenario and success criterion must map to a named test. Fill this in during implementation. All tests live under apps/api/src/conventions/ (plus the doc test for SC9 and the drift-fix test for R11/SC7).

CriterionTest
P1 #1conventions.arch.test.ts › P1#1/SC10: all guards pass over apps/api/src with no network
P1 #2guards.test.ts › SC1/P1#2/G1: a class-validator import is flagged, a nestjs-zod import is not
P1 #3guards.test.ts › SC2/P1#3/G2: an injected ctor param typed by a type-only import is flagged
P1 #4guards.test.ts › SC3/P1#4/G3: an @Global() module is flagged, a plain @Module is not
P1 #5guards.test.ts › SC4/P1#5/G4: a file outside gw2/ referencing api.guildwars2.com … Gw2Service … is not
P1 #6guards.test.ts › SC5/P1#6/G5: a service that throws a Nest *Exception is flagged; a controller is not; …
P1 #7guards.test.ts › SC6/P1#7/G6: a folder with an @Injectable but no *.module.ts is flagged; … app.module.ts … providers key …
P1 #8guards.test.ts › SC7/P1#8/G7: a non-test @Injectable file with no sibling test is flagged; one with a sibling *.test.ts is not
P2 #1tests/docs/nestjs-guide.test.ts › SC9/P2#1: docs/architecture/nestjs.md exists
P2 #2tests/docs/nestjs-guide.test.ts › SC9/P2#2: CLAUDE.md's Reference list links nestjs.md and the target resolves
SC1conventions.arch.test.ts › SC1/G1: no class-validator imports in apps/api (catch: guards.test.ts › SC1/P1#2/G1)
SC2conventions.arch.test.ts › SC2/G2: every injected constructor param is a value import (catch: guards.test.ts › SC2/P1#3/G2)
SC3conventions.arch.test.ts › SC3/G3: no @Global modules (catch: guards.test.ts › SC3/P1#4/G3)
SC4conventions.arch.test.ts › SC4/G4: GW2 access only under gw2/ (catch: guards.test.ts › SC4/P1#5/G4)
SC5conventions.arch.test.ts › SC5/G5: HTTP exceptions only in controllers (catch: guards.test.ts › SC5/P1#6/G5)
SC6conventions.arch.test.ts › SC6/G6: every feature folder has a module and app.module imports-only (catch: guards.test.ts › SC6/P1#7/G6)
SC7conventions.arch.test.ts › SC7/G7: every provider/controller has a colocated test (catch: guards.test.ts › SC7/P1#8/G7; drift fix: health.service.test.ts › R11/SC7: HealthService.getStatus() returns {status:'ok'})
SC8source-model.test.ts › SC8: parseFile … (splits value/type-only imports; reads ctor param types; reads @Module keys; collects decorator names) + SC8: scanTree enumerates real src
SC9tests/docs/nestjs-guide.test.ts › SC9/P2#1: … exists, SC9/P2#2: … CLAUDE.md links … resolves
SC10conventions.arch.test.ts › P1#1/SC10: all guards pass over apps/api/src with no network