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
- 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.
- Given a file anywhere in
apps/api/srcthat imports fromclass-validatororclass-transformer, when the suite runs, then G1 fails and names the offending file. - Given a class decorated
@Injectable()or@Controller()whose constructor takes a parameter typed as a locally-imported class, when that import is writtenimport 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. - Given a module decorated
@Global(), when the suite runs, then G3 fails. - Given a file outside
src/gw2/that references the GW2 API base URL or constructs aGw2Client, when the suite runs, then G4 fails — the one budgeted client stays the sole network chokepoint. - Given a Nest HTTP exception (an identifier imported from
@nestjs/commonwhose name ends inException) thrown in a file that is not a*.controller.ts, when the suite runs, then G5 fails. - Given a folder under
src/that contains a non-test@Controller/@Injectablefile but no*.module.ts, or anapp.module.tswhose@Moduleobject declares keys other thanimports, when the suite runs, then G6 fails. - 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
- Given the committed branch, when the docs are checked, then
docs/architecture/nestjs.mdexists and covers each hard guard G1–G7 and the documented-only rules. - Given
CLAUDE.md, when its Reference list is read, then it linksdocs/architecture/nestjs.mdand 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 readsapps/api/src/**/*.tsfrom 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 CIteststep with no new pipeline wiring. (Confirmed,research.mdV1.) - R2 — A single pure scanner,
apps/api/src/conventions/source-model.ts, enumerates the source files (excluding*.test.tsand 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,@Moduledecorator metadata, import specifiers, decorator names — parsed with@swc/core.parseSync({ syntax: 'typescript', decorators: true }), which is already anapps/apidependency and inpnpm-workspace.yaml'sallowBuilds, and whose AST exposes importtypeOnly/ per-specifierisTypeOnly, decorators, and constructor parameter types — exactly the data G1–G7 need. The classic TypeScript compiler API is not available: this repo'stypescriptis 7.x, the native port, which removedts.createSourceFile(confirmed,research.mdV2/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/srcimports fromclass-validatororclass-transformer; the contract is Zod-first vianestjs-zod. - R4 (G2) — Every constructor parameter of an
@Injectable/@Controllerclass whose type is a locally-imported class is imported as a value (notimport type, not a type-only named specifier). This is the DI value-import rule the SWCdecoratorMetadatabuild 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 theGw2Client; no other code reaches the GW2 API directly. - R7 (G5) — Nest HTTP exceptions (identifiers imported from
@nestjs/commonwhose name ends inException) are thrown only in*.controller.tsfiles, never in services or pure modules. - R8 (G6) — Every folder under
src/that holds a non-test@Controller/@Injectablefile also holds a*.module.ts, andsrc/app.module.ts's@Moduleobject declares onlyimports(nocontrollers/providersat the root). - R9 (G7) — Every non-test file declaring
@Controller()or@Injectable()has a sibling*.test.ts. - R10 —
docs/architecture/nestjs.mdis committed on this branch (folding in the already-drafted guide), documents each hard guard G1–G7 and the documented-only rules, and is referenced fromCLAUDE.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.tsgains a passing siblinghealth.service.test.tsso 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
Errorsubclasses in*.errors.ts— are described in the guide but not given hard tests, because mechanical enforcement of them produces false positives. This spec touches noapps/webfile, noopenapi.json, and no Biome configuration, and scopes guards toapps/apionly.
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 inresearch.mdwith 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-transformercauses exactly one guard to fail, naming the file; with no such import, the guard passes. - SC2 (G2) — An
@Injectable/@Controllerconstructor 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
Gw2Clientconstruction outsidesrc/gw2/fails the guard; the current source (onlygw2/) passes. - SC5 (G5) — A Nest
*Exceptionthrown outside a*.controller.tsfails the guard; the current source (exceptions only inrecipe-graph.controller.ts) passes. - SC6 (G6) — A feature folder missing a
*.module.ts, or anapp.module.tsdeclaringcontrollers/providers, fails the guard; the current structure passes. - SC7 (G7) — A non-test
@Controller/@Injectablefile with no sibling*.test.tsfails the guard; after R11's fix, every current such file passes. - SC8 —
source-model.tsis 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.mdexists and is linked fromCLAUDE.md's Reference list, and a test confirms the link resolves to an existing file. - SC10 — The complete
apps/apitest suite — guards, scanner test, and the newhealth.servicetest — 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/webchange,openapi.jsonregeneration, 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 isapps/api-scoped. The web side is010-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/apisource 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 missinghealth.servicetest (R11). G2 compliance is assumed but confirmed only once the AST guard exists. @swc/coreis available toapps/apiat test time (it is a devDependency and inallowBuilds) and itsparseSyncAST distinguishes value vs. type-only imports and exposes decorators and constructor parameter types (confirmed,research.mdV2). The classictypescriptcompiler API is not used (TS 7 native port removed it —research.mdF3).- Vitest and the api's existing test config already cover
src/conventions/with no new workspace wiring, exactly as they coversrc/gw2/. - This worktree must have its dependencies installed (
pnpm install) before the guard suite runs; per-package deps like@swc/coreandvitestare not present until then (research.mdF4). 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.mdcontent is the basis for R10 and needs only to be committed on this branch (it was written onmainand 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).
| Criterion | Test |
|---|---|
| P1 #1 | conventions.arch.test.ts › P1#1/SC10: all guards pass over apps/api/src with no network |
| P1 #2 | guards.test.ts › SC1/P1#2/G1: a class-validator import is flagged, a nestjs-zod import is not |
| P1 #3 | guards.test.ts › SC2/P1#3/G2: an injected ctor param typed by a type-only import is flagged |
| P1 #4 | guards.test.ts › SC3/P1#4/G3: an @Global() module is flagged, a plain @Module is not |
| P1 #5 | guards.test.ts › SC4/P1#5/G4: a file outside gw2/ referencing api.guildwars2.com … Gw2Service … is not |
| P1 #6 | guards.test.ts › SC5/P1#6/G5: a service that throws a Nest *Exception is flagged; a controller is not; … |
| P1 #7 | guards.test.ts › SC6/P1#7/G6: a folder with an @Injectable but no *.module.ts is flagged; … app.module.ts … providers key … |
| P1 #8 | guards.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 #1 | tests/docs/nestjs-guide.test.ts › SC9/P2#1: docs/architecture/nestjs.md exists |
| P2 #2 | tests/docs/nestjs-guide.test.ts › SC9/P2#2: CLAUDE.md's Reference list links nestjs.md and the target resolves |
| SC1 | conventions.arch.test.ts › SC1/G1: no class-validator imports in apps/api (catch: guards.test.ts › SC1/P1#2/G1) |
| SC2 | conventions.arch.test.ts › SC2/G2: every injected constructor param is a value import (catch: guards.test.ts › SC2/P1#3/G2) |
| SC3 | conventions.arch.test.ts › SC3/G3: no @Global modules (catch: guards.test.ts › SC3/P1#4/G3) |
| SC4 | conventions.arch.test.ts › SC4/G4: GW2 access only under gw2/ (catch: guards.test.ts › SC4/P1#5/G4) |
| SC5 | conventions.arch.test.ts › SC5/G5: HTTP exceptions only in controllers (catch: guards.test.ts › SC5/P1#6/G5) |
| SC6 | conventions.arch.test.ts › SC6/G6: every feature folder has a module and app.module imports-only (catch: guards.test.ts › SC6/P1#7/G6) |
| SC7 | conventions.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'}) |
| SC8 | source-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 |
| SC9 | tests/docs/nestjs-guide.test.ts › SC9/P2#1: … exists, SC9/P2#2: … CLAUDE.md links … resolves |
| SC10 | conventions.arch.test.ts › P1#1/SC10: all guards pass over apps/api/src with no network |