NestJS conventions — apps/api
Durable conventions for the apps/api NestJS app (NestJS 11 on Fastify, SWC-compiled). These are the patterns already established across specs 003–008; a new feature module follows them rather than re-deciding. Where generic NestJS advice conflicts with what is written here, this file wins — the divergences are deliberate and are called out in the last section.
The build model, caching posture, and contract pipeline live in stack.md; the external-API facts live in gw2-api.md. This file is about code shape — how a module is laid out and why.
Feature-module layout
One folder per feature under apps/api/src/<feature>/ — today gw2, health, static-data, recipe-graph and legendaries — each self-contained:
<feature>.module.ts— declaresimports/controllers/providers/exportsexplicitly. A provider another module injects is listed inexports(e.g.RecipeGraphModuleexportsRecipeGraphService); a dependency is pulled in viaimports(Gw2Module,StaticDataModule).<feature>.controller.ts— HTTP surface only (see Thin controllers).<feature>.service.ts— the@Injectable()orchestration layer.<feature>.schema.ts— the Zod/OpenAPI contract (see Zod-first contract).<feature>.errors.ts— named domain-error classes when the feature has them.- Pure helpers as plain modules (
pricing.ts,project-tree.ts,token-bucket.ts,bounded-cache.ts) — no Nest decorators, unit-testable in isolation. - Tests colocated as
<name>.test.tsbeside the file they cover. Split by concern when a unit has several (recipe-graph.service.test.ts,.warm-cache.test.ts,.bifrost.test.ts).
Features register in app.module.ts's imports array and nothing else lives there — AppModule is a manifest, not a home for logic. No @Global() modules; dependencies are named where used.
Thin controllers
Controllers do HTTP-shaped work and delegate everything else:
- Route + method decorators, param parsing (
@Param('itemId', ParseIntPipe)), and translating outcomes into HTTP — e.g. a null root name (item unknown to the GW2 API) becomesNotFoundException, a non-positive id becomesBadRequestException(recipe-graph.controller.ts). - Business logic belongs in the service or a pure helper. The controller composes them (
recipeGraph.resolve→gw2.prices→priceGraph); it does not contain the algorithm. - No data access, no GW2 calls, no graph-walking in the controller body.
The DI value-import rule (easy to get wrong)
Any class injected through a constructor must be a value import, not import type. The api is SWC-compiled with decoratorMetadata, and Nest DI resolves constructor parameters at runtime from the emitted design:paramtypes metadata. import type elides the value, the metadata is lost, and DI silently breaks. Because Biome's linter wants import type for anything used only as a type, each such import carries the ignore comment:
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { Gw2Service } from '../gw2/gw2.service';This applies to injected services (Gw2Service, RecipeGraphService, the static-data services). It does not apply to pure type imports — package types from @gw2priory/recipe-graph (a types-only package) and any symbol used only in annotations stay import type. When in doubt: is it a constructor parameter type Nest must instantiate? → value import. Otherwise → import type.
Zod-first contract (no class-validator DTOs)
The HTTP contract is Zod, end to end — this app does not use class-validator decorator DTOs.
- Response/request shapes are Zod schemas in
<feature>.schema.ts, wrapped withcreateZodDto(schema)fromnestjs-zod. Routes decorate with@ZodResponse({ status, type });main.tsinstalls a globalZodValidationPipe. - Schemas are the source of truth and live in
apps/api, mirroring the types-only domain packages. Pin them to the package type with a drift guard —const S: z.ZodType<PlanSummary> = …— so the schema stops compiling if the shapes diverge (recipe-graph.schema.ts). - Keep recursive schemas named (via
z.lazy) so the emitted OpenAPI is a$refcycle, not an inlined/allOfshape — a generated-doc test asserts the recursive$ref. - This schema feeds the committed
openapi.json→ Orval codegen forapps/web(stack.md). Never hand-edit generated contract artifacts; regenerate —verify:contractfails CI on drift.
The GW2 client boundary
No service calls the GW2 API directly. All access goes through Gw2Service (gw2/), a singleton Nest wrapper over the one budgeted Gw2Client (token bucket, in-memory caches). Consumers importGw2Module and inject Gw2Service (gw2.service.ts, and gw2-api.md). Batch multi-id reads through its methods rather than issuing your own fetch; the rate budget and cache only hold if everyone shares the one client.
Errors
- Domain failures are named
Errorsubclasses in<feature>.errors.tswith a descriptive message (CycleError—recipe-graph.errors.ts). They carry meaning, not HTTP status. - The controller is the single place that maps outcomes to Nest
HttpExceptions (NotFoundException,BadRequestException). Services throw domain errors and otherwise let calls propagate — notry/catchthat swallows a GW2/service failure (spec 007 R9). - Absence is modelled as data where the domain says so (an unknown id resolves to a null-metadata leaf rather than throwing), not as an exception.
Constants and configuration
Magic numbers are named constants at the top of the file, annotated with their source (const GW2_BUCKET_CAPACITY = 300; // documented GW2 budget, research-confirmed). A value that comes from an external contract cites where it was verified, so a later reader can re-check it.
Testing
- Vitest, colocated
.test.ts. Use@nestjs/testing'sTest.createTestingModuleto build the DI graph and override providers — do notnew Service()around DI. - Pure helpers are tested directly as functions (no Nest harness) — a reason to keep algorithmic code in plain modules.
- Every acceptance scenario / success criterion in the feature spec maps to a named test (project Definition of Done).
Where this diverges from generic NestJS advice
An agent trained on typical NestJS guides will reach for patterns this stack has deliberately not adopted. Do not "correct" toward them:
- No
class-validator/class-transformerDTOs — validation is Zod (nestjs-zod). See above. - No TypeORM/Prisma entities — persistence is Postgres for user data only; item/recipe data is a curated static dataset plus the external GW2 API, not an ORM-mapped domain (
stack.md). import typeis not universally safe here — the DI value-import rule overrides the usual "preferimport type" lint guidance for injected classes.tsc/nest buildare not the compiler — SWC CLI with an explicit.swcrcis, because Nest DI needs emitted decorator metadata a typecheck-only compiler never produces (stack.md).- No
@Global()convenience modules — dependencies are imported where used.
Enforcement
The mechanical conventions above are enforced by a Vitest architecture-test suite (apps/api/src/conventions/), which parses every api source file with @swc/core and fails CI on a violation. Each guard is a named test in conventions.arch.test.ts:
- G1 — no
class-validator/class-transformerimports (the contract is Zod-first). - G2 — every constructor-injected param is a value import, not
import type(the DI metadata rule). - G3 — no
@Global()modules. - G4 — GW2 network access only inside
src/gw2/. - G5 — Nest HTTP exceptions constructed (
new *Exception(...)) only in*.controller.tsand*.guard.ts; keyed on the imported name, so an alias is still caught. The rule reads "not in services": both are HTTP-boundary files that run per request, and returning a status is their job — a guard answering401/403is G5 working, not an exemption from it. - G6 — every feature folder has a
*.module.ts;app.module.tsdeclares onlyimports. - G7 — every
@Injectable/@Controllerfile has a colocated*.test.ts.
Documented-only (not hard-guarded) — enforced by review, not a test, because a mechanical check would produce false positives: thin controllers / no business logic in controllers; named constants for magic numbers; domain errors as named Error subclasses in *.errors.ts.
Known limitations — the guards check a single file's AST plus its raw text, not a whole-program type graph. These gaps are inherent to that scope and accepted, not bugs:
- G4 and G5 read raw source text, so a mention of
api.guildwars2.comin a comment or string outsidegw2/(G4) can false-positive, and a Nest exception surfaced through cross-file indirection — a re-exported base-URL constant (G4) or a re-exported / aliased-at-a-distance exception (G5) — can be missed. - A decorator reached through an alias or namespace (
@Global as G,@common.Global()) is recorded under that name and would escape G3. - G2 scans constructor parameters only. A
@Query()/@Body()DTO parameter written asimport typeelides the runtime metatype the same way a constructor parameter's would, silently disablingZodValidationPipefor that route — with green lint, green typecheck and green tests, since Biome'suseImportTypeactively proposes that exact change to a future author. Until G2 covers method parameters, guard a new route with an HTTP-level 400 test (legendaries.controller.ts'sLegendariesQueryDtocarries abiome-ignoreplus this explanation for that reason).