Skip to content

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 — declares imports / controllers / providers / exports explicitly. A provider another module injects is listed in exports (e.g. RecipeGraphModule exports RecipeGraphService); a dependency is pulled in via imports (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.ts beside 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) becomes NotFoundException, a non-positive id becomes BadRequestException (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:

ts
// 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 with createZodDto(schema) from nestjs-zod. Routes decorate with @ZodResponse({ status, type }); main.ts installs a global ZodValidationPipe.
  • 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 $ref cycle, not an inlined/allOf shape — a generated-doc test asserts the recursive $ref.
  • This schema feeds the committed openapi.json → Orval codegen for apps/web (stack.md). Never hand-edit generated contract artifacts; regenerate — verify:contract fails 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 Error subclasses in <feature>.errors.ts with 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 — no try/catch that 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's Test.createTestingModule to build the DI graph and override providers — do not new 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-transformer DTOs — 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 type is not universally safe here — the DI value-import rule overrides the usual "prefer import type" lint guidance for injected classes.
  • tsc/nest build are not the compiler — SWC CLI with an explicit .swcrc is, 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-transformer imports (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.ts and *.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 answering 401/403 is G5 working, not an exemption from it.
  • G6 — every feature folder has a *.module.ts; app.module.ts declares only imports.
  • G7 — every @Injectable/@Controller file 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.com in a comment or string outside gw2/ (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 as import type elides the runtime metatype the same way a constructor parameter's would, silently disabling ZodValidationPipe for that route — with green lint, green typecheck and green tests, since Biome's useImportType actively 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's LegendariesQueryDto carries a biome-ignore plus this explanation for that reason).