
Nestjs Best Practices
- 23.3k installs
- 232 repo stars
- Updated July 23, 2026
- kadajett/agent-nestjs-skills
NestJS Best Practices is an agent skill for building and optimizing Node.js backend applications using the NestJS framework with established architectural patterns.
About
NestJS Best Practices is an agent skill providing structured guidance for building backend applications with the NestJS framework. It covers architecture, dependency injection, error handling, security, performance, testing, database patterns, API design, microservices, and DevOps. Developers use it when building scalable Node.js backend services or when refactoring applications to follow established NestJS patterns.
- 10 structured sections covering NestJS architecture and patterns
- Supported by 6 agentic platforms including Claude Code and Cursor
- Rule-based format with clear incorrect/correct examples
Nestjs Best Practices by the numbers
- 23,268 all-time installs (skills.sh)
- +812 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #46 of 4,386 Backend & APIs skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
nestjs-best-practices capabilities & compatibility
- Capabilities
- architecture design · error handling · security patterns · performance optimization · testing · database design · api design · microservices
What nestjs-best-practices says it does
A structured repository for creating and maintaining NestJS Best Practices optimized for agents and LLMs.
npx skills add https://github.com/kadajett/agent-nestjs-skills --skill nestjs-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 23.3k |
|---|---|
| repo stars | ★ 232 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 23, 2026 |
| Repository | kadajett/agent-nestjs-skills ↗ |
What are NestJS production architecture best practices?
Build scalable backend applications following NestJS architectural best practices
Who is it for?
TypeScript backend developers writing or reviewing NestJS APIs who want enforced module, DI, security, and performance patterns.
Skip if: Teams on Express-only stacks or front-end React projects without a NestJS server layer.
When should I use this skill?
User writes, reviews, or refactors NestJS modules, controllers, services, guards, or interceptors.
What you get
Refactored NestJS modules, DI-correct services and controllers, and code aligned to 40 prioritized rules.
- refactored NestJS source files
- architecture-compliant module structure
By the numbers
- 10 sections covering architecture, DI, error handling, security, performance, testing, database, API design, microservic
Files
NestJS Best Practices
Comprehensive best practices guide for NestJS applications. Contains 40 rules across 10 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Writing new NestJS modules, controllers, or services
- Implementing authentication and authorization
- Reviewing code for architecture and security issues
- Refactoring existing NestJS codebases
- Optimizing performance or database queries
- Building microservices architectures
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Architecture | CRITICAL | arch- |
| 2 | Dependency Injection | CRITICAL | di- |
| 3 | Error Handling | HIGH | error- |
| 4 | Security | HIGH | security- |
| 5 | Performance | HIGH | perf- |
| 6 | Testing | MEDIUM-HIGH | test- |
| 7 | Database & ORM | MEDIUM-HIGH | db- |
| 8 | API Design | MEDIUM | api- |
| 9 | Microservices | MEDIUM | micro- |
| 10 | DevOps & Deployment | LOW-MEDIUM | devops- |
Quick Reference
1. Architecture (CRITICAL)
arch-avoid-circular-deps- Avoid circular module dependenciesarch-feature-modules- Organize by feature, not technical layerarch-module-sharing- Proper module exports/imports, avoid duplicate providersarch-single-responsibility- Focused services over "god services"arch-use-repository-pattern- Abstract database logic for testabilityarch-use-events- Event-driven architecture for decoupling
2. Dependency Injection (CRITICAL)
di-avoid-service-locator- Avoid service locator anti-patterndi-interface-segregation- Interface Segregation Principle (ISP)di-liskov-substitution- Liskov Substitution Principle (LSP)di-prefer-constructor-injection- Constructor over property injectiondi-scope-awareness- Understand singleton/request/transient scopesdi-use-interfaces-tokens- Use injection tokens for interfaces
3. Error Handling (HIGH)
error-use-exception-filters- Centralized exception handlingerror-throw-http-exceptions- Use NestJS HTTP exceptionserror-handle-async-errors- Handle async errors properly
4. Security (HIGH)
security-auth-jwt- Secure JWT authenticationsecurity-validate-all-input- Validate with class-validatorsecurity-use-guards- Authentication and authorization guardssecurity-sanitize-output- Prevent XSS attackssecurity-rate-limiting- Implement rate limiting
5. Performance (HIGH)
perf-async-hooks- Proper async lifecycle hooksperf-use-caching- Implement caching strategiesperf-optimize-database- Optimize database queriesperf-lazy-loading- Lazy load modules for faster startup
6. Testing (MEDIUM-HIGH)
test-use-testing-module- Use NestJS testing utilitiestest-e2e-supertest- E2E testing with Supertesttest-mock-external-services- Mock external dependencies
7. Database & ORM (MEDIUM-HIGH)
db-use-transactions- Transaction managementdb-avoid-n-plus-one- Avoid N+1 query problemsdb-use-migrations- Use migrations for schema changes
8. API Design (MEDIUM)
api-use-dto-serialization- DTO and response serializationapi-use-interceptors- Cross-cutting concernsapi-versioning- API versioning strategiesapi-use-pipes- Input transformation with pipes
9. Microservices (MEDIUM)
micro-use-patterns- Message and event patternsmicro-use-health-checks- Health checks for orchestrationmicro-use-queues- Background job processing
10. DevOps & Deployment (LOW-MEDIUM)
devops-use-config-module- Environment configurationdevops-use-logging- Structured loggingdevops-graceful-shutdown- Zero-downtime deployments
How to Use
Read individual rule files for detailed explanations and code examples:
rules/arch-avoid-circular-deps.md
rules/security-validate-all-input.md
rules/_sections.mdEach rule file contains:
- Brief explanation of why it matters
- Incorrect code example with explanation
- Correct code example with explanation
- Additional context and references
Full Compiled Document
For the complete guide with all rules expanded: AGENTS.md
name: Branch Protection
on:
pull_request:
branches: [main]
jobs:
check-branch:
runs-on: ubuntu-latest
steps:
- name: Block docs branch merge to main
if: github.head_ref == 'docs'
run: |
echo "::error::Merging 'docs' branch into 'main' is not allowed."
echo ""
echo "The 'docs' branch contains the documentation website which should"
echo "remain separate from the main skill files to keep installations lightweight."
echo ""
echo "If you need to sync changes, cherry-pick specific commits instead."
exit 1
- name: Branch check passed
if: github.head_ref != 'docs'
run: echo "Branch check passed - not merging from docs branch"
name: Deploy to GitHub Pages
on:
push:
branches: [docs]
paths:
- 'website/**'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: website/package-lock.json
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: npm ci
working-directory: website
- name: Build
run: npm run build
working-directory: website
- name: Copy index.html to 404.html for SPA routing
run: cp website/dist/index.html website/dist/404.html
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: website/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
# Dependencies
node_modules/
# Website (lives on docs branch)
website/
# Build outputs
dist/
*.js
*.d.ts
*.js.map
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
{
"version": "1.1.0",
"organization": "NestJS Best Practices",
"date": "January 2026",
"abstract": "Comprehensive best practices and architecture guide for NestJS applications, designed for AI agents and LLMs. Contains 40 rules across 10 categories, prioritized by impact from critical (architecture, dependency injection) to incremental (DevOps patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://docs.nestjs.com",
"https://github.com/nestjs/nest",
"https://typeorm.io",
"https://github.com/typestack/class-validator",
"https://github.com/goldbergyoni/nodebestpractices"
]
}
NestJS Best Practices
A structured repository for creating and maintaining NestJS Best Practices optimized for agents and LLMs.
Installation
Install this skill using skills:
# GitHub shorthand
npx skills add Kadajett/agent-nestjs-skills
# Install globally (available across all projects)
npx skills add Kadajett/agent-nestjs-skills --global
# Install for specific agents
npx skills add Kadajett/agent-nestjs-skills -a claude-code -a cursorSupported Agents
- Claude Code
- OpenCode
- Codex
- Cursor
- Antigravity
- Roo Code
Structure
rules/- Individual rule files (one per rule)_sections.md- Section metadata (titles, impacts, descriptions)_template.md- Template for creating new rulesarea-description.md- Individual rule filesscripts/- Build scripts and utilitiesmetadata.json- Document metadata (version, organization, abstract)- __
AGENTS.md__ - Compiled output (generated)
Getting Started
1. Install dependencies:
cd scripts && npm install2. Build AGENTS.md from rules:
npm run build
# or
./scripts/build.shCreating a New Rule
1. Copy rules/_template.md to rules/area-description.md 2. Choose the appropriate area prefix:
arch-for Architecture (Section 1)di-for Dependency Injection (Section 2)error-for Error Handling (Section 3)security-for Security (Section 4)perf-for Performance (Section 5)test-for Testing (Section 6)db-for Database & ORM (Section 7)api-for API Design (Section 8)micro-for Microservices (Section 9)devops-for DevOps & Deployment (Section 10)
3. Fill in the frontmatter and content 4. Ensure you have clear examples with explanations 5. Run the build script to regenerate AGENTS.md
Rule File Structure
Each rule file should follow this structure:
---
title: Rule Title Here
impact: MEDIUM
impactDescription: Optional description
tags: tag1, tag2, tag3
---
## Rule Title Here
Brief explanation of the rule and why it matters.
**Incorrect (description of what's wrong):**
// Bad code example
**Correct (description of what's right):**
// Good code example
Optional explanatory text after examples.
Reference: [NestJS Documentation](https://docs.nestjs.com)
## File Naming Convention
- Files starting with `_` are special (excluded from build)
- Rule files: `area-description.md` (e.g., `arch-avoid-circular-deps.md`)
- Section is automatically inferred from filename prefix
- Rules are sorted alphabetically by title within each section
- IDs (e.g., 1.1, 1.2) are auto-generated during build
## Impact Levels
| Level | Description |
|-------|-------------|
| CRITICAL | Violations cause runtime errors, security vulnerabilities, or architectural breakdown |
| HIGH | Significant impact on reliability, security, or maintainability |
| MEDIUM-HIGH | Notable impact on quality and developer experience |
| MEDIUM | Moderate impact on code quality and best practices |
| LOW-MEDIUM | Minor improvements for consistency and maintainability |
## Scripts
- `npm run build` (in scripts/) - Compile rules into AGENTS.md
## Contributing
When adding or modifying rules:
1. Use the correct filename prefix for your section
2. Follow the `_template.md` structure
3. Include clear bad/good examples with explanations
4. Add appropriate tags
5. Run the build script to regenerate AGENTS.md
6. Rules are automatically sorted by title - no need to manage numbers!
## Documentation Website
The documentation website source code lives on the [`docs` branch](https://github.com/Kadajett/agent-nestjs-skills/tree/docs/website). This separation keeps the skill installation lightweight while maintaining the full documentation site.
To contribute to the website:
git checkout docs cd website npm install npm run dev
## Acknowledgments
- Inspired by the [Vercel React Best Practices](https://github.com/vercel-labs/agent-skills) skill structure
- Compatible with [skills](https://github.com/vercel-labs/skills) for easy installation across coding agents
## Compatible Agents
These NestJS skills work with:
- [Claude Code](https://claude.ai/code) - Anthropic's official CLI
- [AdaL](https://sylph.ai/adal) - Self-evolving AI coding agent with MCP support
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Architecture (arch)
Impact: CRITICAL Description: Proper module organization and dependency management are the foundation of maintainable NestJS applications. Circular dependencies and god services are the #1 architecture killer.
2. Dependency Injection (di)
Impact: CRITICAL Description: NestJS's IoC container is powerful but can be misused. Understanding scopes, injection tokens, and proper patterns is essential for testable code.
3. Error Handling (error)
Impact: HIGH Description: Consistent error handling improves debugging, user experience, and API reliability. Centralized exception filters ensure uniform error responses.
4. Security (security)
Impact: HIGH Description: Security vulnerabilities can be catastrophic. Input validation, authentication, authorization, and data protection are non-negotiable.
5. Performance (perf)
Impact: HIGH Description: Optimizing request handling, caching, and database queries directly impacts application responsiveness and scalability.
6. Testing (test)
Impact: MEDIUM-HIGH Description: Well-tested applications are more reliable. NestJS testing utilities enable comprehensive unit and e2e coverage.
7. Database & ORM (db)
Impact: MEDIUM-HIGH Description: Proper database access patterns, transactions, and query optimization are crucial for data-intensive applications.
8. API Design (api)
Impact: MEDIUM Description: RESTful conventions, versioning, DTOs, and consistent response formats improve API usability and maintainability.
9. Microservices (micro)
Impact: MEDIUM Description: Building distributed systems requires understanding message patterns, health checks, and inter-service communication.
10. DevOps & Deployment (devops)
Impact: LOW-MEDIUM Description: Configuration management, structured logging, and graceful shutdown ensure production readiness and zero-downtime deployments.
Rule Title Here
Impact: MEDIUM (optional impact description)
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
Incorrect (description of what's wrong):
// Bad code example here
const bad = example()Correct (description of what's right):
// Good code example here
const good = example()Reference: Link to documentation or resource
Use DTOs and Serialization for API Responses
Never return entity objects directly from controllers. Use response DTOs with class-transformer's @Exclude() and @Expose() decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract.
Incorrect (returning entities directly or manual spreading):
// Return entities directly
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findById(id);
// Returns: { id, email, passwordHash, ssn, internalNotes, ... }
// Exposes sensitive data!
}
}
// Manual object spreading (error-prone)
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.usersService.findById(id);
return {
id: user.id,
email: user.email,
name: user.name,
// Easy to forget to exclude sensitive fields
// Hard to maintain across endpoints
};
}Correct (use class-transformer with @Exclude and response DTOs):
// Enable class-transformer globally
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
await app.listen(3000);
}
// Entity with serialization control
@Entity()
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
email: string;
@Column()
name: string;
@Column()
@Exclude() // Never include in responses
passwordHash: string;
@Column({ nullable: true })
@Exclude()
ssn: string;
@Column({ default: false })
@Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests
isAdmin: boolean;
@CreateDateColumn()
createdAt: Date;
@Column()
@Exclude()
internalNotes: string;
}
// Now returning entity is safe
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findById(id);
// Returns: { id, email, name, createdAt }
// Sensitive fields excluded automatically
}
}
// For different response shapes, use explicit DTOs
export class UserResponseDto {
@Expose()
id: string;
@Expose()
email: string;
@Expose()
name: string;
@Expose()
@Transform(({ obj }) => obj.posts?.length || 0)
postCount: number;
constructor(partial: Partial<User>) {
Object.assign(this, partial);
}
}
export class UserDetailResponseDto extends UserResponseDto {
@Expose()
createdAt: Date;
@Expose()
@Type(() => PostResponseDto)
posts: PostResponseDto[];
}
// Controller with explicit DTOs
@Controller('users')
export class UsersController {
@Get()
@SerializeOptions({ type: UserResponseDto })
async findAll(): Promise<UserResponseDto[]> {
const users = await this.usersService.findAll();
return users.map(u => plainToInstance(UserResponseDto, u));
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserDetailResponseDto> {
const user = await this.usersService.findByIdWithPosts(id);
return plainToInstance(UserDetailResponseDto, user, {
excludeExtraneousValues: true,
});
}
}
// Groups for conditional serialization
export class UserDto {
@Expose()
id: string;
@Expose()
name: string;
@Expose({ groups: ['admin'] })
email: string;
@Expose({ groups: ['admin'] })
createdAt: Date;
@Expose({ groups: ['admin', 'owner'] })
settings: UserSettings;
}
@Controller('users')
export class UsersController {
@Get()
@SerializeOptions({ groups: ['public'] })
async findAllPublic(): Promise<UserDto[]> {
// Returns: { id, name }
}
@Get('admin')
@UseGuards(AdminGuard)
@SerializeOptions({ groups: ['admin'] })
async findAllAdmin(): Promise<UserDto[]> {
// Returns: { id, name, email, createdAt }
}
@Get('me')
@SerializeOptions({ groups: ['owner'] })
async getProfile(@CurrentUser() user: User): Promise<UserDto> {
// Returns: { id, name, settings }
}
}Reference: NestJS Serialization
Use Interceptors for Cross-Cutting Concerns
Interceptors can transform responses, add logging, handle caching, and measure performance without polluting your business logic. They wrap the route handler execution, giving you access to both the request and response streams.
Incorrect (logging and transformation in every method):
// Logging in every controller method
@Controller('users')
export class UsersController {
@Get()
async findAll(): Promise<User[]> {
const start = Date.now();
this.logger.log('findAll called');
const users = await this.usersService.findAll();
this.logger.log(`findAll completed in ${Date.now() - start}ms`);
return users;
}
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
const start = Date.now();
this.logger.log(`findOne called with id: ${id}`);
const user = await this.usersService.findOne(id);
this.logger.log(`findOne completed in ${Date.now() - start}ms`);
return user;
}
// Repeated in every method!
}
// Manual response wrapping
@Get()
async findAll(): Promise<{ data: User[]; meta: Meta }> {
const users = await this.usersService.findAll();
return {
data: users,
meta: { timestamp: new Date(), count: users.length },
};
}Correct (use interceptors for cross-cutting concerns):
// Logging interceptor
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url, body } = request;
const now = Date.now();
return next.handle().pipe(
tap({
next: (data) => {
const response = context.switchToHttp().getResponse();
this.logger.log(
`${method} ${url} ${response.statusCode} - ${Date.now() - now}ms`,
);
},
error: (error) => {
this.logger.error(
`${method} ${url} ${error.status || 500} - ${Date.now() - now}ms`,
error.stack,
);
},
}),
);
}
}
// Response transformation interceptor
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map((data) => ({
data,
meta: {
timestamp: new Date().toISOString(),
path: context.switchToHttp().getRequest().url,
},
})),
);
}
}
// Timeout interceptor
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(5000),
catchError((err) => {
if (err instanceof TimeoutError) {
throw new RequestTimeoutException('Request timed out');
}
throw err;
}),
);
}
}
// Apply globally or per-controller
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },
],
})
export class AppModule {}
// Or per-controller
@Controller('users')
@UseInterceptors(LoggingInterceptor)
export class UsersController {
@Get()
async findAll(): Promise<User[]> {
// Clean business logic only
return this.usersService.findAll();
}
}
// Custom cache interceptor with TTL
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(
private cacheManager: Cache,
private reflector: Reflector,
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
// Only cache GET requests
if (request.method !== 'GET') {
return next.handle();
}
const cacheKey = this.generateKey(request);
const ttl = this.reflector.get<number>('cacheTTL', context.getHandler()) || 300;
const cached = await this.cacheManager.get(cacheKey);
if (cached) {
return of(cached);
}
return next.handle().pipe(
tap((response) => {
this.cacheManager.set(cacheKey, response, ttl);
}),
);
}
private generateKey(request: Request): string {
return `cache:${request.url}:${JSON.stringify(request.query)}`;
}
}
// Usage with custom TTL
@Get()
@SetMetadata('cacheTTL', 600)
@UseInterceptors(HttpCacheInterceptor)
async findAll(): Promise<User[]> {
return this.usersService.findAll();
}
// Error mapping interceptor
@Injectable()
export class ErrorMappingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
catchError((error) => {
if (error instanceof EntityNotFoundError) {
throw new NotFoundException(error.message);
}
if (error instanceof QueryFailedError) {
if (error.message.includes('duplicate')) {
throw new ConflictException('Resource already exists');
}
}
throw error;
}),
);
}
}Reference: NestJS Interceptors
Use Pipes for Input Transformation
Use built-in pipes like ParseIntPipe, ParseUUIDPipe, and DefaultValuePipe for common transformations. Create custom pipes for business-specific transformations. Pipes separate validation/transformation logic from controllers.
Incorrect (manual type parsing in handlers):
// Manual type parsing in handlers
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
// Manual validation in every handler
const uuid = id.trim();
if (!isUUID(uuid)) {
throw new BadRequestException('Invalid UUID');
}
return this.usersService.findOne(uuid);
}
@Get()
async findAll(
@Query('page') page: string,
@Query('limit') limit: string,
): Promise<User[]> {
// Manual parsing and defaults
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 10;
return this.usersService.findAll(pageNum, limitNum);
}
}
// Type coercion without validation
@Get()
async search(@Query('price') price: string): Promise<Product[]> {
const priceNum = +price; // NaN if invalid, no error
return this.productsService.findByPrice(priceNum);
}Correct (use built-in and custom pipes):
// Use built-in pipes for common transformations
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<User> {
// id is guaranteed to be a valid UUID
return this.usersService.findOne(id);
}
@Get()
async findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
): Promise<User[]> {
// Automatic defaults and type conversion
return this.usersService.findAll(page, limit);
}
@Get('by-status/:status')
async findByStatus(
@Param('status', new ParseEnumPipe(UserStatus)) status: UserStatus,
): Promise<User[]> {
return this.usersService.findByStatus(status);
}
}
// Custom pipe for business logic
@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
transform(value: string): Date {
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new BadRequestException('Invalid date format');
}
return date;
}
}
@Get('reports')
async getReports(
@Query('from', ParseDatePipe) from: Date,
@Query('to', ParseDatePipe) to: Date,
): Promise<Report[]> {
return this.reportsService.findBetween(from, to);
}
// Custom transformation pipes
@Injectable()
export class NormalizeEmailPipe implements PipeTransform<string, string> {
transform(value: string): string {
if (!value) return value;
return value.trim().toLowerCase();
}
}
// Parse comma-separated values
@Injectable()
export class ParseArrayPipe implements PipeTransform<string, string[]> {
transform(value: string): string[] {
if (!value) return [];
return value.split(',').map((v) => v.trim()).filter(Boolean);
}
}
@Get('products')
async findProducts(
@Query('ids', ParseArrayPipe) ids: string[],
@Query('email', NormalizeEmailPipe) email: string,
): Promise<Product[]> {
// ids is already an array, email is normalized
return this.productsService.findByIds(ids);
}
// Sanitize HTML input
@Injectable()
export class SanitizeHtmlPipe implements PipeTransform<string, string> {
transform(value: string): string {
if (!value) return value;
return sanitizeHtml(value, { allowedTags: [] });
}
}
// Global validation pipe with transformation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip non-DTO properties
transform: true, // Auto-transform to DTO types
transformOptions: {
enableImplicitConversion: true, // Convert query strings to numbers
},
forbidNonWhitelisted: true, // Throw on extra properties
}),
);
// DTO with transformation decorators
export class FindProductsDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 10;
@IsOptional()
@Transform(({ value }) => value?.toLowerCase())
@IsString()
search?: string;
@IsOptional()
@Transform(({ value }) => value?.split(','))
@IsArray()
@IsString({ each: true })
categories?: string[];
}
@Get()
async findAll(@Query() dto: FindProductsDto): Promise<Product[]> {
// dto is already transformed and validated
return this.productsService.findAll(dto);
}
// Pipe error customization
@Injectable()
export class CustomParseIntPipe extends ParseIntPipe {
constructor() {
super({
exceptionFactory: (error) =>
new BadRequestException(`${error} must be a valid integer`),
});
}
}
// Or use options on built-in pipes
@Get(':id')
async findOne(
@Param(
'id',
new ParseIntPipe({
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE,
exceptionFactory: () => new NotAcceptableException('ID must be numeric'),
}),
)
id: number,
): Promise<Item> {
return this.itemsService.findOne(id);
}Reference: NestJS Pipes
Use API Versioning for Breaking Changes
Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints.
Incorrect (breaking changes without versioning):
// Breaking changes without versioning
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
// Original response: { id, name, email }
// Later changed to: { id, firstName, lastName, emailAddress }
// Old clients break!
return this.usersService.findOne(id);
}
}
// Manual versioning in routes
@Controller('v1/users')
export class UsersV1Controller {}
@Controller('v2/users')
export class UsersV2Controller {}
// Inconsistent, error-prone, hard to maintainCorrect (use NestJS built-in versioning):
// Enable versioning in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// URI versioning: /v1/users, /v2/users
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
// Or header versioning: X-API-Version: 1
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version',
defaultVersion: '1',
});
// Or media type: Accept: application/json;v=1
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v=',
defaultVersion: '1',
});
await app.listen(3000);
}
// Version-specific controllers
@Controller('users')
@Version('1')
export class UsersV1Controller {
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserV1Response> {
const user = await this.usersService.findOne(id);
// V1 response format
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}
@Controller('users')
@Version('2')
export class UsersV2Controller {
@Get(':id')
async findOne(@Param('id') id: string): Promise<UserV2Response> {
const user = await this.usersService.findOne(id);
// V2 response format with breaking changes
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
emailAddress: user.email,
createdAt: user.createdAt,
};
}
}
// Per-route versioning - different versions for different routes
@Controller('users')
export class UsersController {
@Get()
@Version('1')
findAllV1(): Promise<UserV1Response[]> {
return this.usersService.findAllV1();
}
@Get()
@Version('2')
findAllV2(): Promise<UserV2Response[]> {
return this.usersService.findAllV2();
}
@Get(':id')
@Version(['1', '2']) // Same handler for multiple versions
findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findOne(id);
}
@Post()
@Version(VERSION_NEUTRAL) // Available in all versions
create(@Body() dto: CreateUserDto): Promise<User> {
return this.usersService.create(dto);
}
}
// Shared service with version-specific logic
@Injectable()
export class UsersService {
async findOne(id: string, version: string): Promise<any> {
const user = await this.repo.findOne({ where: { id } });
if (version === '1') {
return this.toV1Response(user);
}
return this.toV2Response(user);
}
private toV1Response(user: User): UserV1Response {
return {
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
};
}
private toV2Response(user: User): UserV2Response {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
emailAddress: user.email,
createdAt: user.createdAt,
};
}
}
// Controller extracts version
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(
@Param('id') id: string,
@Headers('X-API-Version') version: string = '1',
): Promise<any> {
return this.usersService.findOne(id, version);
}
}
// Deprecation strategy - mark old versions as deprecated
@Controller('users')
@Version('1')
@UseInterceptors(DeprecationInterceptor)
export class UsersV1Controller {
// All V1 routes will include deprecation warning
}
@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const response = context.switchToHttp().getResponse();
response.setHeader('Deprecation', 'true');
response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT');
response.setHeader('Link', '</v2/users>; rel="successor-version"');
return next.handle();
}
}Reference: NestJS Versioning
Avoid Circular Dependencies
Circular dependencies occur when Module A imports Module B, and Module B imports Module A (directly or transitively). NestJS can sometimes resolve these through forward references, but they indicate architectural problems and should be avoided. This is the #1 cause of runtime crashes in NestJS applications.
Incorrect (circular module imports):
// users.module.ts
@Module({
imports: [OrdersModule], // Orders needs Users, Users needs Orders = circular
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// orders.module.ts
@Module({
imports: [UsersModule], // Circular dependency!
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}Correct (extract shared logic or use events):
// Option 1: Extract shared logic to a third module
// shared.module.ts
@Module({
providers: [SharedService],
exports: [SharedService],
})
export class SharedModule {}
// users.module.ts
@Module({
imports: [SharedModule],
providers: [UsersService],
})
export class UsersModule {}
// orders.module.ts
@Module({
imports: [SharedModule],
providers: [OrdersService],
})
export class OrdersModule {}
// Option 2: Use events for decoupled communication
// users.service.ts
@Injectable()
export class UsersService {
constructor(private eventEmitter: EventEmitter2) {}
async createUser(data: CreateUserDto) {
const user = await this.userRepo.save(data);
this.eventEmitter.emit('user.created', user);
return user;
}
}
// orders.service.ts
@Injectable()
export class OrdersService {
@OnEvent('user.created')
handleUserCreated(user: User) {
// React to user creation without direct dependency
}
}Reference: NestJS Circular Dependency
Organize by Feature Modules
Organize your application into feature modules that encapsulate related functionality. Each feature module should be self-contained with its own controllers, services, entities, and DTOs. Avoid organizing by technical layer (all controllers together, all services together). This enables 3-5x faster onboarding and feature development.
Incorrect (technical layer organization):
// Technical layer organization (anti-pattern)
src/
├── controllers/
│ ├── users.controller.ts
│ ├── orders.controller.ts
│ └── products.controller.ts
├── services/
│ ├── users.service.ts
│ ├── orders.service.ts
│ └── products.service.ts
├── entities/
│ ├── user.entity.ts
│ ├── order.entity.ts
│ └── product.entity.ts
└── app.module.ts // Imports everything directlyCorrect (feature module organization):
// Feature module organization
src/
├── users/
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ ├── entities/
│ │ └── user.entity.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── users.repository.ts
│ └── users.module.ts
├── orders/
│ ├── dto/
│ ├── entities/
│ ├── orders.controller.ts
│ ├── orders.service.ts
│ └── orders.module.ts
├── shared/
│ ├── guards/
│ ├── interceptors/
│ ├── filters/
│ └── shared.module.ts
└── app.module.ts
// users.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService], // Only export what others need
})
export class UsersModule {}
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot(),
UsersModule,
OrdersModule,
SharedModule,
],
})
export class AppModule {}Reference: NestJS Modules
Use Proper Module Sharing Patterns
NestJS modules are singletons by default. When a service is properly exported from a module and that module is imported elsewhere, the same instance is shared. However, providing a service in multiple modules creates separate instances, leading to memory waste, state inconsistency, and confusing behavior. Always encapsulate services in dedicated modules, export them explicitly, and import the module where needed.
Incorrect (service provided in multiple modules):
// StorageService provided directly in multiple modules - WRONG
// storage.service.ts
@Injectable()
export class StorageService {
private cache = new Map(); // Each instance has separate state!
store(key: string, value: any) {
this.cache.set(key, value);
}
}
// app.module.ts
@Module({
providers: [StorageService], // Instance #1
controllers: [AppController],
})
export class AppModule {}
// videos.module.ts
@Module({
providers: [StorageService], // Instance #2 - different from AppModule!
controllers: [VideosController],
})
export class VideosModule {}
// Problems:
// 1. Two separate StorageService instances exist
// 2. cache.set() in VideosModule doesn't affect AppModule's cache
// 3. Memory wasted on duplicate instances
// 4. Debugging nightmares when state doesn't syncCorrect (dedicated module with exports):
// storage/storage.module.ts
@Module({
providers: [StorageService],
exports: [StorageService], // Make available to importers
})
export class StorageModule {}
// videos/videos.module.ts
@Module({
imports: [StorageModule], // Import the module, not the service
controllers: [VideosController],
providers: [VideosService],
})
export class VideosModule {}
// channels/channels.module.ts
@Module({
imports: [StorageModule], // Same instance shared
controllers: [ChannelsController],
providers: [ChannelsService],
})
export class ChannelsModule {}
// app.module.ts
@Module({
imports: [
StorageModule, // Only if AppModule itself needs StorageService
VideosModule,
ChannelsModule,
],
})
export class AppModule {}
// Now all modules share the SAME StorageService instanceWhen to use @Global() (sparingly):
// ONLY for truly cross-cutting concerns
@Global()
@Module({
providers: [ConfigService, LoggerService],
exports: [ConfigService, LoggerService],
})
export class CoreModule {}
// Import once in AppModule
@Module({
imports: [CoreModule], // Registered globally, available everywhere
})
export class AppModule {}
// Other modules don't need to import CoreModule
@Module({
controllers: [UsersController],
providers: [UsersService], // Can inject ConfigService without importing
})
export class UsersModule {}
// WARNING: Don't make everything global!
// - Hides dependencies (can't see what a module needs from imports)
// - Makes testing harder
// - Reserve for: config, logging, database connectionsModule re-exporting pattern:
// common.module.ts - shared utilities
@Module({
providers: [DateService, ValidationService],
exports: [DateService, ValidationService],
})
export class CommonModule {}
// core.module.ts - re-exports common for convenience
@Module({
imports: [CommonModule, DatabaseModule],
exports: [CommonModule, DatabaseModule], // Re-export for consumers
})
export class CoreModule {}
// feature.module.ts - imports CoreModule, gets both
@Module({
imports: [CoreModule], // Gets CommonModule + DatabaseModule
controllers: [FeatureController],
})
export class FeatureModule {}Reference: NestJS Modules
Single Responsibility for Services
Each service should have a single, well-defined responsibility. Avoid "god services" that handle multiple unrelated concerns. If a service name includes "And" or handles more than one domain concept, it likely violates single responsibility. This reduces complexity and improves testability by 40%+.
Incorrect (god service anti-pattern):
// God service anti-pattern
@Injectable()
export class UserAndOrderService {
constructor(
private userRepo: UserRepository,
private orderRepo: OrderRepository,
private mailer: MailService,
private payment: PaymentService,
) {}
async createUser(dto: CreateUserDto) {
const user = await this.userRepo.save(dto);
await this.mailer.sendWelcome(user);
return user;
}
async createOrder(userId: string, dto: CreateOrderDto) {
const order = await this.orderRepo.save({ userId, ...dto });
await this.payment.charge(order);
await this.mailer.sendOrderConfirmation(order);
return order;
}
async calculateOrderStats(userId: string) {
// Stats logic mixed in
}
async validatePayment(orderId: string) {
// Payment logic mixed in
}
}Correct (focused services with single responsibility):
// Focused services with single responsibility
@Injectable()
export class UsersService {
constructor(private userRepo: UserRepository) {}
async create(dto: CreateUserDto): Promise<User> {
return this.userRepo.save(dto);
}
async findById(id: string): Promise<User> {
return this.userRepo.findOneOrFail({ where: { id } });
}
}
@Injectable()
export class OrdersService {
constructor(private orderRepo: OrderRepository) {}
async create(userId: string, dto: CreateOrderDto): Promise<Order> {
return this.orderRepo.save({ userId, ...dto });
}
async findByUser(userId: string): Promise<Order[]> {
return this.orderRepo.find({ where: { userId } });
}
}
@Injectable()
export class OrderStatsService {
constructor(private orderRepo: OrderRepository) {}
async calculateForUser(userId: string): Promise<OrderStats> {
// Focused stats calculation
}
}
// Orchestration in controller or dedicated orchestrator
@Controller('orders')
export class OrdersController {
constructor(
private orders: OrdersService,
private payment: PaymentService,
private notifications: NotificationService,
) {}
@Post()
async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) {
const order = await this.orders.create(user.id, dto);
await this.payment.charge(order);
await this.notifications.sendOrderConfirmation(order);
return order;
}
}Reference: NestJS Providers
Use Event-Driven Architecture for Decoupling
Use @nestjs/event-emitter for intra-service events and message brokers for inter-service communication. Events allow modules to react to changes without direct dependencies, improving modularity and enabling async processing.
Incorrect (direct service coupling):
// Direct service coupling
@Injectable()
export class OrdersService {
constructor(
private inventoryService: InventoryService,
private emailService: EmailService,
private analyticsService: AnalyticsService,
private notificationService: NotificationService,
private loyaltyService: LoyaltyService,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.repo.save(dto);
// Tight coupling - OrdersService knows about all consumers
await this.inventoryService.reserve(order.items);
await this.emailService.sendConfirmation(order);
await this.analyticsService.track('order_created', order);
await this.notificationService.push(order.userId, 'Order placed');
await this.loyaltyService.addPoints(order.userId, order.total);
// Adding new behavior requires modifying this service
return order;
}
}Correct (event-driven decoupling):
// Use EventEmitter for decoupling
import { EventEmitter2 } from '@nestjs/event-emitter';
// Define event
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
public readonly items: OrderItem[],
public readonly total: number,
) {}
}
// Service emits events
@Injectable()
export class OrdersService {
constructor(
private eventEmitter: EventEmitter2,
private repo: Repository<Order>,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.repo.save(dto);
// Emit event - no knowledge of consumers
this.eventEmitter.emit(
'order.created',
new OrderCreatedEvent(order.id, order.userId, order.items, order.total),
);
return order;
}
}
// Listeners in separate modules
@Injectable()
export class InventoryListener {
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.inventoryService.reserve(event.items);
}
}
@Injectable()
export class EmailListener {
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.emailService.sendConfirmation(event.orderId);
}
}
@Injectable()
export class AnalyticsListener {
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.analyticsService.track('order_created', {
orderId: event.orderId,
total: event.total,
});
}
}Reference: NestJS Events
Use Repository Pattern for Data Access
Create custom repositories to encapsulate complex queries and database logic. This keeps services focused on business logic, makes testing easier with mock repositories, and allows changing database implementations without affecting business code.
Incorrect (complex queries in services):
// Complex queries in services
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User) private repo: Repository<User>,
) {}
async findActiveWithOrders(minOrders: number): Promise<User[]> {
// Complex query logic mixed with business logic
return this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.where('user.isActive = :active', { active: true })
.andWhere('user.deletedAt IS NULL')
.groupBy('user.id')
.having('COUNT(order.id) >= :min', { min: minOrders })
.orderBy('user.createdAt', 'DESC')
.getMany();
}
// Service becomes bloated with query logic
}Correct (custom repository with encapsulated queries):
// Custom repository with encapsulated queries
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(User) private repo: Repository<User>,
) {}
async findById(id: string): Promise<User | null> {
return this.repo.findOne({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.repo.findOne({ where: { email } });
}
async findActiveWithMinOrders(minOrders: number): Promise<User[]> {
return this.repo
.createQueryBuilder('user')
.leftJoinAndSelect('user.orders', 'order')
.where('user.isActive = :active', { active: true })
.andWhere('user.deletedAt IS NULL')
.groupBy('user.id')
.having('COUNT(order.id) >= :min', { min: minOrders })
.orderBy('user.createdAt', 'DESC')
.getMany();
}
async save(user: User): Promise<User> {
return this.repo.save(user);
}
}
// Clean service with business logic only
@Injectable()
export class UsersService {
constructor(private usersRepo: UsersRepository) {}
async getActiveUsersWithOrders(): Promise<User[]> {
return this.usersRepo.findActiveWithMinOrders(1);
}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.usersRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictException('Email already registered');
}
const user = new User();
user.email = dto.email;
user.name = dto.name;
return this.usersRepo.save(user);
}
}Reference: Repository Pattern
Avoid N+1 Query Problems
N+1 queries occur when you fetch a list of entities, then make an additional query for each entity to load related data. Use eager loading with relations, query builder joins, or DataLoader to batch queries efficiently.
Incorrect (lazy loading in loops causes N+1):
// Lazy loading in loops causes N+1
@Injectable()
export class OrdersService {
async getOrdersWithItems(userId: string): Promise<Order[]> {
const orders = await this.orderRepo.find({ where: { userId } });
// 1 query for orders
for (const order of orders) {
// N additional queries - one per order!
order.items = await this.itemRepo.find({ where: { orderId: order.id } });
}
return orders;
}
}
// Accessing lazy relations without loading
@Controller('users')
export class UsersController {
@Get()
async findAll(): Promise<User[]> {
const users = await this.userRepo.find();
// If User.posts is lazy-loaded, serializing triggers N queries
return users; // Each user.posts access = 1 query
}
}Correct (use relations for eager loading):
// Use relations option for eager loading
@Injectable()
export class OrdersService {
async getOrdersWithItems(userId: string): Promise<Order[]> {
// Single query with JOIN
return this.orderRepo.find({
where: { userId },
relations: ['items', 'items.product'],
});
}
}
// Use QueryBuilder for complex joins
@Injectable()
export class UsersService {
async getUsersWithPostCounts(): Promise<UserWithPostCount[]> {
return this.userRepo
.createQueryBuilder('user')
.leftJoin('user.posts', 'post')
.select('user.id', 'id')
.addSelect('user.name', 'name')
.addSelect('COUNT(post.id)', 'postCount')
.groupBy('user.id')
.getRawMany();
}
async getActiveUsersWithPosts(): Promise<User[]> {
return this.userRepo
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.leftJoinAndSelect('post.comments', 'comment')
.where('user.isActive = :active', { active: true })
.andWhere('post.status = :status', { status: 'published' })
.getMany();
}
}
// Use find options for specific fields
async getOrderSummaries(userId: string): Promise<OrderSummary[]> {
return this.orderRepo.find({
where: { userId },
relations: ['items'],
select: {
id: true,
total: true,
status: true,
items: {
id: true,
quantity: true,
price: true,
},
},
});
}
// Use DataLoader for GraphQL to batch and cache queries
import DataLoader from 'dataloader';
@Injectable({ scope: Scope.REQUEST })
export class PostsLoader {
constructor(private postsService: PostsService) {}
readonly batchPosts = new DataLoader<string, Post[]>(async (userIds) => {
// Single query for all users' posts
const posts = await this.postsService.findByUserIds([...userIds]);
// Group by userId
const postsMap = new Map<string, Post[]>();
for (const post of posts) {
const userPosts = postsMap.get(post.userId) || [];
userPosts.push(post);
postsMap.set(post.userId, userPosts);
}
// Return in same order as input
return userIds.map((id) => postsMap.get(id) || []);
});
}
// In resolver
@ResolveField()
async posts(@Parent() user: User): Promise<Post[]> {
// DataLoader batches multiple calls into single query
return this.postsLoader.batchPosts.load(user.id);
}
// Enable query logging in development to detect N+1
TypeOrmModule.forRoot({
logging: ['query', 'error'],
logger: 'advanced-console',
});Reference: TypeORM Relations
Use Database Migrations
Never use synchronize: true in production. Use migrations for all schema changes. Migrations provide version control for your database, enable safe rollbacks, and ensure consistency across all environments.
Incorrect (using synchronize or manual SQL):
// Use synchronize in production
TypeOrmModule.forRoot({
type: 'postgres',
synchronize: true, // DANGEROUS in production!
// Can drop columns, tables, or data
});
// Manual SQL in production
@Injectable()
export class DatabaseService {
async addColumn(): Promise<void> {
await this.dataSource.query('ALTER TABLE users ADD COLUMN age INT');
// No version control, no rollback, inconsistent across envs
}
}
// Modify entities without migration
@Entity()
export class User {
@Column()
email: string;
@Column() // Added without migration
newField: string; // Will crash in production if synchronize is false
}Correct (use migrations for all schema changes):
// Configure TypeORM for migrations
// data-source.ts
export const dataSource = new DataSource({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
synchronize: false, // Always false in production
migrationsRun: true, // Run migrations on startup
});
// app.module.ts
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST'),
synchronize: config.get('NODE_ENV') === 'development', // Only in dev
migrations: ['dist/migrations/*.js'],
migrationsRun: true,
}),
});
// migrations/1705312800000-AddUserAge.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserAge1705312800000 implements MigrationInterface {
name = 'AddUserAge1705312800000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add column with default to handle existing rows
await queryRunner.query(`
ALTER TABLE "users" ADD "age" integer DEFAULT 0
`);
// Add index for frequently queried columns
await queryRunner.query(`
CREATE INDEX "IDX_users_age" ON "users" ("age")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Always implement down for rollback
await queryRunner.query(`DROP INDEX "IDX_users_age"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "age"`);
}
}
// Safe column rename (two-step)
export class RenameNameToFullName1705312900000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Step 1: Add new column
await queryRunner.query(`
ALTER TABLE "users" ADD "full_name" varchar(255)
`);
// Step 2: Copy data
await queryRunner.query(`
UPDATE "users" SET "full_name" = "name"
`);
// Step 3: Add NOT NULL constraint
await queryRunner.query(`
ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL
`);
// Step 4: Drop old column (after verifying app works)
await queryRunner.query(`
ALTER TABLE "users" DROP COLUMN "name"
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "name" varchar(255)`);
await queryRunner.query(`UPDATE "users" SET "name" = "full_name"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "full_name"`);
}
}Reference: TypeORM Migrations
Use Transactions for Multi-Step Operations
When multiple database operations must succeed or fail together, wrap them in a transaction. This prevents partial updates that leave your data in an inconsistent state. Use TypeORM's transaction APIs or the DataSource query runner for complex scenarios.
Incorrect (multiple saves without transaction):
// Multiple saves without transaction
@Injectable()
export class OrdersService {
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
// If any step fails, data is inconsistent
const order = await this.orderRepo.save({ userId, status: 'pending' });
for (const item of items) {
await this.orderItemRepo.save({ orderId: order.id, ...item });
await this.inventoryRepo.decrement({ productId: item.productId }, 'stock', item.quantity);
}
await this.paymentService.charge(order.id);
// If payment fails, order and inventory are already modified!
return order;
}
}Correct (use DataSource.transaction for automatic rollback):
// Use DataSource.transaction() for automatic rollback
@Injectable()
export class OrdersService {
constructor(private dataSource: DataSource) {}
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
return this.dataSource.transaction(async (manager) => {
// All operations use the same transactional manager
const order = await manager.save(Order, { userId, status: 'pending' });
for (const item of items) {
await manager.save(OrderItem, { orderId: order.id, ...item });
await manager.decrement(
Inventory,
{ productId: item.productId },
'stock',
item.quantity,
);
}
// If this throws, everything rolls back
await this.paymentService.chargeWithManager(manager, order.id);
return order;
});
}
}
// QueryRunner for manual transaction control
@Injectable()
export class TransferService {
constructor(private dataSource: DataSource) {}
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// Debit source account
await queryRunner.manager.decrement(
Account,
{ id: fromId },
'balance',
amount,
);
// Verify sufficient funds
const source = await queryRunner.manager.findOne(Account, {
where: { id: fromId },
});
if (source.balance < 0) {
throw new BadRequestException('Insufficient funds');
}
// Credit destination account
await queryRunner.manager.increment(
Account,
{ id: toId },
'balance',
amount,
);
// Log the transaction
await queryRunner.manager.save(TransactionLog, {
fromId,
toId,
amount,
timestamp: new Date(),
});
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
}
// Repository method with transaction support
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(User) private repo: Repository<User>,
private dataSource: DataSource,
) {}
async createWithProfile(
userData: CreateUserDto,
profileData: CreateProfileDto,
): Promise<User> {
return this.dataSource.transaction(async (manager) => {
const user = await manager.save(User, userData);
await manager.save(Profile, { ...profileData, userId: user.id });
return user;
});
}
}Reference: TypeORM Transactions
Implement Graceful Shutdown
Handle SIGTERM and SIGINT signals to gracefully shutdown your NestJS application. Stop accepting new requests, wait for in-flight requests to complete, close database connections, and clean up resources. This prevents data loss and connection errors during deployments.
Incorrect (ignoring shutdown signals):
// Ignore shutdown signals
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
// App crashes immediately on SIGTERM
// In-flight requests fail
// Database connections are abruptly closed
}
// Long-running tasks without cancellation
@Injectable()
export class ProcessingService {
async processLargeFile(file: File): Promise<void> {
// No way to interrupt this during shutdown
for (let i = 0; i < file.chunks.length; i++) {
await this.processChunk(file.chunks[i]);
// May run for minutes, blocking shutdown
}
}
}Correct (enable shutdown hooks and handle cleanup):
// Enable shutdown hooks in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable shutdown hooks
app.enableShutdownHooks();
// Optional: Add timeout for forced shutdown
const server = await app.listen(3000);
server.setTimeout(30000); // 30 second timeout
// Handle graceful shutdown
const signals = ['SIGTERM', 'SIGINT'];
signals.forEach((signal) => {
process.on(signal, async () => {
console.log(`Received ${signal}, starting graceful shutdown...`);
// Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed');
await app.close();
process.exit(0);
});
// Force exit after timeout
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
});
});
}
// Lifecycle hooks for cleanup
@Injectable()
export class DatabaseService implements OnApplicationShutdown {
private readonly connections: Connection[] = [];
async onApplicationShutdown(signal?: string): Promise<void> {
console.log(`Database service shutting down on ${signal}`);
// Close all connections gracefully
await Promise.all(
this.connections.map((conn) => conn.close()),
);
console.log('All database connections closed');
}
}
// Queue processor with graceful shutdown
@Injectable()
export class QueueService implements OnApplicationShutdown, OnModuleDestroy {
private isShuttingDown = false;
onModuleDestroy(): void {
this.isShuttingDown = true;
}
async onApplicationShutdown(): Promise<void> {
// Wait for current jobs to complete
await this.queue.close();
}
async processJob(job: Job): Promise<void> {
if (this.isShuttingDown) {
throw new Error('Service is shutting down');
}
await this.doWork(job);
}
}
// WebSocket gateway cleanup
@WebSocketGateway()
export class EventsGateway implements OnApplicationShutdown {
@WebSocketServer()
server: Server;
async onApplicationShutdown(): Promise<void> {
// Notify all connected clients
this.server.emit('shutdown', { message: 'Server is shutting down' });
// Close all connections
this.server.disconnectSockets();
}
}
// Health check integration
@Injectable()
export class ShutdownService {
private isShuttingDown = false;
startShutdown(): void {
this.isShuttingDown = true;
}
isShutdown(): boolean {
return this.isShuttingDown;
}
}
@Controller('health')
export class HealthController {
constructor(private shutdownService: ShutdownService) {}
@Get('ready')
@HealthCheck()
readiness(): Promise<HealthCheckResult> {
// Return 503 during shutdown - k8s stops sending traffic
if (this.shutdownService.isShutdown()) {
throw new ServiceUnavailableException('Shutting down');
}
return this.health.check([
() => this.db.pingCheck('database'),
]);
}
}
// Integrate with shutdown
@Injectable()
export class AppShutdownService implements OnApplicationShutdown {
constructor(private shutdownService: ShutdownService) {}
async onApplicationShutdown(): Promise<void> {
// Mark as unhealthy first
this.shutdownService.startShutdown();
// Wait for k8s to update endpoints
await this.sleep(5000);
// Then proceed with cleanup
}
}
// Request tracking for in-flight requests
@Injectable()
export class RequestTracker implements NestMiddleware, OnApplicationShutdown {
private activeRequests = 0;
private isShuttingDown = false;
private shutdownPromise: Promise<void> | null = null;
private resolveShutdown: (() => void) | null = null;
use(req: Request, res: Response, next: NextFunction): void {
if (this.isShuttingDown) {
res.status(503).send('Service Unavailable');
return;
}
this.activeRequests++;
res.on('finish', () => {
this.activeRequests--;
if (this.isShuttingDown && this.activeRequests === 0 && this.resolveShutdown) {
this.resolveShutdown();
}
});
next();
}
async onApplicationShutdown(): Promise<void> {
this.isShuttingDown = true;
if (this.activeRequests > 0) {
console.log(`Waiting for ${this.activeRequests} requests to complete`);
this.shutdownPromise = new Promise((resolve) => {
this.resolveShutdown = resolve;
});
// Wait with timeout
await Promise.race([
this.shutdownPromise,
new Promise((resolve) => setTimeout(resolve, 30000)),
]);
}
console.log('All requests completed');
}
}Reference: NestJS Lifecycle Events
Use ConfigModule for Environment Configuration
Use @nestjs/config for environment-based configuration. Validate configuration at startup to fail fast on misconfigurations. Use namespaced configuration for organization and type safety.
Incorrect (accessing process.env directly):
// Access process.env directly
@Injectable()
export class DatabaseService {
constructor() {
// No validation, can fail at runtime
this.connection = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT), // NaN if missing
password: process.env.DB_PASSWORD, // undefined if missing
});
}
}
// Scattered env access
@Injectable()
export class EmailService {
sendEmail() {
// Different services access env differently
const apiKey = process.env.SENDGRID_API_KEY || 'default';
// Typos go unnoticed: process.env.SENDGRID_API_KY
}
}Correct (use @nestjs/config with validation):
// Setup validated configuration
import { ConfigModule, ConfigService, registerAs } from '@nestjs/config';
import * as Joi from 'joi';
// config/database.config.ts
export const databaseConfig = registerAs('database', () => ({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
}));
// config/app.config.ts
export const appConfig = registerAs('app', () => ({
port: parseInt(process.env.PORT, 10) || 3000,
environment: process.env.NODE_ENV || 'development',
apiPrefix: process.env.API_PREFIX || 'api',
}));
// config/validation.schema.ts
export const validationSchema = Joi.object({
NODE_ENV: Joi.string()
.valid('development', 'production', 'test')
.default('development'),
PORT: Joi.number().default(3000),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_USERNAME: Joi.string().required(),
DB_PASSWORD: Joi.string().required(),
DB_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
REDIS_URL: Joi.string().uri().required(),
});
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Available everywhere without importing
load: [databaseConfig, appConfig],
validationSchema,
validationOptions: {
abortEarly: true, // Stop on first error
allowUnknown: true, // Allow other env vars
},
}),
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('database.host'),
port: config.get('database.port'),
username: config.get('database.username'),
password: config.get('database.password'),
database: config.get('database.database'),
autoLoadEntities: true,
}),
}),
],
})
export class AppModule {}
// Type-safe configuration access
export interface AppConfig {
port: number;
environment: 'development' | 'production' | 'test';
apiPrefix: string;
}
export interface DatabaseConfig {
host: string;
port: number;
username: string;
password: string;
database: string;
}
// Type-safe access
@Injectable()
export class AppService {
constructor(private config: ConfigService) {}
getPort(): number {
// Type-safe with generic
return this.config.get<number>('app.port');
}
getDatabaseConfig(): DatabaseConfig {
return this.config.get<DatabaseConfig>('database');
}
}
// Inject namespaced config directly
@Injectable()
export class DatabaseService {
constructor(
@Inject(databaseConfig.KEY)
private dbConfig: ConfigType<typeof databaseConfig>,
) {
// Full type inference!
const host = this.dbConfig.host; // string
const port = this.dbConfig.port; // number
}
}
// Environment files support
ConfigModule.forRoot({
envFilePath: [
`.env.${process.env.NODE_ENV}.local`,
`.env.${process.env.NODE_ENV}`,
'.env.local',
'.env',
],
});
// .env.development
// DB_HOST=localhost
// DB_PORT=5432
// .env.production
// DB_HOST=prod-db.example.com
// DB_PORT=5432Reference: NestJS Configuration
Use Structured Logging
Use NestJS Logger with structured JSON output in production. Include contextual information (request ID, user ID, operation) to trace requests across services. Avoid console.log and implement proper log levels.
Incorrect (using console.log in production):
// Use console.log in production
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
console.log('Creating user:', dto);
// Not structured, no levels, lost in production logs
try {
const user = await this.repo.save(dto);
console.log('User created:', user.id);
return user;
} catch (error) {
console.log('Error:', error); // Using log for errors
throw error;
}
}
}
// Log sensitive data
console.log('Login attempt:', { email, password }); // SECURITY RISK!
// Inconsistent log format
logger.log('User ' + userId + ' created at ' + new Date());
// Hard to parse, no structureCorrect (use structured logging with context):
// Configure logger in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger:
process.env.NODE_ENV === 'production'
? ['error', 'warn', 'log']
: ['error', 'warn', 'log', 'debug', 'verbose'],
});
}
// Use NestJS Logger with context
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
async createUser(dto: CreateUserDto): Promise<User> {
this.logger.log('Creating user', { email: dto.email });
try {
const user = await this.repo.save(dto);
this.logger.log('User created', { userId: user.id });
return user;
} catch (error) {
this.logger.error('Failed to create user', error.stack, {
email: dto.email,
});
throw error;
}
}
}
// Custom logger for JSON output
@Injectable()
export class JsonLogger implements LoggerService {
log(message: string, context?: object): void {
console.log(
JSON.stringify({
level: 'info',
timestamp: new Date().toISOString(),
message,
...context,
}),
);
}
error(message: string, trace?: string, context?: object): void {
console.error(
JSON.stringify({
level: 'error',
timestamp: new Date().toISOString(),
message,
trace,
...context,
}),
);
}
warn(message: string, context?: object): void {
console.warn(
JSON.stringify({
level: 'warn',
timestamp: new Date().toISOString(),
message,
...context,
}),
);
}
debug(message: string, context?: object): void {
console.debug(
JSON.stringify({
level: 'debug',
timestamp: new Date().toISOString(),
message,
...context,
}),
);
}
}
// Request context logging with ClsModule
import { ClsModule, ClsService } from 'nestjs-cls';
@Module({
imports: [
ClsModule.forRoot({
global: true,
middleware: {
mount: true,
generateId: true,
},
}),
],
})
export class AppModule {}
// Middleware to set request context
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(private cls: ClsService) {}
use(req: Request, res: Response, next: NextFunction): void {
const requestId = req.headers['x-request-id'] || randomUUID();
this.cls.set('requestId', requestId);
this.cls.set('userId', req.user?.id);
res.setHeader('x-request-id', requestId);
next();
}
}
// Logger that includes request context
@Injectable()
export class ContextLogger {
constructor(private cls: ClsService) {}
log(message: string, data?: object): void {
console.log(
JSON.stringify({
level: 'info',
timestamp: new Date().toISOString(),
requestId: this.cls.get('requestId'),
userId: this.cls.get('userId'),
message,
...data,
}),
);
}
error(message: string, error: Error, data?: object): void {
console.error(
JSON.stringify({
level: 'error',
timestamp: new Date().toISOString(),
requestId: this.cls.get('requestId'),
userId: this.cls.get('userId'),
message,
error: error.message,
stack: error.stack,
...data,
}),
);
}
}
// Pino integration for high-performance logging
import { LoggerModule } from 'nestjs-pino';
@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
redact: ['req.headers.authorization', 'req.body.password'],
serializers: {
req: (req) => ({
method: req.method,
url: req.url,
query: req.query,
}),
res: (res) => ({
statusCode: res.statusCode,
}),
},
},
}),
],
})
export class AppModule {}
// Usage with Pino
@Injectable()
export class UsersService {
constructor(private logger: PinoLogger) {
this.logger.setContext(UsersService.name);
}
async findOne(id: string): Promise<User> {
this.logger.info({ userId: id }, 'Finding user');
// Pino uses first arg for data, second for message
}
}Reference: NestJS Logger
Avoid Service Locator Anti-Pattern
Avoid using ModuleRef.get() or global containers to resolve dependencies at runtime. This hides dependencies, makes code harder to test, and breaks the benefits of dependency injection. Use constructor injection instead.
Incorrect (service locator anti-pattern):
// Use ModuleRef to get dependencies dynamically
@Injectable()
export class OrdersService {
constructor(private moduleRef: ModuleRef) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
// Dependencies are hidden - not visible in constructor
const usersService = this.moduleRef.get(UsersService);
const inventoryService = this.moduleRef.get(InventoryService);
const paymentService = this.moduleRef.get(PaymentService);
const user = await usersService.findOne(dto.userId);
// ... rest of logic
}
}
// Global singleton container
class ServiceContainer {
private static instance: ServiceContainer;
private services = new Map<string, any>();
static getInstance(): ServiceContainer {
if (!this.instance) {
this.instance = new ServiceContainer();
}
return this.instance;
}
get<T>(key: string): T {
return this.services.get(key);
}
}Correct (constructor injection with explicit dependencies):
// Use constructor injection - dependencies are explicit
@Injectable()
export class OrdersService {
constructor(
private usersService: UsersService,
private inventoryService: InventoryService,
private paymentService: PaymentService,
) {}
async createOrder(dto: CreateOrderDto): Promise<Order> {
const user = await this.usersService.findOne(dto.userId);
const inventory = await this.inventoryService.check(dto.items);
// Dependencies are clear and testable
}
}
// Easy to test with mocks
describe('OrdersService', () => {
let service: OrdersService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: UsersService, useValue: mockUsersService },
{ provide: InventoryService, useValue: mockInventoryService },
{ provide: PaymentService, useValue: mockPaymentService },
],
}).compile();
service = module.get(OrdersService);
});
});
// VALID: Factory pattern for dynamic instantiation
@Injectable()
export class HandlerFactory {
constructor(private moduleRef: ModuleRef) {}
getHandler(type: string): Handler {
switch (type) {
case 'email':
return this.moduleRef.get(EmailHandler);
case 'sms':
return this.moduleRef.get(SmsHandler);
default:
return this.moduleRef.get(DefaultHandler);
}
}
}Reference: NestJS Module Reference
Apply Interface Segregation Principle
Clients should not be forced to depend on interfaces they don't use. In NestJS, this means keeping interfaces small and focused on specific capabilities rather than creating "fat" interfaces that bundle unrelated methods. When a service only needs to send emails, it shouldn't depend on an interface that also includes SMS, push notifications, and logging. Split large interfaces into role-based ones.
Incorrect (fat interface forcing unused dependencies):
// Fat interface - forces all consumers to depend on everything
interface NotificationService {
sendEmail(to: string, subject: string, body: string): Promise<void>;
sendSms(phone: string, message: string): Promise<void>;
sendPush(userId: string, notification: PushPayload): Promise<void>;
sendSlack(channel: string, message: string): Promise<void>;
logNotification(type: string, payload: any): Promise<void>;
getDeliveryStatus(id: string): Promise<DeliveryStatus>;
retryFailed(id: string): Promise<void>;
scheduleNotification(dto: ScheduleDto): Promise<string>;
}
// Consumer only needs email, but must mock everything for tests
@Injectable()
export class OrdersService {
constructor(
private notifications: NotificationService, // Depends on 8 methods, uses 1
) {}
async confirmOrder(order: Order): Promise<void> {
await this.notifications.sendEmail(
order.customer.email,
'Order Confirmed',
`Your order ${order.id} has been confirmed.`,
);
}
}
// Testing is painful - must mock unused methods
const mockNotificationService = {
sendEmail: jest.fn(),
sendSms: jest.fn(), // Never used, but required
sendPush: jest.fn(), // Never used, but required
sendSlack: jest.fn(), // Never used, but required
logNotification: jest.fn(), // Never used, but required
getDeliveryStatus: jest.fn(), // Never used, but required
retryFailed: jest.fn(), // Never used, but required
scheduleNotification: jest.fn(), // Never used, but required
};Correct (segregated interfaces by capability):
// Segregated interfaces - each focused on one capability
interface EmailSender {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}
interface SmsSender {
sendSms(phone: string, message: string): Promise<void>;
}
interface PushSender {
sendPush(userId: string, notification: PushPayload): Promise<void>;
}
interface NotificationLogger {
logNotification(type: string, payload: any): Promise<void>;
}
interface NotificationScheduler {
scheduleNotification(dto: ScheduleDto): Promise<string>;
}
// Implementation can implement multiple interfaces
@Injectable()
export class NotificationService implements EmailSender, SmsSender, PushSender {
async sendEmail(to: string, subject: string, body: string): Promise<void> {
// Email implementation
}
async sendSms(phone: string, message: string): Promise<void> {
// SMS implementation
}
async sendPush(userId: string, notification: PushPayload): Promise<void> {
// Push implementation
}
}
// Or separate implementations
@Injectable()
export class SendGridEmailService implements EmailSender {
async sendEmail(to: string, subject: string, body: string): Promise<void> {
// SendGrid-specific implementation
}
}
// Consumer depends only on what it needs
@Injectable()
export class OrdersService {
constructor(
@Inject(EMAIL_SENDER) private emailSender: EmailSender, // Minimal dependency
) {}
async confirmOrder(order: Order): Promise<void> {
await this.emailSender.sendEmail(
order.customer.email,
'Order Confirmed',
`Your order ${order.id} has been confirmed.`,
);
}
}
// Testing is simple - only mock what's used
const mockEmailSender: EmailSender = {
sendEmail: jest.fn(),
};
// Module registration with tokens
export const EMAIL_SENDER = Symbol('EMAIL_SENDER');
export const SMS_SENDER = Symbol('SMS_SENDER');
@Module({
providers: [
{ provide: EMAIL_SENDER, useClass: SendGridEmailService },
{ provide: SMS_SENDER, useClass: TwilioSmsService },
],
exports: [EMAIL_SENDER, SMS_SENDER],
})
export class NotificationModule {}Combining interfaces when needed:
// Sometimes a consumer legitimately needs multiple capabilities
interface EmailAndSmsSender extends EmailSender, SmsSender {}
// Or use intersection types
type MultiChannelSender = EmailSender & SmsSender & PushSender;
// Consumer that genuinely needs multiple channels
@Injectable()
export class AlertService {
constructor(
@Inject(MULTI_CHANNEL_SENDER)
private sender: EmailSender & SmsSender,
) {}
async sendCriticalAlert(user: User, message: string): Promise<void> {
await Promise.all([
this.sender.sendEmail(user.email, 'Critical Alert', message),
this.sender.sendSms(user.phone, message),
]);
}
}Reference: Interface Segregation Principle
Honor Liskov Substitution Principle
Subtypes must be substitutable for their base types without altering program correctness. In NestJS with dependency injection, this means any implementation of an interface or abstract class must honor the contract completely. A mock payment service used in tests must behave like a real payment service (return similar shapes, handle errors the same way). Violating LSP causes subtle bugs when swapping implementations.
Incorrect (implementation violates the contract):
// Base interface with clear contract
interface PaymentGateway {
/**
* Charges the specified amount.
* @returns PaymentResult on success
* @throws PaymentFailedException on payment failure
*/
charge(amount: number, currency: string): Promise<PaymentResult>;
}
// Production implementation - follows the contract
@Injectable()
export class StripeService implements PaymentGateway {
async charge(amount: number, currency: string): Promise<PaymentResult> {
const response = await this.stripe.charges.create({ amount, currency });
return { success: true, transactionId: response.id, amount };
}
}
// Mock that violates LSP - different behavior!
@Injectable()
export class MockPaymentService implements PaymentGateway {
async charge(amount: number, currency: string): Promise<PaymentResult> {
// VIOLATION 1: Throws for valid input (contract says return PaymentResult)
if (amount > 1000) {
throw new Error('Mock does not support large amounts');
}
// VIOLATION 2: Returns null instead of PaymentResult
if (currency !== 'USD') {
return null as any; // Real service would convert or reject properly
}
// VIOLATION 3: Missing required field
return { success: true } as PaymentResult; // Missing transactionId!
}
}
// Consumer trusts the contract
@Injectable()
export class OrdersService {
constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {}
async checkout(order: Order): Promise<void> {
const result = await this.payment.charge(order.total, order.currency);
// These fail with MockPaymentService:
await this.saveTransaction(result.transactionId); // undefined!
await this.sendReceipt(result); // might be null!
}
}Correct (implementations honor the contract):
// Well-defined interface with documented behavior
interface PaymentGateway {
/**
* Charges the specified amount.
* @param amount - Amount in smallest currency unit (cents)
* @param currency - ISO 4217 currency code
* @returns PaymentResult with transactionId, success status, and amount
* @throws PaymentFailedException if charge is declined
* @throws InvalidCurrencyException if currency is not supported
*/
charge(amount: number, currency: string): Promise<PaymentResult>;
/**
* Refunds a previous charge.
* @throws TransactionNotFoundException if transactionId is invalid
*/
refund(transactionId: string, amount?: number): Promise<RefundResult>;
}
// Production implementation
@Injectable()
export class StripeService implements PaymentGateway {
async charge(amount: number, currency: string): Promise<PaymentResult> {
try {
const response = await this.stripe.charges.create({ amount, currency });
return {
success: true,
transactionId: response.id,
amount: response.amount,
};
} catch (error) {
if (error.type === 'card_error') {
throw new PaymentFailedException(error.message);
}
throw error;
}
}
async refund(transactionId: string, amount?: number): Promise<RefundResult> {
// Implementation...
}
}
// Mock that honors LSP - same contract, same behavior shape
@Injectable()
export class MockPaymentService implements PaymentGateway {
private transactions = new Map<string, PaymentResult>();
async charge(amount: number, currency: string): Promise<PaymentResult> {
// Honor the contract: validate currency like real service would
if (!['USD', 'EUR', 'GBP'].includes(currency)) {
throw new InvalidCurrencyException(`Unsupported currency: ${currency}`);
}
// Simulate decline for specific test scenarios
if (amount === 99999) {
throw new PaymentFailedException('Card declined (test scenario)');
}
// Return same shape as production
const result: PaymentResult = {
success: true,
transactionId: `mock_${Date.now()}_${Math.random().toString(36)}`,
amount,
};
this.transactions.set(result.transactionId, result);
return result;
}
async refund(transactionId: string, amount?: number): Promise<RefundResult> {
// Honor the contract: throw if transaction not found
if (!this.transactions.has(transactionId)) {
throw new TransactionNotFoundException(transactionId);
}
return {
success: true,
refundId: `refund_${transactionId}`,
amount: amount ?? this.transactions.get(transactionId)!.amount,
};
}
}
// Consumer can swap implementations safely
@Injectable()
export class OrdersService {
constructor(@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway) {}
async checkout(order: Order): Promise<Order> {
try {
const result = await this.payment.charge(order.total, order.currency);
// Works with both StripeService and MockPaymentService
order.transactionId = result.transactionId;
order.status = 'paid';
return order;
} catch (error) {
if (error instanceof PaymentFailedException) {
order.status = 'payment_failed';
return order;
}
throw error;
}
}
}Testing LSP compliance:
// Shared test suite that any implementation must pass
function testPaymentGatewayContract(
createGateway: () => PaymentGateway,
) {
describe('PaymentGateway contract', () => {
let gateway: PaymentGateway;
beforeEach(() => {
gateway = createGateway();
});
it('returns PaymentResult with all required fields', async () => {
const result = await gateway.charge(1000, 'USD');
expect(result).toHaveProperty('success');
expect(result).toHaveProperty('transactionId');
expect(result).toHaveProperty('amount');
expect(typeof result.transactionId).toBe('string');
});
it('throws InvalidCurrencyException for unsupported currency', async () => {
await expect(gateway.charge(1000, 'INVALID'))
.rejects.toThrow(InvalidCurrencyException);
});
it('throws TransactionNotFoundException for invalid refund', async () => {
await expect(gateway.refund('nonexistent'))
.rejects.toThrow(TransactionNotFoundException);
});
});
}
// Run against all implementations
describe('StripeService', () => {
testPaymentGatewayContract(() => new StripeService(mockStripeClient));
});
describe('MockPaymentService', () => {
testPaymentGatewayContract(() => new MockPaymentService());
});Reference: Liskov Substitution Principle
Prefer Constructor Injection
Always use constructor injection over property injection. Constructor injection makes dependencies explicit, enables TypeScript type checking, ensures dependencies are available when the class is instantiated, and improves testability. This is required for proper DI, testing, and TypeScript support.
Incorrect (property injection with hidden dependencies):
// Property injection - avoid unless necessary
@Injectable()
export class UsersService {
@Inject()
private userRepo: UserRepository; // Hidden dependency
@Inject('CONFIG')
private config: ConfigType; // Also hidden
async findAll() {
return this.userRepo.find();
}
}
// Problems:
// 1. Dependencies not visible in constructor
// 2. Service can be instantiated without dependencies in tests
// 3. TypeScript can't enforce dependency types at instantiationCorrect (constructor injection with explicit dependencies):
// Constructor injection - explicit and testable
@Injectable()
export class UsersService {
constructor(
private readonly userRepo: UserRepository,
@Inject('CONFIG') private readonly config: ConfigType,
) {}
async findAll(): Promise<User[]> {
return this.userRepo.find();
}
}
// Testing is straightforward
describe('UsersService', () => {
let service: UsersService;
let mockRepo: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepo = {
find: jest.fn(),
save: jest.fn(),
} as any;
service = new UsersService(mockRepo, { dbUrl: 'test' });
});
it('should find all users', async () => {
mockRepo.find.mockResolvedValue([{ id: '1', name: 'Test' }]);
const result = await service.findAll();
expect(result).toHaveLength(1);
});
});
// Only use property injection for optional dependencies
@Injectable()
export class LoggingService {
@Optional()
@Inject('ANALYTICS')
private analytics?: AnalyticsService;
log(message: string) {
console.log(message);
this.analytics?.track('log', message); // Optional enhancement
}
}Reference: NestJS Providers
Understand Provider Scopes
NestJS has three provider scopes: DEFAULT (singleton), REQUEST (per-request instance), and TRANSIENT (new instance for each injection). Most providers should be singletons. Request-scoped providers have performance implications as they bubble up through the dependency tree. Understanding scopes prevents memory leaks and incorrect data sharing.
Incorrect (wrong scope usage):
// Request-scoped when not needed (performance hit)
@Injectable({ scope: Scope.REQUEST })
export class UsersService {
// This creates a new instance for EVERY request
// All dependencies also become request-scoped
async findAll() {
return this.userRepo.find();
}
}
// Singleton with mutable request state
@Injectable() // Default: singleton
export class RequestContextService {
private userId: string; // DANGER: Shared across all requests!
setUser(userId: string) {
this.userId = userId; // Overwrites for all concurrent requests
}
getUser() {
return this.userId; // Returns wrong user!
}
}Correct (appropriate scope for each use case):
// Singleton for stateless services (default, most common)
@Injectable()
export class UsersService {
constructor(private readonly userRepo: UserRepository) {}
async findById(id: string): Promise<User> {
return this.userRepo.findOne({ where: { id } });
}
}
// Request-scoped ONLY when you need request context
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
private userId: string;
setUser(userId: string) {
this.userId = userId;
}
getUser(): string {
return this.userId;
}
}
// Better: Use NestJS built-in request context
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
@Injectable({ scope: Scope.REQUEST })
export class AuditService {
constructor(@Inject(REQUEST) private request: Request) {}
log(action: string) {
console.log(`User ${this.request.user?.id} performed ${action}`);
}
}
// Best: Use ClsModule for async context (no scope bubble-up)
import { ClsService } from 'nestjs-cls';
@Injectable() // Stays singleton!
export class AuditService {
constructor(private cls: ClsService) {}
log(action: string) {
const userId = this.cls.get('userId');
console.log(`User ${userId} performed ${action}`);
}
}Reference: NestJS Injection Scopes
Use Injection Tokens for Interfaces
TypeScript interfaces are erased at compile time and can't be used as injection tokens. Use string tokens, symbols, or abstract classes when you want to inject implementations of interfaces. This enables swapping implementations for testing or different environments.
Incorrect (interface can't be used as token):
// Interface can't be used as injection token
interface PaymentGateway {
charge(amount: number): Promise<PaymentResult>;
}
@Injectable()
export class StripeService implements PaymentGateway {
charge(amount: number) { /* ... */ }
}
@Injectable()
export class OrdersService {
// This WON'T work - PaymentGateway doesn't exist at runtime
constructor(private payment: PaymentGateway) {}
}Correct (symbol tokens or abstract classes):
// Option 1: String/Symbol tokens (most flexible)
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');
export interface PaymentGateway {
charge(amount: number): Promise<PaymentResult>;
}
@Injectable()
export class StripeService implements PaymentGateway {
async charge(amount: number): Promise<PaymentResult> {
// Stripe implementation
}
}
@Injectable()
export class MockPaymentService implements PaymentGateway {
async charge(amount: number): Promise<PaymentResult> {
return { success: true, id: 'mock-id' };
}
}
// Module registration
@Module({
providers: [
{
provide: PAYMENT_GATEWAY,
useClass: process.env.NODE_ENV === 'test'
? MockPaymentService
: StripeService,
},
],
exports: [PAYMENT_GATEWAY],
})
export class PaymentModule {}
// Injection
@Injectable()
export class OrdersService {
constructor(
@Inject(PAYMENT_GATEWAY) private payment: PaymentGateway,
) {}
async createOrder(dto: CreateOrderDto) {
await this.payment.charge(dto.amount);
}
}
// Option 2: Abstract class (carries runtime type info)
export abstract class PaymentGateway {
abstract charge(amount: number): Promise<PaymentResult>;
}
@Injectable()
export class StripeService extends PaymentGateway {
async charge(amount: number): Promise<PaymentResult> {
// Implementation
}
}
// No @Inject needed with abstract class
@Injectable()
export class OrdersService {
constructor(private payment: PaymentGateway) {}
}Reference: NestJS Custom Providers
Handle Async Errors Properly
NestJS automatically catches errors from async route handlers, but errors from background tasks, event handlers, and manually created promises can crash your application. Always handle async errors explicitly and use global handlers as a safety net.
Incorrect (fire-and-forget without error handling):
// Fire-and-forget without error handling
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Fire and forget - if this fails, error is unhandled!
this.emailService.sendWelcome(user.email);
return user;
}
}
// Unhandled promise in event handler
@Injectable()
export class OrdersService {
@OnEvent('order.created')
handleOrderCreated(event: OrderCreatedEvent) {
// This returns a promise but it's not awaited!
this.processOrder(event);
// Errors will crash the process
}
private async processOrder(event: OrderCreatedEvent): Promise<void> {
await this.inventoryService.reserve(event.items);
await this.notificationService.send(event.userId);
}
}
// Missing try-catch in scheduled tasks
@Cron('0 0 * * *')
async dailyCleanup(): Promise<void> {
await this.cleanupService.run();
// If this throws, no error handling
}Correct (explicit async error handling):
// Handle fire-and-forget with explicit catch
@Injectable()
export class UsersService {
private readonly logger = new Logger(UsersService.name);
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Explicitly catch and log errors
this.emailService.sendWelcome(user.email).catch((error) => {
this.logger.error('Failed to send welcome email', error.stack);
// Optionally queue for retry
});
return user;
}
}
// Properly handle async event handlers
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
@OnEvent('order.created')
async handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
try {
await this.processOrder(event);
} catch (error) {
this.logger.error('Failed to process order', { event, error });
// Don't rethrow - would crash the process
await this.deadLetterQueue.add('order.created', event);
}
}
}
// Safe scheduled tasks
@Injectable()
export class CleanupService {
private readonly logger = new Logger(CleanupService.name);
@Cron('0 0 * * *')
async dailyCleanup(): Promise<void> {
try {
await this.cleanupService.run();
this.logger.log('Daily cleanup completed');
} catch (error) {
this.logger.error('Daily cleanup failed', error.stack);
// Alert or retry logic
}
}
}
// Global unhandled rejection handler in main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = new Logger('Bootstrap');
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', error);
process.exit(1);
});
await app.listen(3000);
}Reference: Node.js Unhandled Rejections
Throw HTTP Exceptions from Services
It's acceptable (and often preferable) to throw HttpException subclasses from services in HTTP applications. This keeps controllers thin and allows services to communicate appropriate error states. For truly layer-agnostic services, use domain exceptions that map to HTTP status codes.
Incorrect (return error objects instead of throwing):
// Return error objects instead of throwing
@Injectable()
export class UsersService {
async findById(id: string): Promise<{ user?: User; error?: string }> {
const user = await this.repo.findOne({ where: { id } });
if (!user) {
return { error: 'User not found' }; // Controller must check this
}
return { user };
}
}
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string) {
const result = await this.usersService.findById(id);
if (result.error) {
throw new NotFoundException(result.error);
}
return result.user;
}
}Correct (throw exceptions directly from service):
// Throw exceptions directly from service
@Injectable()
export class UsersService {
constructor(private readonly repo: UserRepository) {}
async findById(id: string): Promise<User> {
const user = await this.repo.findOne({ where: { id } });
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
async create(dto: CreateUserDto): Promise<User> {
const existing = await this.repo.findOne({
where: { email: dto.email },
});
if (existing) {
throw new ConflictException('Email already registered');
}
return this.repo.save(dto);
}
async update(id: string, dto: UpdateUserDto): Promise<User> {
const user = await this.findById(id); // Throws if not found
Object.assign(user, dto);
return this.repo.save(user);
}
}
// Controller stays thin
@Controller('users')
export class UsersController {
@Get(':id')
findOne(@Param('id') id: string): Promise<User> {
return this.usersService.findById(id);
}
@Post()
create(@Body() dto: CreateUserDto): Promise<User> {
return this.usersService.create(dto);
}
}
// For layer-agnostic services, use domain exceptions
export class EntityNotFoundException extends Error {
constructor(
public readonly entity: string,
public readonly id: string,
) {
super(`${entity} with ID "${id}" not found`);
}
}
// Map to HTTP in exception filter
@Catch(EntityNotFoundException)
export class EntityNotFoundFilter implements ExceptionFilter {
catch(exception: EntityNotFoundException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.status(404).json({
statusCode: 404,
message: exception.message,
entity: exception.entity,
id: exception.id,
});
}
}Reference: NestJS Exception Filters
Use Exception Filters for Error Handling
Never catch exceptions and manually format error responses in controllers. Use NestJS exception filters to handle errors consistently across your application. Create custom exception filters for specific error types and a global filter for unhandled exceptions.
Incorrect (manual error handling in controllers):
// Manual error handling in controllers
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string, @Res() res: Response) {
try {
const user = await this.usersService.findById(id);
if (!user) {
return res.status(404).json({
statusCode: 404,
message: 'User not found',
});
}
return res.json(user);
} catch (error) {
console.error(error);
return res.status(500).json({
statusCode: 500,
message: 'Internal server error',
});
}
}
}Correct (exception filters with consistent handling):
// Use built-in and custom exceptions
@Controller('users')
export class UsersController {
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
const user = await this.usersService.findById(id);
if (!user) {
throw new NotFoundException(`User #${id} not found`);
}
return user;
}
}
// Custom domain exception
export class UserNotFoundException extends NotFoundException {
constructor(userId: string) {
super({
statusCode: 404,
error: 'Not Found',
message: `User with ID "${userId}" not found`,
code: 'USER_NOT_FOUND',
});
}
}
// Custom exception filter for domain errors
@Catch(DomainException)
export class DomainExceptionFilter implements ExceptionFilter {
catch(exception: DomainException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus?.() || 400;
response.status(status).json({
statusCode: status,
code: exception.code,
message: exception.message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
// Global exception filter for unhandled errors
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly logger: Logger) {}
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';
this.logger.error(
`${request.method} ${request.url}`,
exception instanceof Error ? exception.stack : exception,
);
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
// Register globally in main.ts
app.useGlobalFilters(
new AllExceptionsFilter(app.get(Logger)),
new DomainExceptionFilter(),
);
// Or via module
@Module({
providers: [
{
provide: APP_FILTER,
useClass: AllExceptionsFilter,
},
],
})
export class AppModule {}Reference: NestJS Exception Filters
Implement Health Checks for Microservices
Implement liveness and readiness probes using @nestjs/terminus. Liveness checks determine if the service should be restarted. Readiness checks determine if the service can accept traffic. Proper health checks enable Kubernetes and load balancers to route traffic correctly.
Incorrect (simple ping that doesn't check dependencies):
// Simple ping that doesn't check dependencies
@Controller('health')
export class HealthController {
@Get()
check(): string {
return 'OK'; // Service might be unhealthy but returns OK
}
}
// Health check that blocks on slow dependencies
@Controller('health')
export class HealthController {
@Get()
async check(): Promise<string> {
// If database is slow, health check times out
await this.userRepo.findOne({ where: { id: '1' } });
await this.redis.ping();
await this.externalApi.healthCheck();
return 'OK';
}
}Correct (use @nestjs/terminus for comprehensive health checks):
// Use @nestjs/terminus for comprehensive health checks
import {
HealthCheckService,
HttpHealthIndicator,
TypeOrmHealthIndicator,
HealthCheck,
DiskHealthIndicator,
MemoryHealthIndicator,
} from '@nestjs/terminus';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private http: HttpHealthIndicator,
private db: TypeOrmHealthIndicator,
private disk: DiskHealthIndicator,
private memory: MemoryHealthIndicator,
) {}
// Liveness probe - is the service alive?
@Get('live')
@HealthCheck()
liveness() {
return this.health.check([
// Basic checks only
() => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200MB
]);
}
// Readiness probe - can the service handle traffic?
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('database'),
() =>
this.http.pingCheck('redis', 'http://redis:6379', { timeout: 1000 }),
() =>
this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
]);
}
// Deep health check for debugging
@Get('deep')
@HealthCheck()
deepCheck() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
() => this.memory.checkRSS('memory_rss', 300 * 1024 * 1024),
() =>
this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
() =>
this.http.pingCheck('external-api', 'https://api.example.com/health'),
]);
}
}
// Custom indicator for business-specific health
@Injectable()
export class QueueHealthIndicator extends HealthIndicator {
constructor(private queueService: QueueService) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
const queueStats = await this.queueService.getStats();
const isHealthy = queueStats.failedCount < 100;
const result = this.getStatus(key, isHealthy, {
waiting: queueStats.waitingCount,
active: queueStats.activeCount,
failed: queueStats.failedCount,
});
if (!isHealthy) {
throw new HealthCheckError('Queue unhealthy', result);
}
return result;
}
}
// Redis health indicator
@Injectable()
export class RedisHealthIndicator extends HealthIndicator {
constructor(@InjectRedis() private redis: Redis) {
super();
}
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
const pong = await this.redis.ping();
return this.getStatus(key, pong === 'PONG');
} catch (error) {
throw new HealthCheckError('Redis check failed', this.getStatus(key, false));
}
}
}
// Use custom indicators
@Get('ready')
@HealthCheck()
readiness() {
return this.health.check([
() => this.db.pingCheck('database'),
() => this.redis.isHealthy('redis'),
() => this.queue.isHealthy('job-queue'),
]);
}
// Graceful shutdown handling
@Injectable()
export class GracefulShutdownService implements OnApplicationShutdown {
private isShuttingDown = false;
isShutdown(): boolean {
return this.isShuttingDown;
}
async onApplicationShutdown(signal: string): Promise<void> {
this.isShuttingDown = true;
console.log(`Shutting down on ${signal}`);
// Wait for in-flight requests
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
// Health check respects shutdown state
@Get('ready')
@HealthCheck()
readiness() {
if (this.shutdownService.isShutdown()) {
throw new ServiceUnavailableException('Shutting down');
}
return this.health.check([
() => this.db.pingCheck('database'),
]);
}Kubernetes Configuration
# Kubernetes deployment with probes
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-service
spec:
template:
spec:
containers:
- name: api
image: api-service:latest
ports:
- containerPort: 3000
livenessProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health/ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
startupProbe:
httpGet:
path: /health/live
port: 3000
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 30Reference: NestJS Terminus
Use Message and Event Patterns Correctly
NestJS microservices support two communication patterns: request-response (MessagePattern) and event-based (EventPattern). Use MessagePattern when you need a response, and EventPattern for fire-and-forget notifications. Understanding the difference prevents communication bugs.
Incorrect (using wrong pattern for use case):
// Use @MessagePattern for fire-and-forget
@Controller()
export class NotificationsController {
@MessagePattern('user.created')
async handleUserCreated(data: UserCreatedEvent) {
// This WAITS for response, blocking the sender
await this.emailService.sendWelcome(data.email);
// If email fails, sender gets an error (coupling!)
}
}
// Use @EventPattern expecting a response
@Controller()
export class OrdersController {
@EventPattern('inventory.check')
async checkInventory(data: CheckInventoryDto) {
const available = await this.inventory.check(data);
return available; // This return value is IGNORED with @EventPattern!
}
}
// Tight coupling in client
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Blocks until notification service responds
await this.client.send('user.created', user).toPromise();
// If notification service is down, user creation fails!
return user;
}
}Correct (use MessagePattern for request-response, EventPattern for fire-and-forget):
// MessagePattern: Request-Response (when you NEED a response)
@Controller()
export class InventoryController {
@MessagePattern({ cmd: 'check_inventory' })
async checkInventory(data: CheckInventoryDto): Promise<InventoryResult> {
const result = await this.inventoryService.check(data.productId, data.quantity);
return result; // Response sent back to caller
}
}
// Client expects response
@Injectable()
export class OrdersService {
async createOrder(dto: CreateOrderDto): Promise<Order> {
// Check inventory - we NEED this response to proceed
const inventory = await firstValueFrom(
this.inventoryClient.send<InventoryResult>(
{ cmd: 'check_inventory' },
{ productId: dto.productId, quantity: dto.quantity },
),
);
if (!inventory.available) {
throw new BadRequestException('Insufficient inventory');
}
return this.repo.save(dto);
}
}
// EventPattern: Fire-and-Forget (for notifications, side effects)
@Controller()
export class NotificationsController {
@EventPattern('user.created')
async handleUserCreated(data: UserCreatedEvent): Promise<void> {
// No return value needed - just process the event
await this.emailService.sendWelcome(data.email);
await this.analyticsService.track('user_signup', data);
// If this fails, it doesn't affect the sender
}
}
// Client emits event without waiting
@Injectable()
export class UsersService {
async createUser(dto: CreateUserDto): Promise<User> {
const user = await this.repo.save(dto);
// Fire and forget - doesn't block, doesn't wait
this.eventClient.emit('user.created', {
userId: user.id,
email: user.email,
timestamp: new Date(),
});
return user; // User creation succeeds regardless of event handling
}
}
// Hybrid pattern for critical events
@Injectable()
export class OrdersService {
async createOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.repo.save(dto);
// Critical: inventory reservation (use MessagePattern)
const reserved = await firstValueFrom(
this.inventoryClient.send({ cmd: 'reserve_inventory' }, {
orderId: order.id,
items: dto.items,
}),
);
if (!reserved.success) {
await this.repo.delete(order.id);
throw new BadRequestException('Could not reserve inventory');
}
// Non-critical: notifications (use EventPattern)
this.eventClient.emit('order.created', {
orderId: order.id,
userId: dto.userId,
total: dto.total,
});
return order;
}
}
// Error handling patterns
// MessagePattern errors propagate to caller
@MessagePattern({ cmd: 'get_user' })
async getUser(userId: string): Promise<User> {
const user = await this.repo.findOne({ where: { id: userId } });
if (!user) {
throw new RpcException('User not found'); // Received by caller
}
return user;
}
// EventPattern errors should be handled locally
@EventPattern('order.created')
async handleOrderCreated(data: OrderCreatedEvent): Promise<void> {
try {
await this.processOrder(data);
} catch (error) {
// Log and potentially retry - don't throw
this.logger.error('Failed to process order event', error);
await this.deadLetterQueue.add(data);
}
}Reference: NestJS Microservices
Use Message Queues for Background Jobs
Use @nestjs/bullmq for background job processing. Queues decouple long-running tasks from HTTP requests, enable retry logic, and distribute workload across workers. Use them for emails, file processing, notifications, and any task that shouldn't block user requests.
Incorrect (long-running tasks in HTTP handlers):
// Long-running tasks in HTTP handlers
@Controller('reports')
export class ReportsController {
@Post()
async generate(@Body() dto: GenerateReportDto): Promise<Report> {
// This blocks the request for potentially minutes
const data = await this.fetchLargeDataset(dto);
const report = await this.processData(data); // Slow!
await this.sendEmail(dto.email, report); // Can fail!
return report; // Client times out
}
}
// Fire-and-forget without retry
@Injectable()
export class EmailService {
async sendWelcome(email: string): Promise<void> {
// If this fails, email is never sent
await this.mailer.send({ to: email, template: 'welcome' });
// No retry, no tracking, no visibility
}
}
// Use setInterval for scheduled tasks
setInterval(async () => {
await cleanupOldRecords();
}, 60000); // No error handling, memory leaksCorrect (use BullMQ for background processing):
// Configure BullMQ
import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
BullModule.forRoot({
connection: {
host: 'localhost',
port: 6379,
},
defaultJobOptions: {
removeOnComplete: 1000,
removeOnFail: 5000,
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
},
}),
BullModule.registerQueue(
{ name: 'email' },
{ name: 'reports' },
{ name: 'notifications' },
),
],
})
export class QueueModule {}
// Producer: Add jobs to queue
@Injectable()
export class ReportsService {
constructor(
@InjectQueue('reports') private reportsQueue: Queue,
) {}
async requestReport(dto: GenerateReportDto): Promise<{ jobId: string }> {
// Return immediately, process in background
const job = await this.reportsQueue.add('generate', dto, {
priority: dto.urgent ? 1 : 10,
delay: dto.scheduledFor ? Date.parse(dto.scheduledFor) - Date.now() : 0,
});
return { jobId: job.id };
}
async getJobStatus(jobId: string): Promise<JobStatus> {
const job = await this.reportsQueue.getJob(jobId);
return {
status: await job.getState(),
progress: job.progress,
result: job.returnvalue,
};
}
}
// Consumer: Process jobs
@Processor('reports')
export class ReportsProcessor {
private readonly logger = new Logger(ReportsProcessor.name);
@Process('generate')
async generateReport(job: Job<GenerateReportDto>): Promise<Report> {
this.logger.log(`Processing report job ${job.id}`);
// Update progress
await job.updateProgress(10);
const data = await this.fetchData(job.data);
await job.updateProgress(50);
const report = await this.processData(data);
await job.updateProgress(90);
await this.saveReport(report);
await job.updateProgress(100);
return report;
}
@OnQueueActive()
onActive(job: Job) {
this.logger.log(`Processing job ${job.id}`);
}
@OnQueueCompleted()
onCompleted(job: Job, result: any) {
this.logger.log(`Job ${job.id} completed`);
}
@OnQueueFailed()
onFailed(job: Job, error: Error) {
this.logger.error(`Job ${job.id} failed: ${error.message}`);
}
}
// Email queue with retry
@Processor('email')
export class EmailProcessor {
@Process('send')
async sendEmail(job: Job<SendEmailDto>): Promise<void> {
const { to, template, data } = job.data;
try {
await this.mailer.send({
to,
template,
context: data,
});
} catch (error) {
// BullMQ will retry based on job options
throw error;
}
}
}
// Usage
@Injectable()
export class NotificationService {
constructor(@InjectQueue('email') private emailQueue: Queue) {}
async sendWelcome(user: User): Promise<void> {
await this.emailQueue.add(
'send',
{
to: user.email,
template: 'welcome',
data: { name: user.name },
},
{
attempts: 5,
backoff: { type: 'exponential', delay: 5000 },
},
);
}
}
// Scheduled jobs
@Injectable()
export class ScheduledJobsService implements OnModuleInit {
constructor(@InjectQueue('maintenance') private queue: Queue) {}
async onModuleInit(): Promise<void> {
// Clean up old reports daily at midnight
await this.queue.add(
'cleanup',
{},
{
repeat: { cron: '0 0 * * *' },
jobId: 'daily-cleanup', // Prevent duplicates
},
);
// Send digest every hour
await this.queue.add(
'digest',
{},
{
repeat: { every: 60 * 60 * 1000 },
jobId: 'hourly-digest',
},
);
}
}
@Processor('maintenance')
export class MaintenanceProcessor {
@Process('cleanup')
async cleanup(): Promise<void> {
await this.cleanupOldReports();
await this.cleanupExpiredSessions();
}
@Process('digest')
async sendDigest(): Promise<void> {
const users = await this.getUsersForDigest();
for (const user of users) {
await this.emailQueue.add('send', { to: user.email, template: 'digest' });
}
}
}
// Queue monitoring with Bull Board
import { BullBoardModule } from '@bull-board/nestjs';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
@Module({
imports: [
BullBoardModule.forRoot({
route: '/admin/queues',
adapter: ExpressAdapter,
}),
BullBoardModule.forFeature({
name: 'email',
adapter: BullMQAdapter,
}),
BullBoardModule.forFeature({
name: 'reports',
adapter: BullMQAdapter,
}),
],
})
export class AdminModule {}Reference: NestJS Queues
Related skills
How it compares
Pick nestjs-best-practices over generic TypeScript lint skills when you need Nest-specific DI, module boundaries, guards, and interceptor patterns.
FAQ
How many rules does nestjs-best-practices include?
nestjs-best-practices contains 40 rules organized into 10 categories, prioritized by impact. The agent applies them when writing, reviewing, or refactoring NestJS modules, controllers, services, and security code.
When should I use nestjs-best-practices?
Use nestjs-best-practices when creating new NestJS modules, implementing authentication, or refactoring code for proper dependency injection, layering, security, and performance. It targets production-ready TypeScript APIs.
Is Nestjs Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.