NestJS #
NestJS is a Node.js backend framework combining Object-Oriented Programming, Functional Programming, and Reactive Programming in one structured architecture. Born from the “Angular for backend” philosophy, NestJS brings the module system, decorators, and dependency injection to the Node.js world — three pillars familiar to Spring, Laravel, or .NET developers, yet still lightweight and idiomatic in TypeScript.
For local development, NestJS has unique challenges: its codebase is large, its dependencies are numerous (TypeScript, ts-node, the Nest CLI, reflector metadata, RxJS), and hot reload must work inside a container without losing TypeScript state. Docker is the most solid answer — you standardize the toolchain, dependencies, and supporting services in one stack that can be re-run with a single command.
This article covers a Docker Compose setup for NestJS local development in depth: multi-stage Dockerfiles, hot reload via nest start --watch, the module structure, controllers, services, DTOs, validation pipes, dependency injection, TypeORM with Postgres, and production-grade best practices.
Prerequisites #
Make sure the host has installed:
- Docker and Docker Compose (latest versions, v2.20+)
- Node.js 20+ — for host tooling (optional, only if you want to run the Nest CLI without Docker)
- The Nest CLI —
npm i -g @nestjs/cli(optional, for manual scaffolding)
The assumed NestJS project structure:
nestjs-app/
├── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── common/
│ │ ├── filters/
│ │ ├── interceptors/
│ │ ├── guards/
│ │ └── pipes/
│ ├── config/
│ │ ├── database.config.ts
│ │ └── validation.schema.ts
│ ├── users/
│ │ ├── users.module.ts
│ │ ├── users.controller.ts
│ │ ├── users.service.ts
│ │ ├── dto/
│ │ │ ├── create-user.dto.ts
│ │ │ └── update-user.dto.ts
│ │ └── entities/
│ │ └── user.entity.ts
│ └── health/
│ └── health.controller.ts
├── test/
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env
├── nest-cli.json
├── package.json
├── package-lock.json
└── tsconfig.json
NestJS has a powerful CLI:nest new,nest g module users,nest g controller users,nest g service users. The CLI generates files with correct boilerplate and updates modules automatically. Use the CLI for new projects, manual edits for existing projects.
Multi-Stage Dockerfile #
NestJS is TypeScript — source code needs compiling to JavaScript before running. For development, we run directly via ts-node-dev or nest start --watch (more recommended). For production, we compile to dist/. The Dockerfile below separates the two.
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
# Copy the manifest first for layer caching
COPY package.json package-lock.json ./
# Install all dependencies (including devDependencies)
RUN npm install --no-audit --no-fund
# Copy TypeScript and Nest CLI configuration
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
# Copy source code
COPY src ./src
# ----- Production builder -----
FROM deps AS builder
RUN npm run build
# ----- Production runner -----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
RUN addgroup -S app && adduser -S app -G app
# Copy only production dependencies
COPY package.json package-lock.json ./
RUN npm install --omit=dev --no-audit --no-fund
# Copy the build output
COPY --from=builder --chown=app:app /app/dist ./dist
USER app
EXPOSE 3000
CMD ["node", "dist/main.js"]
Stage Explanations #
The deps stage — the development foundation. Installs all dependencies (including TypeScript, the Nest CLI, ts-node) and copies source code. This is the stage used for daily docker compose up.
The builder stage — compiles TypeScript to dist/. Useful for testing the production image locally or in CI.
The runner stage — a slim image. Only includes dist/ and production dependencies. A non-root user for security.
Why multi-stage? Local dev uses the deps stage (has hot reload); production builds and tests can use the builder or runner stages without changing the Dockerfile.
Don’t copynode_modulesfrom thedepsstage to therunnerstage. Thedepsstage contains devDependencies that must not exist in production. Always runnpm install --omit=devin therunnerstage so only thedependenciesinpackage.jsonget installed.
docker-compose.yml for Development #
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile
target: deps
image: nestjs-app:dev
container_name: nestjs-dev
command: npm run start:dev
working_dir: /app
ports:
- "3000:3000"
volumes:
- ./src:/app/src
- ./test:/app/test
- /app/node_modules # anonymous volume
- /app/dist # anonymous volume for build output
environment:
NODE_ENV: development
PORT: 3000
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_USER: app
DATABASE_PASSWORD: dev
DATABASE_NAME: nestjs_dev
REDIS_HOST: cache
REDIS_PORT: 6379
JWT_SECRET: dev-secret-change-me
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:16-alpine
container_name: nestjs-db
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: dev
POSTGRES_DB: nestjs_dev
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d nestjs_dev"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
cache:
image: redis:7-alpine
container_name: nestjs-cache
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
ports:
- "6379:6379"
volumes:
db-data:
Service Explanations #
api — the NestJS service. target: deps selects the development stage. command: npm run start:dev runs the start:dev script in package.json, which we set to nest start --watch. Watch mode watches file changes in src/ and restarts the server automatically.
Two anonymous volumes for node_modules and dist — both mandatory:
/app/node_modulesprevents the host from overwriting dependencies/app/distprevents locally builtdist/output from riding along in the container (which could conflict with watch mode)
db and cache — supporting services with healthchecks. The NestJS app won’t start until Postgres and Redis are truly ready.
Architecture Diagram #
flowchart TB
subgraph Host
Dev[Developer]
Code[Source code<br/>src/*.ts]
end
subgraph Docker
App[Container: api<br/>NestJS + watch mode]
DB[(Postgres 16)]
Cache[(Redis 7)]
end
Dev -->|edit .ts file| Code
Code -->|bind mount| App
App -->|query SQL| DB
App -->|cache session/rate limit| Cache
App -.->|healthcheck| DB
App -.->|healthcheck| CacheHot Reload with nest start –watch #
NestJS has built-in watch mode. Since Nest CLI version 9, watch mode uses tsc watch, which monitors TypeScript changes, recompiles, and restarts the server.
package.json:
{
"name": "nestjs-app",
"version": "1.0.0",
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug 0.0.0.0:9229 --watch",
"start:prod": "node dist/main",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.3.10",
"@nestjs/core": "^10.3.10",
"@nestjs/platform-express": "^10.3.10",
"@nestjs/config": "^3.2.3",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"typeorm": "^0.3.20",
"pg": "^8.12.0",
"class-validator": "^0.14.1",
"class-transformer": "^0.5.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"ioredis": "^5.4.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.4",
"@nestjs/testing": "^10.3.10",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.12",
"jest": "^29.7.0",
"supertest": "^7.0.0",
"ts-jest": "^29.2.2",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
}
}
nest-cli.json:
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
tsconfig.json:
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true
}
}
emitDecoratorMetadata: trueandexperimentalDecorators: trueare mandatory for NestJS. Decorators don’t work in NestJS without these two options. When creating a project withnest new, the CLI includes both by default.
.dockerignore:
node_modules
dist
coverage
.git
.gitignore
.env
.env.local
Dockerfile
docker-compose.yml
.dockerignore
README.md
*.md
test
NestJS Architecture — Module, Controller, Service #
NestJS has three core concepts that set it apart from other Node.js frameworks: Module, Controller, and Service — plus Dependency Injection, which binds them together.
Module #
A module is an organizational unit. Each module groups controllers, services, and other providers with high cohesion. The root module (AppModule) imports all other modules.
src/app.module.ts:
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
// Global ConfigModule, reads from .env
ConfigModule.forRoot({
isGlobal: true,
cache: true,
}),
// TypeORM async config — reads from ConfigService
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get<string>('DATABASE_HOST'),
port: config.get<number>('DATABASE_PORT'),
username: config.get<string>('DATABASE_USER'),
password: config.get<string>('DATABASE_PASSWORD'),
database: config.get<string>('DATABASE_NAME'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: config.get<string>('NODE_ENV') === 'development',
logging: ['error', 'warn'],
}),
}),
UsersModule,
HealthModule,
],
})
export class AppModule {}
Controller #
A controller is the layer that receives HTTP requests, validates input, and returns responses. The @Controller, @Get, @Post decorators are declared above classes and methods.
src/users/users.controller.ts:
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@Controller('api/v1/users')
@UseGuards(JwtAuthGuard) // all endpoints need auth
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
async findAll(
@Query('limit') limit = 20,
@Query('offset') offset = 0,
) {
return this.usersService.findAll({ limit: +limit, offset: +offset });
}
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.usersService.findOne(+id);
return { data: user };
}
@Post()
@HttpCode(HttpStatus.CREATED)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
async create(@Body() createUserDto: CreateUserDto) {
const user = await this.usersService.create(createUserDto);
return { data: user };
}
@Put(':id')
async update(
@Param('id') id: string,
@Body() updateUserDto: UpdateUserDto,
) {
const updated = await this.usersService.update(+id, updateUserDto);
return { data: updated };
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string) {
await this.usersService.remove(+id);
}
}
Service #
Services contain business logic. NestJS injects services into controllers via the constructor — that’s its Dependency Injection.
src/users/users.service.ts:
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async findAll(options: { limit: number; offset: number }) {
const [data, total] = await this.userRepository.findAndCount({
take: options.limit,
skip: options.offset,
order: { id: 'ASC' },
});
return { data, total };
}
async findOne(id: number): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`User with ID ${id} not found`);
}
return user;
}
async create(createUserDto: CreateUserDto): Promise<User> {
const existing = await this.userRepository.findOne({
where: { email: createUserDto.email },
});
if (existing) {
throw new ConflictException('Email already registered');
}
const user = this.userRepository.create(createUserDto);
return this.userRepository.save(user);
}
async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id); // throws 404 if missing
Object.assign(user, updateUserDto);
return this.userRepository.save(user);
}
async remove(id: number): Promise<void> {
const user = await this.findOne(id);
await this.userRepository.remove(user);
}
}
Entities and DTOs #
src/users/entities/user.entity.ts — a TypeORM entity:
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Index({ unique: true })
@Column({ type: 'varchar', length: 255 })
email: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 255 })
passwordHash: string;
@Column({ type: 'varchar', length: 20, default: 'user' })
role: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
src/users/dto/create-user.dto.ts — a DTO with class-validator:
import {
IsEmail,
IsString,
MinLength,
MaxLength,
IsOptional,
IsEnum,
} from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateUserDto {
@IsEmail()
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsString()
@MinLength(8)
@MaxLength(128)
password: string;
@IsOptional()
@IsEnum(['user', 'admin'])
role: 'user' | 'admin' = 'user';
}
src/users/users.module.ts — the module binding it all together:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
Dependency Injection Diagram #
flowchart LR
AppModule[AppModule] -->|imports| UsersModule
UsersModule -->|controllers| UsersController
UsersModule -->|providers| UsersService
UsersModule -->|imports| TypeOrmModule
TypeOrmModule -->|provides Repository| Repo[(User Repository)]
UsersController -->|DI: constructor| UsersService
UsersService -->|DI: @InjectRepository| RepoNotice the constructors inUsersControllerandUsersService— there’s nonew UsersService(). NestJS injects the instance. This isn’t magic: NestJS reads the parameter types from TypeScript (UsersService,Repository<User>) viareflect-metadata, then resolves them from the container. The result: easier-to-test code (you can swap the service with a mock in unit tests) and loose coupling.
The Main Entry Point #
src/main.ts:
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'], // default logger
});
const config = app.get(ConfigService);
// Global validation pipe for all DTOs
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip fields not in the DTO
forbidNonWhitelisted: true, // error 400 on extra fields
transform: true, // transform the payload into class instances
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// Optional global prefix: app.setGlobalPrefix('api/v1');
// Listen on 0.0.0.0 so the container is reachable from the host
const port = config.get<number>('PORT') || 3000;
await app.listen(port, '0.0.0.0');
Logger.log(`Application running on port ${port}`, 'Bootstrap');
}
bootstrap().catch((err) => {
// eslint-disable-next-line no-console
console.error('Failed to start application', err);
process.exit(1);
});
await app.listen(port, '0.0.0.0')—0.0.0.0is mandatory for Docker. If you listen on127.0.0.1, the container only accepts connections from inside the container, not from the host.
ValidationPipewithwhitelist: trueis important protection — without whitelist, client-sent bodies can contain extra fields that get persisted to the database.forbidNonWhitelisted: truerejects requests with extra fields, returning an error 400.transform: trueconverts JSON payloads into DTO class instances, soclass-validatordecorators can run.
Health Check Endpoints #
Health checks are mandatory for containerized apps. Docker, Kubernetes, and load balancers ping this endpoint to know whether the app is ready for requests.
src/health/health.controller.ts:
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
) {}
@Get()
@HealthCheck()
check() {
return this.health.check([
() => this.db.pingCheck('database'),
]);
}
}
Add @nestjs/terminus to dependencies and register HealthModule in AppModule.
Testing with @nestjs/testing #
NestJS has built-in testing helpers that handle the dependency injection container for tests.
src/users/users.service.spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
describe('UsersService', () => {
let service: UsersService;
let mockRepository: any;
beforeEach(async () => {
mockRepository = {
find: jest.fn(),
findOne: jest.fn(),
findAndCount: jest.fn(),
create: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: getRepositoryToken(User), useValue: mockRepository },
],
}).compile();
service = module.get<UsersService>(UsersService);
});
it('should throw NotFoundException for missing user', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
});
it('should throw ConflictException for duplicate email', async () => {
mockRepository.findOne.mockResolvedValue({ id: 1, email: '[email protected]' });
await expect(
service.create({ email: '[email protected]', name: 'Test', password: '12345678' })
).rejects.toThrow(ConflictException);
});
});
@nestjs/testing creates a testing module similar to the production module. You can swap providers with mocks via useValue, useClass, or useFactory.
Build and Run #
# Build the image
docker compose build
# Run all services
docker compose up -d
# View logs
docker compose logs -f api
# Test an endpoint
curl http://localhost:3000/api/v1/users
# Check health
curl http://localhost:3000/health
# Stop
docker compose down
# Reset the database (remove volumes)
docker compose down -v
Access:
| Service | URL |
|---|---|
| API | http://localhost:3000 |
| Health | http://localhost:3000/health |
| Postgres | localhost:5432 (user app, password dev) |
| Redis | localhost:6379 |
To debug NestJS from inside the container, add a debug port to the
apiservice indocker-compose.yml:ports: - "3000:3000" - "9229:9229" # Node.js debuggerThen in VS Code, create a launch configuration with
port: 9229andaddress: localhost. Set a breakpoint insrc/users/users.service.ts, then trigger a request — VS Code will break.
Best Practices #
1. Always Separate Modules #
Each domain (users, orders, products) must have its own module. Modules are independent and communicate via exported services. Avoid a “god module” containing all controllers and services.
2. Use DTOs and Class-Validator #
DTOs validate input and convert it into class instances. Without DTOs, req.body is a plain object without validation, and decorators don’t work.
3. Exception Filters for Consistent Responses #
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
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';
response.status(status).json({
statusCode: status,
path: request.url,
method: request.method,
message,
timestamp: new Date().toISOString(),
});
}
}
Register in main.ts: app.useGlobalFilters(new AllExceptionsFilter()).
4. Use ConfigService, Not process.env #
// ANTI-PATTERN
const port = process.env.PORT || 3000;
// CORRECT
const config = app.get(ConfigService);
const port = config.get<number>('PORT') || 3000;
ConfigService validates types and injects the config module. Safer and test-friendly.
5. Non-Root Users in Production #
The runner stage in the Dockerfile already runs the container as the app user. Prevents privilege escalation.
6. Use Pino for Logging #
The default NestJS logger is fine for development, but nestjs-pino is faster and supports structured logging.
7. Healthcheck Endpoints #
The /health endpoint must be pingable by Docker. Use @nestjs/terminus to check dependencies (Postgres, Redis).
When NestJS, When Express? #
| Criterion | Choose NestJS | Choose Express |
|---|---|---|
| Large team structure | ✓ | – |
| Need a DI container | ✓ | – |
| Codebase > 50k LOC | ✓ | – |
| Microservices with consistent patterns | ✓ | – |
| Small scripts / prototypes | – | ✓ |
| Lightweight API gateways | – | ✓ |
| Event-driven apps | ✓ (RxJS) | – |
| Need structural flexibility | – | ✓ |
NestJS isn’t Express’s “successor” — they’re different tools for different scales. Express is ideal for a 50-line script; NestJS is ideal for a 200+ file monorepo with 10 developers. Choosing a “bigger” framework for a small problem is over-engineering.
Troubleshooting #
Watch Mode Not Restarting #
Make sure nest start --watch is in the start:dev script, and the ./src:/app/src bind mount is in Compose. Check the logs: docker compose logs api — there should be a [Nest] Starting Nest application message on every change.
TypeORM Cannot Connect #
Error: connect ECONNREFUSED 127.0.0.1:5432
Wrong hostname. Inside the container, the hostname is the Compose service name, not localhost. Set DATABASE_HOST: db (not localhost).
reflect-metadata Not Found #
Make sure import 'reflect-metadata' is at the very top of main.ts. Without this import, decorators have no metadata and DI fails to resolve.
Validation Pipe Not Rejecting Foreign Fields #
Add whitelist: true and forbidNonWhitelisted: true to the ValidationPipe. Without whitelist, body fields not in the DTO slip through.
Summary #
- NestJS is ideal for teams needing structure, DI, and a module system. Hot reload via
nest start --watchkeeps iteration fast without leaving the container.- Multi-stage Dockerfiles: a
depsstage for development (all dependencies), abuilderstage for compiling, a slimrunnerstage for production with a non-root user.- The anonymous volumes
/app/node_modulesand/app/distare mandatory — they protect dependencies and build output from host overrides.- The module system organizes the codebase into high-cohesion units. Each domain has its own module.
- Decorators (
@Controller,@Get,@Injectable) are runtime metadata. NeedexperimentalDecoratorsandemitDecoratorMetadataintsconfig.json.- Dependency Injection via constructors — NestJS resolves and injects services. No
new Service()in application code.- DTOs + class-validator for input validation. A global
ValidationPipewithwhitelist: trueandforbidNonWhitelisted: truekeeps stray fields out.- TypeORM entities represent database tables. Repositories are injected via
@InjectRepository(Entity).- Global exception filters for consistent error responses across all endpoints.
- Health endpoints with
@nestjs/terminusping the database. Mandatory for container orchestration.- Testing with
@nestjs/testing+ mock repositories. No need to start Postgres for unit tests.- When to choose NestJS: large codebases, large teams, need DI. When to choose Express: small scripts, prototypes, lightweight API gateways.
- Troubleshooting: watch mode (check volumes), DB connections (service hostname, not localhost), validation (whitelist), decorators (reflect-metadata + tsconfig).
- Production: non-root users, healthchecks, structured logging, separate Dockerfile stages, environment variables via ConfigService.