Skip to main content

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:

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.

TypePatternExample
Service*.service.tsuser.service.ts
Controller*.controller.tsuser.controller.ts
Module*.module.tsuser.module.ts
Entity / Model*.entity.tsuser.entity.ts
DTO*.dto.tscreate-user.dto.ts
Guard*.guard.tsauth.guard.ts
Interceptor*.interceptor.tslogging.interceptor.ts
Pipe*.pipe.tsvalidation.pipe.ts
Filter*.filter.tshttp-exception.filter.ts
Decorator*.decorator.tscurrent-user.decorator.ts
Utility*.util.tsdate-format.util.ts
Spec (test)*.spec.tsuser.service.spec.ts
E2E test*.e2e-spec.tsuser.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.

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.


Database patterns

ORM setup

S&P does not mandate a single ORM. The right choice depends on the project's needs.

ORMApproachBest forWatch out for
TypeORMDecorator-based entitiesDeep NestJS integration, Active Record or Data Mapper pattern, mature migrationsComplex query performance, eager loading footguns
DrizzleSQL-like query builderStaying close to SQL, strong TypeScript inference, lightweightSmaller ecosystem, fewer NestJS-specific integrations
PrismaSchema-first, generated clientExcellent DX for simpler data models, built-in migrationsPerformance with complex queries, schema drift on large models
KyselyType-safe SQL query builderFull SQL control with TypeScript safety, minimal abstractionNo 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 gen_random_uuid(),
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, generated by the database. UUIDv7 is preferred over UUIDv4 because it encodes a timestamp, giving you natural ordering without an extra column, better B-tree index locality (new rows cluster at the end instead of scattering randomly), and the ability to extract an approximate creation time from the ID itself. PostgreSQL 18+ supports uuidv7() natively; on earlier versions, use pgcrypto or generate UUIDs in the application layer.
  • 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.

Migrations

Migrations are the only way schema changes reach shared environments. Never use ORM auto-sync (synchronize: true) outside of local throwaway databases.

Workflow:

  1. Make the entity change in code.
  2. Generate the migration: pnpm typeorm migration:generate src/migrations/AddUserAvatar (TypeORM) or equivalent.
  3. Review the generated SQL. The generator is a starting point, not the final word -- it may produce unnecessary ALTER statements or miss index changes.
  4. Test the migration locally against a real PostgreSQL instance (Docker Compose).
  5. 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.

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, DELETE on application tables -- not a superuser.
  • Enable SSL/TLS for all database connections, including from Cloud Run to Cloud SQL.

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: * with credentials: true -- browsers reject this combination. Some teams "fix" it by reflecting the request's Origin header verbatim, which is effectively no CORS protection at all.
  • Including localhost origins in production configuration.
  • Allowing methods the API does not need -- if an endpoint only supports GET and POST, do not allow DELETE.

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:

  1. Response data -- correct status code and body.
  2. State changes -- the database reflects the expected change.
  3. Outgoing calls -- external services received the expected request (intercept with MSW or nock).
  4. Messages and events -- the expected message was placed on the queue.
  5. 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):

  1. Install Vitest and unplugin-swc (for NestJS decorator support).
  2. Replace jest.config.ts with vitest.config.ts.
  3. Update imports: describe, it, expect from vitest instead of global Jest.
  4. Replace jest.fn() with vi.fn(), jest.spyOn with vi.spyOn.
  5. Run the existing test suite -- most tests pass without changes beyond import swaps.
  6. 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: