Why NestJS for Production APIs?
NestJS is the most opinionated Node.js framework available, and that is its greatest strength. It enforces a module-based architecture inspired by Angular, ships with built-in dependency injection, and has first-class TypeScript support throughout. Teams that switch from Express to NestJS consistently report fewer architecture arguments, faster onboarding, and less time spent wiring up cross-cutting concerns.
This guide covers the architecture decisions that matter most once you move past the tutorial phase and into a system that needs to handle real load.
Module Architecture: Domain-First Organisation
Organise modules by domain, not by technical layer. An auth module, a users module, and an orders module is better than a single controllers folder and a single services folder. Each domain module owns its controller, service, repository, and DTOs.
src/
auth/
auth.module.ts
auth.controller.ts
auth.service.ts
strategies/
jwt.strategy.ts
users/
users.module.ts
users.controller.ts
users.service.ts
dto/
create-user.dto.ts
shared/
database/
cache/
queue/
The shared module contains infrastructure concerns that cut across domains. Everything else is self-contained.
Guards and Interceptors for Cross-Cutting Concerns
Never put auth logic inside controllers. Use Guards for authentication and authorization, Interceptors for logging, response transformation, and caching.
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context)
}
}
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest()
const start = Date.now()
return next.handle().pipe(
tap(() => {
const ms = Date.now() - start
console.log(`${req.method} ${req.url} ${ms}ms`)
})
)
}
}
Apply guards and interceptors globally in main.ts rather than per-controller to avoid forgetting them on new endpoints.
Database Layer: Repository Pattern with TypeORM
Wrap TypeORM repositories in a service layer. Controllers should never import TypeORM entities directly. This keeps your business logic testable and decoupled from the ORM.
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async findByEmail(email: string): Promise<User | null> {
return this.userRepo.findOne({ where: { email } })
}
async create(dto: CreateUserDto): Promise<User> {
const user = this.userRepo.create(dto)
return this.userRepo.save(user)
}
}
Redis Caching with Cache Manager
NestJS has first-class cache support via @nestjs/cache-manager. Configure Redis as the backing store and use the @UseInterceptors(CacheInterceptor) decorator on GET endpoints that are expensive to compute.
// app.module.ts
CacheModule.registerAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
store: redisStore,
host: config.get('REDIS_HOST'),
port: config.get('REDIS_PORT'),
ttl: 60,
}),
inject: [ConfigService],
isGlobal: true,
})
// In your controller
@Get(':id')
@UseInterceptors(CacheInterceptor)
@CacheTTL(300)
async findOne(@Param('id') id: string) {
return this.usersService.findById(id)
}
Queue-Based Workers with BullMQ
Move any slow operation — sending emails, processing uploads, calling third-party APIs — off the request path and into a queue. BullMQ with Redis is the standard choice in the NestJS ecosystem.
// Producer — trigger from your service
@InjectQueue('email') private emailQueue: Queue
await this.emailQueue.add('send-welcome', {
to: user.email,
userId: user.id,
})
// Consumer — separate worker process
@Processor('email')
export class EmailProcessor {
@Process('send-welcome')
async handleWelcome(job: Job) {
await this.mailerService.send(job.data)
}
}
Run workers as separate Node.js processes so a slow job does not affect API response times. In Kubernetes, this means a separate Deployment for your worker pods, scaled independently of your API pods.
Horizontal Scaling Considerations
- Use stateless JWT auth — no session storage in API pods
- Store all session/cache state in Redis, not in process memory
- Use sticky sessions at the load balancer only if you have WebSocket connections
- Database connection pooling is critical — each pod opens its own pool, so total connections = pods × pool size. Keep pool size small (5–10) per pod
- Use a distributed lock (Redlock) for any operation that must run on exactly one pod at a time
NestJS does not get in the way of scaling — it just requires that you make statelessness a first-class constraint from the start.