Backend Reference
Backend engineering at S&P means building APIs and services that are reliable, maintainable, and honest about their contracts. NestJS on Node.js with PostgreSQL is our primary stack. This appendix is the lookup reference for implementation patterns -- use the heading list to jump to the concept you need.
Scope
This appendix covers backend-specific implementation patterns for S&P projects. For cross-cutting practices, see the main playbook sections:
- Code Standards -- language-level conventions, function design
- Testing Strategy -- testing philosophy, trophy model
- Security -- threat modeling, security principles
- Architecture -- system design, ADRs
Examples use NestJS as the reference implementation. The principles are portable -- adapt patterns for your project's stack.
Project structure and modules
Organize backend code into cohesive modules. Each module owns a single domain concept and encapsulates its data models, business logic, transport layer, and validation. Other modules consume it through its exported public API -- never by reaching into internals.
One module per domain concept. A UserModule owns everything about users: entity, DTOs, service, controller. An OrderModule owns orders. If module A needs something from module B, it imports B and uses B's exported service. No shortcuts.
Export only what others need. The exports array is your module's public contract. Keep it minimal -- typically just the service. Controllers and entities stay internal.
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
Directory layout for a module:
src/
user/
user.module.ts
user.controller.ts
user.service.ts
user.entity.ts
dto/
create-user.dto.ts
update-user.dto.ts
guards/
user-owner.guard.ts
Keep modules flat until complexity forces nesting. A module with 5-8 files does not need subdirectories beyond dto/. When a module grows past ~15 files, that is a signal to split the domain concept.
Polyglot note: Spring Boot uses @Configuration classes and component scanning within packages. .NET uses project references and DI service registrations in Program.cs. Go organizes by package -- each directory is a module boundary with exported (capitalized) types as the public API. The principle is identical: one boundary per domain concept, explicit exports.
Naming conventions
All files use kebab-case with a dot-separated type suffix. The suffix describes the file's role and makes it possible to find any file by its purpose without opening it.
| Type | Pattern | Example |
|---|---|---|
| Service | *.service.ts | user.service.ts |
| Controller | *.controller.ts | user.controller.ts |
| Module | *.module.ts | user.module.ts |
| Entity / Model | *.entity.ts | user.entity.ts |
| DTO | *.dto.ts | create-user.dto.ts |
| Guard | *.guard.ts | auth.guard.ts |
| Interceptor | *.interceptor.ts | logging.interceptor.ts |
| Pipe | *.pipe.ts | validation.pipe.ts |
| Filter | *.filter.ts | http-exception.filter.ts |
| Decorator | *.decorator.ts | current-user.decorator.ts |
| Utility | *.util.ts | date-format.util.ts |
| Spec (test) | *.spec.ts | user.service.spec.ts |
| E2E test | *.e2e-spec.ts | user.e2e-spec.ts |
Class naming follows the suffix: UserService, UserController, CreateUserDto, AuthGuard. The class name matches the filename in PascalCase.
Polyglot note: Spring uses UserService.java, UserController.java, UserRepository.java -- same suffix convention, different casing (PascalCase filenames). .NET follows the same PascalCase pattern. Go uses user_service.go with snake_case filenames.
DTOs and input validation
Validate incoming data at the API boundary before it reaches business logic. Never trust client input.
Separate DTOs for create and update
Create DTOs include all required fields. Update DTOs make everything optional. NestJS provides PartialType to derive one from the other without duplicating validation rules.
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
avatarUrl?: string;
}
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto';
export class UpdateUserDto extends PartialType(CreateUserDto) {}
Nested object validation
When a DTO contains nested objects or arrays, use @ValidateNested with @Type to ensure the nested objects are also validated.
import { IsUUID, IsArray, ArrayMinSize, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateOrderDto {
@IsUUID('7')
userId: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
Enable validation globally
Register the ValidationPipe globally so every endpoint validates input without per-controller setup.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties not in the DTO
forbidNonWhitelisted: true, // Throw if unknown properties are sent
transform: true, // Auto-transform payloads to DTO instances
}),
);
whitelist: true is a security measure. Without it, a client can send { "role": "admin" } alongside valid fields, and if your entity has a role column, the ORM may persist it. Whitelisting strips anything the DTO does not declare.
Polyglot note: .NET uses FluentValidation or Data Annotations for the same pattern. Java/Spring uses Bean Validation (@Valid, @NotNull, @Size). Go typically uses struct tags with a validation library like go-playground/validator. The principle is the same: schema validation at the edge, separate input shapes from domain models.
Error handling
Handle errors at the boundary, not everywhere. Register a global exception filter for cross-cutting concerns (logging, error response shaping, error tracking). Only catch locally when you can do something useful -- retry a network call, fall back to a cached value, wrap a third-party library that throws unpredictably.
Services return null, controllers throw
Services operate at the domain level and have no knowledge of HTTP. When a lookup finds nothing or an operation cannot complete, return null (or false for boolean operations) -- do not throw an HTTP exception from inside a service.
Controllers decide what null means in their context. For some endpoints, null is a definitive 404. For others, null is a signal to branch into different logic: fall back to a default, trigger a create, or return an empty collection. If the service threw directly, the controller would need to catch and ignore the exception to implement that branch, which is awkward and error-prone.
// Service: domain concern only, no HTTP awareness
async findOne(id: string): Promise<UserEntity | null> {
return this.userRepo.findOneBy({ id });
}
// Controller A: null is a hard error
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string) {
const user = await this.userService.findOne(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
// Controller B: null triggers further computation
@Post(':id/ensure')
async ensure(@Param('id', ParseUUIDPipe) id: string) {
const existing = await this.userService.findOne(id);
if (!existing) return this.userService.create({ id });
return existing;
}
This also keeps services reusable outside HTTP: a background job or another service can call findOne and handle null naturally without catching an HTTP exception class.
Structured error responses
All APIs return errors in a consistent shape based on RFC 9457 (Problem Details). This lets frontend code handle errors generically instead of parsing different shapes per endpoint.
// Response shape for all errors
{
"statusCode": 404,
"error": "Not Found",
"message": "User with id 01936f4e-... not found",
"timestamp": "2025-01-15T10:30:00.000Z",
"path": "/api/v1/users/01936f4e-..."
}
Custom exceptions with context
Fail with context. Error messages should include enough information to diagnose the problem without reading the source code.
// Good -- context in the error message
throw new NotFoundException(`User with id ${id} not found`);
// Bad -- the person debugging this needs to read the stack trace
throw new NotFoundException('Not found');
Global exception filter
A single filter catches everything, shapes the response, and sends the error to your tracking service.
import {
ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
// Log the full error for debugging; return a safe message to the client
if (status >= 500) {
this.logger.error(exception);
}
response.status(status).json({
statusCode: status,
error: HttpStatus[status],
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
Register it globally in main.ts:
app.useGlobalFilters(new AllExceptionsFilter());
Do not leak internal details. The filter logs the full stack trace for 5xx errors but returns only a safe message to the client. Stack traces, SQL errors, and internal paths never appear in API responses.
Polyglot note: .NET uses exception-handling middleware (app.UseExceptionHandler) or IExceptionFilter. Go returns errors as values and typically handles them in a middleware that wraps handlers. Spring Boot uses @ControllerAdvice with @ExceptionHandler methods. Every stack needs one place that shapes error responses -- do not scatter try/catch through every function.
Logging and health checks
The observability rationale (the three pillars, mandatory log fields, log levels, correlation IDs, what never to log) lives in Observability & Incidents. This section is the NestJS wiring.
Structured logging with Pino
Use Pino via nestjs-pino. It emits JSON by default, is significantly faster than Winston, and feeds GCP Cloud Logging without transformation. Every request gets a correlation ID; sensitive headers are redacted at the logger.
// main.ts: configure structured logging
import { Logger } from 'nestjs-pino';
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
// PinoModule configuration in AppModule
PinoModule.forRoot({
pinoHttp: {
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development'
? { target: 'pino-pretty' }
: undefined,
genReqId: (req) => req.headers['x-correlation-id'] || generateULID(),
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
userAgent: req.headers['user-agent'],
}),
res: (res) => ({ statusCode: res.statusCode }),
},
redact: ['req.headers.authorization', 'req.headers.cookie'],
},
});
Health check endpoints
Expose two endpoints via @nestjs/terminus: /health/live (process up, no dependency checks, drives container restarts) and /health/ready (dependencies reachable, drives traffic routing).
// health.controller.ts
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private redis: RedisHealthIndicator,
) {}
@Get('live')
liveness() {
return { status: 'ok' };
}
@Get('ready')
readiness() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.redis.pingCheck('cache'),
]);
}
}
Local debugging
The debugging method and when to reach for each tool live in Developer Experience. Local debugging. This section is the NestJS wiring.
Run in debug mode. Nest's CLI exposes start:debug, which runs the app with the Node inspector open and hot reload on:
// package.json scripts
{
"start:debug": "nest start --debug --watch"
}
nest start --debug opens the inspector on 9229. Attach from Cursor/VS Code (see DevOps Reference. Debug configuration) and set breakpoints in the TypeScript source. For a bug that happens during startup (module init, a provider constructor), break before the first line so nothing runs un-paused:
node --inspect-brk dist/main.js
Debug a single test. Run one spec under the inspector, paused at the first line, instead of dropping console.log into the test:
node --inspect-brk node_modules/.bin/jest --runInBand path/to/thing.spec.ts
--runInBand is required: it runs tests in the same process the inspector is attached to, rather than forking workers.
Debug-level logging. Temporary diagnostic lines go through the Nest logger at debug level, never console.log (the reasoning is in Observability & Incidents):
this.logger.debug({ userId, cacheKey, hit }, 'cache lookup');
Set LOG_LEVEL=debug locally to see them. The pino-pretty transport shown in Structured logging with Pino already makes dev output readable. Keep LOG_LEVEL at info (or higher) everywhere else.
Shared request collection (Bruno). Commit a Bruno collection to the repo so every endpoint has a runnable, versioned request the whole team shares, instead of each developer keeping private Postman tabs. Bruno stores each request as a plain-text .bru file, so it diffs and reviews like code:
apps/api/
bruno/
users/
create-user.bru
get-user.bru
environments/
local.bru # baseUrl + non-secret vars, committed
// bruno/users/get-user.bru
get {
url: {{baseUrl}}/api/v1/users/{{userId}}
headers {
Authorization: Bearer {{token}}
x-correlation-id: {{$randomUUID}}
}
}
Keep secrets (real tokens, API keys) out of the committed environment file: put them in a gitignored local environment, the same rule as .env versus .env.example. When the frontend team hits an API problem, they hand the backend a cURL rather than a Bruno file (see Developer Experience. Local debugging); Bruno is the backend team's own collection.
Database patterns
S&P is polyglot on storage. We choose the database that fits the workload rather than standardising on one engine, and most services use exactly one primary store. The patterns below are written against PostgreSQL (our default), but the primary-key, migration, and security principles port to any relational engine.
Choosing a database
| Engine | Reach for it when | Strength | Watch out for |
|---|---|---|---|
| PostgreSQL (default) | Relational data, transactions, anything with real constraints | Mature SQL, JSONB, extensions (PostGIS, pgvector), strict integrity | Little for most projects -- this is the default for a reason |
| MySQL | A client, CMS, or managed platform mandates it | Ubiquitous, simple replication, well-understood ops | Fewer advanced types than Postgres; don't pick it over Postgres without a reason |
| Firestore | Real-time client sync, offline-first mobile/web, serverless autoscale | Live listeners, tight Firebase/GCP auth integration, no connection pool to manage | Limited queries (no joins, no aggregates), per-read cost model, weak multi-document transactions |
| MongoDB | Genuinely document-shaped data with variable schema | Flexible nested documents, high write throughput, horizontal scaling | Easy to model relational data badly in it; schema drift; joins ($lookup) are awkward |
One primary store per service. Adding a second database (a cache, a search index, an analytics store) is a deliberate architecture decision, not a default. Record it in an ADR.
ORM setup
S&P does not mandate a single ORM. The right choice depends on the project's needs.
| ORM | Approach | Best for | Watch out for |
|---|---|---|---|
| TypeORM | Decorator-based entities | Deep NestJS integration, Active Record or Data Mapper pattern, mature migrations | Complex query performance, eager loading footguns |
| Drizzle | SQL-like query builder | Staying close to SQL, strong TypeScript inference, lightweight | Smaller ecosystem, fewer NestJS-specific integrations |
| Prisma | Schema-first, generated client | Excellent DX for simpler data models, built-in migrations | Performance with complex queries, schema drift on large models |
| Kysely | Type-safe SQL query builder | Full SQL control with TypeScript safety, minimal abstraction | No entity model, manual migration management |
S&P default recommendation: TypeORM for projects that benefit from decorator-based entities and the deep @nestjs/typeorm integration. Drizzle for projects where the team prefers SQL-level control and lighter abstractions. Either is a good choice -- pick one per project and use it everywhere.
One ORM per data layer. Do not mix two ORMs accessing the same database in the same app. If the backend API uses TypeORM, it uses TypeORM everywhere.
PostgreSQL conventions
Database objects use snake_case. This is PostgreSQL convention and avoids quoting issues.
CREATE TABLE user_accounts (
id UUID PRIMARY KEY DEFAULT uuidv7(), -- PG 18+; see Primary keys for older versions
email VARCHAR(255) NOT NULL UNIQUE,
full_name VARCHAR(255) NOT NULL,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Every table gets these columns:
id-- UUIDv7 primary key (see Primary keys below for the rationale and the alternatives).created_at--TIMESTAMPTZ, set on insert, never updated.updated_at--TIMESTAMPTZ, updated on every modification.
Soft deletes -- add deleted_at TIMESTAMPTZ when the business requires retaining deleted records (audit trails, undo functionality). Default to NULL and filter with WHERE deleted_at IS NULL. Do not add soft deletes by default -- they complicate every query and most CRUD apps do not need them.
Foreign keys follow the pattern {referenced_table_singular}_id:
ALTER TABLE orders ADD COLUMN user_id UUID REFERENCES user_accounts(id);
Indexes are named explicitly:
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE UNIQUE INDEX idx_user_accounts_email ON user_accounts(email);
The naming convention is idx_{table}_{column(s)} for regular indexes and idx_{table}_{column(s)} with a UNIQUE qualifier for unique constraints. Explicit names make migration rollbacks and debugging significantly easier than auto-generated names.
Primary keys
Default: UUIDv7. A time-ordered UUID is the default primary key for new relational tables. UUIDv7 encodes a millisecond timestamp in its high bits, which buys three things over random UUIDv4: natural creation-time ordering without an extra column, far better B-tree index locality (new rows append to the end of the index instead of scattering and fragmenting it), and the ability to recover an approximate creation time from the ID itself. PostgreSQL 18+ exposes uuidv7() natively; on earlier versions generate it in the application layer (most ORMs and uuid libraries support it) or add the pg_uuidv7 extension.
ULID was our previous default and remains a valid reference. Before UUIDv7 stabilised, S&P reached for ULID to get the same time-sortable property (see the patient-ID migration example in Source Control). The two solve the same problem: ULID pioneered the time-prefixed 128-bit ID, and UUIDv7 (RFC 9562) is the standardised successor. New work uses UUIDv7 because it stores in a native uuid column, works with existing UUID tooling (ParseUUIDPipe, gen_random_uuid(), DB-native types), and needs no custom base32 encoding. Keep ULID where a system already uses it: the two are interchangeable in intent, and there is no value in churning a working schema.
A bigint identity is fine behind a trust boundary. Internal tables that are never exposed across a boundary (join tables, append-only logs, internal lookups) can use GENERATED ALWAYS AS IDENTITY: it is smaller and faster. The rule is the boundary, not the table.
Never expose a sequential integer in a public API or URL. Auto-increment IDs leak row counts and invite enumeration (/orders/41, then try /orders/42). If a table needs a sequential internal key, give it a separate UUIDv7 as the externally addressable identifier.
Document stores: prefer a meaningful document ID when a natural key exists. A Firestore document ID is part of the document's path, so a direct doc(id).get() on a key you already hold is a single read with no index, whereas falling back to a where(...) query is slower and needs a composite index to scale. When a stable, unique key is known at read time, make it the document ID instead of leaning on Firestore's random auto-ID: the Firebase Auth UID for a user document, the device ID for a device, or a deterministic composition of known fields ({tenantId}_{externalRef}). This turns lookups into direct gets and makes writes idempotent (same key writes the same document, no duplicates). Reserve auto-generated 20-character IDs for documents with no natural key (event logs, append-only entries). Two cautions: never derive the ID from a value that can change (a document ID cannot be renamed, you copy and delete), and avoid monotonically increasing IDs at high write rates, which hotspot Firestore's index. MongoDB is the other way around: you address documents by indexed field queries rather than by path, so the default ObjectId (12 bytes, time-prefixed and sortable) is fine. Don't graft relational UUIDv7 conventions onto either.
Migrations
Migrations are the only way schema changes reach shared environments. Never use ORM auto-sync (synchronize: true) outside of local throwaway databases.
Workflow:
- Make the entity change in code.
- Generate the migration:
pnpm typeorm migration:generate src/migrations/AddUserAvatar(TypeORM) or equivalent. - Review the generated SQL. The generator is a starting point, not the final word -- it may produce unnecessary
ALTERstatements or miss index changes. - Test the migration locally against a real PostgreSQL instance (Docker Compose).
- Commit the migration file. It goes through code review like any other code.
Migration naming: Timestamp prefix with a description: 1716652800000-create-user-accounts.ts. The timestamp ensures ordering; the description makes the migration history readable without opening each file.
Migrations are forward-only in production. Never edit a migration that has been applied to a shared environment. If a migration was wrong, write a new migration that corrects it.
Seeds exist for local development. A new developer should be able to populate a working dataset with pnpm db:seed. Seed scripts are committed and maintained alongside migrations.
Expand and contract for changes that can break running code. A deploy runs the new migration against a database the old code is still talking to (and briefly the reverse). For anything destructive (renaming or dropping a column, tightening a constraint, changing a type) split it across releases: (1) expand: add the new column or table and write to both; (2) backfill in a separate, batched, idempotent migration that does not hold a long lock; (3) contract: switch reads over, then drop the old column in a later release. Never rename a column in the same migration that ships the code depending on the new name.
Backfills are not schema migrations. A backfill that rewrites millions of rows in one statement locks the table and stalls the deploy. Batch it (a few thousand rows per transaction), make it resumable, and run it out of band from the schema change.
Database security
- Use parameterized queries or ORM-generated queries exclusively. Never interpolate user input into SQL strings.
- Database credentials are per-environment, stored in the secrets manager, and rotated regularly.
- Application database users have minimum required permissions. The app connects with a role that can
SELECT,INSERT,UPDATE,DELETEon application tables -- not a superuser. - Enable SSL/TLS for all database connections, including from Cloud Run to Cloud SQL.
- Migrations run as a separate, higher-privileged role than the application runtime role. The app role cannot
ALTERorDROP. - Enforce row-level security (RLS) or an equivalent tenant filter for multi-tenant schemas. A
WHERE tenant_id = ?that lives only in application code is one forgotten query away from a cross-tenant leak. - Bound the connection pool per instance. An unbounded pool turns a traffic spike into
too many connectionsand takes the database down for everyone: size it against the databasemax_connections, not against request volume. - PII and secrets are encrypted at rest (managed-disk encryption at minimum), and access to them is logged.
See Security -- Database security for the full policy.
API design implementation
OpenAPI with @nestjs/swagger
The backend generates an OpenAPI specification that serves as the single source of truth for the API contract. The frontend consumes it to auto-generate typed clients.
Decorate DTOs, not controllers. When DTOs use class-validator decorators, @nestjs/swagger can infer most of the schema automatically via the Swagger plugin. This keeps documentation co-located with the validation logic.
// nest-cli.json -- enable the Swagger plugin
{
"compilerOptions": {
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"introspectComments": true
}
}
]
}
}
Controller decorators for what the plugin cannot infer:
import { Controller, Post, Body, Get, Param } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('Users')
@Controller('api/v1/users')
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
@ApiOperation({ summary: 'Create a new user' })
@ApiResponse({ status: 201, description: 'User created', type: UserResponseDto })
@ApiResponse({ status: 400, description: 'Validation failed' })
@ApiResponse({ status: 409, description: 'Email already exists' })
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
}
@Get(':id')
@ApiOperation({ summary: 'Get user by ID' })
@ApiResponse({ status: 200, type: UserResponseDto })
@ApiResponse({ status: 404, description: 'User not found' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.userService.findOne(id);
}
}
Export the spec as JSON for frontend type generation:
// In main.ts, after creating the Swagger document
const document = SwaggerModule.createDocument(app, config);
// Write to file during build (not at runtime in production)
if (process.env.NODE_ENV !== 'production') {
const fs = await import('fs');
fs.writeFileSync('./swagger/openapi.json', JSON.stringify(document, null, 2));
}
URL path versioning. Use /api/v1/ as the URL prefix. Header-based versioning adds complexity that most S&P projects do not need. When a breaking change is unavoidable, version the affected endpoints -- do not version the entire API surface.
Rate limiting
Every API exposed to the internet needs rate limiting. Use @nestjs/throttler for application-level limits and infrastructure-level controls (Cloud Run concurrency, nginx limit_req) as a second layer.
import { ThrottlerModule } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{ name: 'short', ttl: 1000, limit: 3 }, // 3 req/sec burst protection
{ name: 'medium', ttl: 10000, limit: 20 }, // 20 req/10sec sustained
{ name: 'long', ttl: 60000, limit: 100 }, // 100 req/min global
],
}),
],
})
export class AppModule {}
Apply stricter limits on sensitive endpoints (login, password reset, OTP verification) using @Throttle per route. Return 429 Too Many Requests with a Retry-After header.
Rate limiting protects against more than attacks -- it also catches accidental self-DDoS from a frontend bug that loops API calls, and keeps costs predictable on pay-per-request platforms.
CORS configuration
Configure CORS explicitly per environment. Never use a wildcard origin (*) in production.
app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
});
Common mistakes to avoid:
Access-Control-Allow-Origin: *withcredentials: true-- browsers reject this combination. Some teams "fix" it by reflecting the request'sOriginheader verbatim, which is effectively no CORS protection at all.- Including
localhostorigins in production configuration. - Allowing methods the API does not need -- if an endpoint only supports
GETandPOST, do not allowDELETE.
Polyglot note: Express uses the cors package with identical options. .NET configures CORS via AddCors / UseCors in Program.cs. Go uses middleware like rs/cors. The configuration shape differs but the rules are the same: explicit origins, no wildcards in production, restrict methods to what is actually used.
Testing setup
Integration tests
Test your API endpoints through HTTP using Supertest, against a real PostgreSQL database running in Testcontainers. No mocking the database. No SQLite stand-ins. The database under test is the same engine you run in production.
Bootstrap a test module:
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../src/user/user.module';
describe('UserController (e2e)', () => {
let app: INestApplication;
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('test_db')
.start();
const moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: container.getHost(),
port: container.getMappedPort(5432),
username: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
autoLoadEntities: true,
synchronize: true, // Acceptable in tests -- schema is throwaway
}),
UserModule,
],
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
afterAll(async () => {
await app.close();
await container.stop();
});
it('POST /api/users creates a user and returns 201', async () => {
const payload = { email: 'test@example.com', name: 'Test User' };
const response = await request(app.getHttpServer())
.post('/api/users')
.send(payload)
.expect(201);
expect(response.body).toMatchObject({
email: 'test@example.com',
name: 'Test User',
});
});
});
Test isolation: Each test creates its own data and cleans up after itself. No shared seeds across tests. No reliance on insertion order. Tests run in parallel -- shared state is the number one cause of flaky integration tests. Use beforeEach to truncate tables or wrap each test in a transaction that rolls back.
What to verify at this level:
- Response data -- correct status code and body.
- State changes -- the database reflects the expected change.
- Outgoing calls -- external services received the expected request (intercept with MSW or nock).
- Messages and events -- the expected message was placed on the queue.
- Error handling -- invalid input returns the correct error response and does not corrupt state.
Test runner configuration
Jest is the current standard across S&P projects. When NestJS 12 delivers first-class Vitest support, new projects should adopt Vitest for its faster execution and native ESM support. Existing projects migrate opportunistically -- do not rewrite a working test suite for a speed improvement.
Migrating from Jest to Vitest (when the time comes):
- Install Vitest and
unplugin-swc(for NestJS decorator support). - Replace
jest.config.tswithvitest.config.ts. - Update imports:
describe,it,expectfromvitestinstead of global Jest. - Replace
jest.fn()withvi.fn(),jest.spyOnwithvi.spyOn. - Run the existing test suite -- most tests pass without changes beyond import swaps.
- Remove Jest dependencies.
Do not maintain both runners in the same project. Pick one and use it everywhere.
Polyglot note: .NET uses xUnit or NUnit with WebApplicationFactory<T> for integration tests -- the same pattern of spinning up the app in-process and making HTTP requests against it. Java/Spring uses @SpringBootTest with TestRestTemplate or MockMvc, plus Testcontainers for database tests. Go uses httptest.NewServer with table-driven tests. The principle is universal: test through HTTP against a real database.
Resources
S&P internal:
Industry references:
- Zalando RESTful API Guidelines -- API design patterns and naming conventions
- Node.js Best Practices -- Production-grade Node.js patterns
- Tao of Node -- Architecture and structure for Node.js projects
- 12-Factor App -- Application deployment principles
- NestJS Documentation -- Framework reference
- TypeORM Documentation -- ORM reference (decorator-based)
- Drizzle Documentation -- ORM reference (SQL-like)
- Spectral API Rulesets -- OpenAPI linting
- RFC 9457 -- Problem Details for HTTP APIs -- Standard error response format
- RFC 9562 -- Universally Unique IDentifiers (UUID) -- The UUIDv7 time-ordered ID spec
- ULID specification -- Lexicographically sortable IDs (our previous default, still a valid reference)