NestJS 11 Enterprise API Patterns
NestJS 11 has emerged as the go-to framework for building production-grade APIs in TypeScript, combining Angular-inspired architecture with the raw power of Node.js. When you're operating at enterprise scale — handling millions of requests, managing multi-tenant data, and coordinating dozens of modules — the patterns you choose on day one determine whether your codebase scales gracefully or collapses under its own weight.
This guide distills hard-won lessons from building a 56-module NestJS 11 backend with 310+ TypeScript files, covering everything from module organization and guard composition to multi-tenancy patterns that actually hold up under load.
Key Takeaways
- Use
forRoutes('*path')notforRoutes('*')— NestJS 11 changed wildcard route matching- Global exception filters must be registered in
main.ts, not throughAPP_FILTER- Multi-tenancy requires
organizationIdfiltering at every query layer, not just middleware@Public()decorator pattern is safer than disabling guards entirely for open routes- Never use
sql.raw()in Drizzle queries — always parameterizedsqltemplate literals- EmailModule should be
@Global()to avoid re-importing in every feature module- Dotenv must be preloaded in
main.tsbefore NestJS bootstraps to avoid env resolution issues- Rate limiting is mandatory on all public endpoints — use
@nestjs/throttler
Project Structure at Scale
The single most important architectural decision in NestJS is how you organize modules. At enterprise scale, a flat module list in app.module.ts becomes unmanageable. The pattern that works is domain-driven module grouping with explicit dependency declarations.
apps/api/src/
modules/
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
guards/
jwt.guard.ts
roles.guard.ts
decorators/
public.decorator.ts
roles.decorator.ts
contacts/
contacts.module.ts
contacts.controller.ts
contacts.service.ts
contacts.spec.ts
dto/
create-contact.dto.ts
update-contact.dto.ts
billing/
billing.module.ts
billing.service.ts
webhook.controller.ts
shared/
filters/
global-exception.filter.ts
interceptors/
transform.interceptor.ts
pipes/
validation.pipe.ts
health/
health.controller.ts
indicators/
main.ts
app.module.ts
Each domain module is self-contained. The shared/ directory holds cross-cutting concerns. This separation lets you add new domains without touching existing code.
The @Public() Decorator Pattern
NestJS 11 applies JWT guards globally, but you need select endpoints open — health checks, auth callbacks, webhook receivers. The @Public() decorator pattern is far superior to disabling guards per-route.
// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}
// app.module.ts
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
})
export class AppModule {}
Now any controller method decorated with @Public() bypasses JWT validation entirely. Webhook controllers, health endpoints, and auth routes all use this pattern.
Multi-Tenancy with OrganizationId
The cardinal rule of multi-tenant APIs: every database query must filter by organizationId. Middleware that sets req.organizationId is not enough — a developer forgetting to apply the filter exposes cross-tenant data.
The pattern is to extract organizationId from the JWT payload and attach it to a typed request interface:
// types/authenticated-request.interface.ts
import { Request } from 'express';
export interface AuthenticatedRequest extends Request {
user: {
sub: string;
email: string;
name: string;
role: 'admin' | 'support' | 'user';
organizationId: string;
};
}
// contacts/contacts.controller.ts
import { Controller, Get, Post, Body, Req } from '@nestjs/common';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';
@Controller('contacts')
export class ContactsController {
constructor(private contactsService: ContactsService) {}
@Get()
findAll(@Req() req: AuthenticatedRequest) {
return this.contactsService.findAll(req.user.organizationId);
}
@Post()
create(@Body() dto: CreateContactDto, @Req() req: AuthenticatedRequest) {
return this.contactsService.create(dto, req.user.organizationId);
}
}
// contacts/contacts.service.ts
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';
@Injectable()
export class ContactsService {
async findAll(organizationId: string) {
return db
.select()
.from(contacts)
.where(eq(contacts.organizationId, organizationId))
.limit(100);
}
}
The service layer enforces organizationId at the query level. No amount of middleware failure or forgotten decorator can leak cross-tenant data because the SQL itself enforces isolation.
Global Exception Filter
Consistent error responses across all endpoints require a global exception filter. NestJS 11's HttpException hierarchy handles most cases, but you also need to catch unexpected errors and never expose stack traces in production.
// filters/global-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = 'Internal server error';
let errors: Record<string, string[]> | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
} else if (typeof exceptionResponse === 'object') {
const resp = exceptionResponse as Record<string, unknown>;
message = (resp.message as string) || message;
if (Array.isArray(resp.message)) {
// Validation errors from class-validator
errors = this.formatValidationErrors(resp.message as string[]);
message = 'Validation failed';
}
}
} else if (exception instanceof Error) {
this.logger.error(exception.message, exception.stack);
// Never expose stack traces in production
if (process.env.NODE_ENV !== 'production') {
message = exception.message;
}
}
response.status(status).json({
statusCode: status,
message,
errors,
path: request.url,
timestamp: new Date().toISOString(),
});
}
private formatValidationErrors(messages: string[]): Record<string, string[]> {
const errors: Record<string, string[]> = {};
for (const msg of messages) {
const [field, ...rest] = msg.split(' ');
if (!errors[field]) errors[field] = [];
errors[field].push(rest.join(' '));
}
return errors;
}
}
Register it in main.ts:
// main.ts
import { NestFactory } from '@nestjs/core';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';
async function bootstrap() {
// CRITICAL: Load env vars before NestJS bootstraps
require('dotenv').config({ path: join(__dirname, '..', '..', '..', '.env.local') });
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.listen(3001);
}
The NestJS 11 Route Wildcard Change
One of the most breaking changes in NestJS 11 is wildcard route matching. If you're migrating from NestJS 10, this will silently break your middleware:
// NestJS 10 — works
consumer.apply(LoggerMiddleware).forRoutes('*');
// NestJS 11 — use '*path' instead
consumer.apply(LoggerMiddleware).forRoutes('*path');
The same applies to Swagger setup and any string-based route patterns. This change affects middleware registration, route exclusions, and any place you use glob-style route matching.
Roles-Based Access Control
Beyond JWT authentication, enterprise APIs need role-based authorization. The decorator-plus-guard pattern keeps controllers clean:
// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export type Role = 'admin' | 'support' | 'user';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, Role } from '../decorators/roles.decorator';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
const hasRole = requiredRoles.some((role) => user.role === role);
if (!hasRole) {
throw new ForbiddenException('Insufficient permissions');
}
return true;
}
}
Usage in controllers:
// For top-level module controllers — one `..`
import { Roles } from '../auth/guards/roles.guard';
// For nested sub-module controllers — two `../..`
import { Roles } from '../../auth/guards/roles.guard';
@Controller('admin/contacts')
@Roles('admin', 'support')
export class AdminContactsController {}
The import path depth is a common source of bugs. Top-level module controllers use one ..; nested sub-module controllers use two ../...
Global EmailModule Pattern
The @Global() decorator solves the repetitive import problem for services used everywhere. Email is the canonical use case — you don't want to import EmailModule in every feature module that sends notifications.
// email/email.module.ts
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';
@Global()
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
Register it once in AppModule:
@Module({
imports: [
EmailModule, // Global — available everywhere
ContactsModule,
BillingModule,
// ...
],
})
export class AppModule {}
Now any service can inject EmailService without touching module imports. The same pattern works for Redis, EventBus, and any cross-cutting infrastructure service.
Rate Limiting Public Endpoints
Enterprise APIs face abuse attempts daily. NestJS's @nestjs/throttler integrates cleanly with the guard system:
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([
{ name: 'short', ttl: 1000, limit: 10 },
{ name: 'medium', ttl: 60000, limit: 100 },
]),
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
],
})
Override per-endpoint with @Throttle() for stricter limits on sensitive routes:
import { Throttle } from '@nestjs/throttler';
@Controller('auth')
export class AuthController {
@Post('exchange')
@Public()
@Throttle({ short: { ttl: 60000, limit: 5 } }) // 5 per minute
async exchangeCode(@Body() dto: ExchangeCodeDto) {
return this.authService.exchangeCode(dto.code);
}
}
Health Checks with @nestjs/terminus
Production APIs need health endpoints for load balancer checks and monitoring. @nestjs/terminus provides a declarative system with built-in indicators:
// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
import { Public } from '../auth/decorators/public.decorator';
import { DatabaseHealthIndicator } from './indicators/database.indicator';
import { RedisHealthIndicator } from './indicators/redis.indicator';
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: DatabaseHealthIndicator,
private redis: RedisHealthIndicator,
) {}
@Get()
@Public()
@HealthCheck()
check() {
return this.health.check([
() => this.db.isHealthy('database'),
() => this.redis.isHealthy('redis'),
]);
}
}
Custom indicator for Drizzle ORM (since terminus doesn't have one built-in):
// health/indicators/database.indicator.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { db } from '@ecosire/db';
import { sql } from 'drizzle-orm';
@Injectable()
export class DatabaseHealthIndicator extends HealthIndicator {
async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
await db.execute(sql`SELECT 1`);
return this.getStatus(key, true);
} catch (error) {
throw new HealthCheckError('Database check failed', this.getStatus(key, false));
}
}
}
DTO Validation Patterns
The ValidationPipe with whitelist: true strips unknown properties before they reach your service layer. Combined with class-transformer, DTOs become your first line of defense:
// contacts/dto/create-contact.dto.ts
import { IsString, IsEmail, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
export enum ContactType {
INDIVIDUAL = 'individual',
COMPANY = 'company',
}
export class CreateContactDto {
@ApiProperty({ example: 'Acme Corp' })
@IsString()
@MinLength(2)
@MaxLength(255)
@Transform(({ value }) => value?.trim())
name: string;
@ApiProperty({ example: '[email protected]' })
@IsEmail()
@Transform(({ value }) => value?.toLowerCase().trim())
email: string;
@ApiPropertyOptional({ enum: ContactType })
@IsOptional()
@IsEnum(ContactType)
type?: ContactType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(500)
notes?: string;
}
The @Transform decorators normalize data at the validation layer. Trimming whitespace and lowercasing emails prevents duplicate records from case differences.
Swagger Integration
Enterprise APIs need documentation. NestJS's Swagger module generates OpenAPI specs from decorators, but the setup must be deliberate:
// main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('ECOSIRE API')
.setDescription('Enterprise API for ECOSIRE platform')
.setVersion('1.0')
.addBearerAuth()
.addTag('auth', 'Authentication endpoints')
.addTag('contacts', 'Contact management')
.build();
const document = SwaggerModule.createDocument(app, config);
// Only expose in non-production environments
if (process.env.NODE_ENV !== 'production') {
SwaggerModule.setup('api/docs', app, document);
}
await app.listen(3001);
}
Every controller method needs @ApiOperation and @ApiResponse:
@Get(':id')
@ApiOperation({ summary: 'Get contact by ID' })
@ApiResponse({ status: 200, description: 'Contact found', type: ContactResponseDto })
@ApiResponse({ status: 404, description: 'Contact not found' })
async findOne(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
return this.contactsService.findOne(id, req.user.organizationId);
}
Common Pitfalls and Solutions
Pitfall 1: Circular dependencies between modules
Circular imports crash NestJS at startup with a cryptic error. The fix is forwardRef():
@Module({
imports: [forwardRef(() => BillingModule)],
})
export class LicenseModule {}
Better solution: extract shared logic to a third module that both import without circular dependency.
Pitfall 2: Env vars not available during module initialization
If you access process.env.DATABASE_URL in a class constructor or provider factory before dotenv loads, you get undefined. Solution: load dotenv at the very top of main.ts, before any NestJS import.
// main.ts — must be FIRST
import * as path from 'path';
require('dotenv').config({ path: path.join(__dirname, '..', '..', '..', '.env.local') });
// Then NestJS imports
import { NestFactory } from '@nestjs/core';
Pitfall 3: Missing await on async operations in lifecycle hooks
// Wrong — database connection might not be ready
@Injectable()
export class AppService implements OnModuleInit {
onModuleInit() {
this.seedDatabase(); // Not awaited!
}
}
// Correct
async onModuleInit() {
await this.seedDatabase();
}
Pitfall 4: Using sql.raw() in Drizzle queries
sql.raw() bypasses parameterization and opens SQL injection vectors. Always use the sql template literal:
// Dangerous — never do this
const result = await db.execute(sql.raw(`SELECT * FROM contacts WHERE id = '${id}'`));
// Safe — parameterized
const result = await db.execute(sql`SELECT * FROM contacts WHERE id = ${id}`);
Frequently Asked Questions
How do I handle versioning in NestJS 11 APIs?
NestJS 11 supports URI versioning, header versioning, and media type versioning out of the box. Enable it in main.ts with app.enableVersioning({ type: VersioningType.URI }), then decorate controllers with @Version('1'). For enterprise APIs, URI versioning (/v1/contacts) is the most explicit and cache-friendly approach.
What's the best way to handle file uploads in NestJS?
Use @nestjs/platform-express with multer for file uploads. For S3 uploads, configure a custom storage engine via multer-s3. Always validate file types and sizes at the pipe level before the handler runs, and never trust client-provided MIME types — validate magic bytes instead.
How should I structure database transactions in NestJS?
Drizzle ORM supports transactions with db.transaction(async (tx) => { ... }). Pass the transaction object to service methods rather than the global db instance. For multi-operation business logic (create order + deduct inventory + send email), wrap everything in a transaction and make email sending non-blocking with .catch() after commit.
When should I use guards vs. middleware vs. interceptors?
Guards handle authorization (can this user access this resource?). Middleware handles cross-cutting request transformation (logging, correlation IDs, parsing). Interceptors wrap the request-response cycle for transformation, caching, and metrics. The execution order is: Middleware → Guards → Interceptors (before) → Pipes → Handler → Interceptors (after) → Exception Filters.
How do I test NestJS modules in isolation?
Use Test.createTestingModule() to create a testing sandbox with mocked dependencies. Mock your service methods with jest.fn() or vi.fn(), and test controller behavior independently of your database. For integration tests, use @nestjs/testing with a real database connection (separate test database) and transaction rollback after each test.
What's the performance impact of global guards?
Global guards run on every request, so keep them fast. JWT verification is typically 1-5ms. Avoid database lookups in guards — load permissions during token creation and include them in the JWT payload. If you need fresh permissions on every request, use Redis with a short TTL rather than hitting the database.
Next Steps
Building enterprise APIs at scale requires the right architecture from day one. ECOSIRE's engineering team has built and operated a 56-module NestJS 11 backend handling complex multi-tenant workflows, Stripe billing, license management, and AI-powered analytics.
Whether you need a custom NestJS API, Odoo ERP integration, or a full-stack enterprise platform, our team brings production-proven patterns to your project. Explore our development services to see how we can accelerate your next build.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Transform Your Business with Odoo ERP
Expert Odoo implementation, customization, and support to streamline your operations.
Related Articles
The Complete Guide to Odoo ERP in 2026: Everything You Need to Know
Comprehensive Odoo ERP guide covering modules, pricing, implementation, customization, and integration. Learn why 12M+ users choose Odoo in 2026.
Microsoft Dynamics 365 to Odoo Migration: Enterprise Guide
Enterprise guide for migrating from Microsoft Dynamics 365 to Odoo. Module equivalents, data extraction, customization audit, and parallel run strategy.
ERP vs CRM: What's the Difference and Which Do You Need?
ERP vs CRM comparison explaining core functions, when you need each system, when you need both, integration benefits, and how Odoo unifies them.