NestJS Production Patterns
Module Structure
Every feature is a self-contained NestJS module:
modules/<feature>/
<feature>.module.ts # Module definition (imports, providers, exports)
<feature>.controller.ts # HTTP handlers (REST endpoints)
<feature>.service.ts # Business logic (orchestration, validation)
<feature>.repository.ts # Data access (ORM queries only)
<feature>.mapper.ts # Entity <-> DTO transformation
<feature>.constants.ts # Queue names, job IDs, span names
dto/ # Zod-based request/response DTOs
create-<feature>.dto.ts
update-<feature>.dto.ts
schemas/ # Database type inference (insert/select)
db-<feature>.ts
<feature>.spec.ts # Co-located unit tests
Module Registration
@Module({
imports: [DatabaseModule, OtherFeatureModule],
providers: [FeatureService, FeatureRepository, FeatureMapper],
controllers: [FeatureController],
exports: [FeatureService, FeatureRepository], // Public API for other modules
})
export class FeatureModule {}
Container modules group related feature modules without their own logic:
@Module({
imports: [UsersModule, PaymentsModule, AnalyticsModule],
controllers: [BackofficeController], // Optional top-level controller
})
export class BackofficeModule {}
Dynamic modules for configurable services (e.g., email, SMS):
@Global()
@Module({})
export class EmailModule {
static forRoot(options: EmailOptions): DynamicModule { /* ... */ }
static forRootAsync(options: EmailAsyncOptions): DynamicModule { /* ... */ }
}
Controllers
@ApiTags('Feature')
@ApiSecurity('application-oauth2')
@Controller('features')
export class FeatureController {
constructor(private readonly service: FeatureService) {}
@Get()
@Version('1')
@ApiOperation({ summary: 'List features' })
@ZodResponse({ status: 200, type: FeatureListDto })
async list(@Query() filter: FeatureFilterDto): Promise<FeatureListDto> {
return this.service.findAll(filter);
}
@Post()
@Version('1')
@HttpCode(HttpStatus.CREATED)
@Permissions(PermissionEnum.enum['feature:write'])
@ZodResponse({ status: 201, type: FeatureDto })
async create(@Body() dto: CreateFeatureDto): Promise<FeatureDto> {
return this.service.create(dto);
}
@Get(':id')
@Version('1')
async findOne(@Param('id') id: string): Promise<FeatureDto> {
return this.service.findById(id);
}
}
Decorator checklist for every endpoint:
@Version('1')— API versioning (URI-based)@ApiOperation({ summary })— Swagger docs@ZodResponse({ status, type })— Response validation + Swagger schema@HttpCode()— Override default status (201 for POST, 200 for others)@Permissions()+@UseGuards(PermissionsGuard)— Authorization@Throttle({ name: { ttl, limit } })— Rate limiting (override module default)@Public()— Skip authentication
User context: @KeycloakUser() user: JwtToken injects the authenticated user from JWT.
Services
@Injectable()
export class FeatureService {
private readonly logger = new Logger(FeatureService.name);
constructor(
private readonly repository: FeatureRepository,
private readonly mapper: FeatureMapper,
) {}
@Transactional()
async create(dto: CreateFeatureDto): Promise<FeatureDto> {
const entity = await this.repository.create(this.mapper.toInsert(dto));
return this.mapper.toDto(entity);
}
}
Key patterns:
- Logger:
new Logger(ClassName.name)(orOtelLoggerfor trace-correlated logs) @Transactional()from@nestjs-cls/transactionalfor atomic operations- Services orchestrate; repositories query; mappers transform
- Throw NestJS exceptions:
NotFoundException,BadRequestException,ConflictException - Never inject the database directly — always go through repositories
Repositories (Drizzle ORM)
@Injectable()
export class FeatureRepository {
constructor(private readonly txHost: TransactionHost<DbTransactionAdapter>) {}
async findById(id: string) {
const rows = await this.txHost.tx
.select()
.from(features)
.where(eq(features.id, id))
.limit(1);
return rows.at(0) ?? null;
}
async create(data: InsertFeature) {
const results = await this.txHost.tx
.insert(features)
.values(data)
.returning();
const created = results.at(0);
if (!created) throw new Error('Failed to create feature');
return created;
}
async findMany(filter: FeatureFilter): Promise<Result<SelectFeature>> {
const where = and(
filter.name ? ilike(features.name, `%${filter.name}%`) : undefined,
filter.status ? eq(features.status, filter.status) : undefined,
);
const total = await this.txHost.tx.$count(features, where);
const data = await this.txHost.tx
.select()
.from(features)
.where(where)
.limit(filter.limit)
.offset(filter.offset)
.orderBy(desc(features.createdAt));
return { total, data };
}
async update(id: string, data: Partial<InsertFeature>) {
const results = await this.txHost.tx
.update(features)
.set(data)
.where(eq(features.id, id))
.returning();
return results.at(0) ?? null;
}
}
Conventions:
TransactionHost<DbTransactionAdapter>— transaction-aware via@nestjs-cls/transactional- Access DB via
this.txHost.tx(not a direct DB reference) - Always
.returning()on INSERT/UPDATE - Use
rows.at(0) ?? nullfor single-row queries (neverrows[0]) Result<T> = { total: number; data: T[] }for paginated responses
DTOs (Zod-based)
import { createZodDto } from 'nestjs-zod';
import { z } from 'zod';
const CreateFeatureSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(2000).optional(),
status: z.enum(['active', 'inactive']).default('active'),
});
export class CreateFeatureDto extends createZodDto(CreateFeatureSchema) {}
For database types, infer from Drizzle schema:
export type SelectFeature = typeof features.$inferSelect;
export type InsertFeature = typeof features.$inferInsert;
Mappers
@Injectable()
export class FeatureMapper {
toDto(entity: SelectFeature): FeatureDto {
return {
id: entity.id,
name: entity.name,
createdAt: entity.createdAt.toISOString(),
};
}
toInsert(dto: CreateFeatureDto): InsertFeature {
return {
name: dto.name,
description: dto.description ?? null,
};
}
}
When to split mappers: Separate UserFeatureMapper (mobile/public) from FeatureMapper (admin) when different consumers need different DTO shapes.
Guards & Permissions
// Custom permission decorator
export const Permissions = (...perms: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, perms);
// Guard reads metadata and checks JWT roles
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<Permission[]>(PERMISSIONS_KEY, context.getHandler());
if (!required?.length) return true;
const user = context.switchToHttp().getRequest().user;
return required.some(p => user.realm_access.roles.includes(p));
}
}
Global Providers (AppModule)
providers: [
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_PIPE, useClass: ZodValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: ThrottlerGuard },
]
Bootstrap (Fastify)
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ querystringParser: qs.parse }),
);
app.enableCors({ origin: true, credentials: true });
app.setGlobalPrefix('api');
app.enableVersioning({ type: VersioningType.URI });
await app.register(helmet);
await app.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } });
// Swagger setup...
await app.listen(PORT, '0.0.0.0');
Constants File Pattern
// Queue name (BullModule.registerQueue + @InjectQueue)
export const FEATURE_QUEUE_NAME = 'feature-processing' as const;
// Job names (when enqueuing)
export const FeatureJobName = { PROCESS: 'process-feature' } as const;
// Job IDs (for deduplication)
export const FeatureJobId = { PROCESS: 'process-feature-job' } as const;
// OTel span names (dot-notation)
export const FeatureSpanName = { PROCESS: 'feature.process' } as const;
Critical: @InjectQueue must use the queue name constant, never a job-ID constant.
Idempotency
For operations that may be retried (queue jobs, webhooks):
- Check for existing records before creating (e.g.,
hasLedgerEntry(referenceId)) - Log duplicates as warnings, return early without error
- Use
jobIdin BullMQ for built-in deduplication