Back to Blog
Web Development13 min readMay 20, 2025

NestJS Backend Architecture: Building APIs That Scale to Millions

A deep-dive into NestJS architecture patterns for production APIs — modules, guards, interceptors, caching, queue-based workers, and horizontal scaling strategies.

NestJSNode.jsTypeScriptREST APIArchitecture
A

Azam

DevOps & AI Consultant

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.

Want to Build This for Your Team?

I help teams implement the patterns and architectures described in these articles. Let's talk about your project.

Book a Free Call