NestJS Scale & Maintainability Skill
When designing for scalability, the goal is: any instance of the app can handle any request. When designing for maintainability, the goal is: any developer can understand, modify, and test any module in under 30 minutes. These are not separate concerns — a well-maintained codebase scales more predictably.
Scalability Principles for NestJS
Scale dimension → NestJS approach
─────────────────────────────────────────────────────────
Stateless instances → Redis for sessions, locks, caches
CPU-bound work → Bull/BullMQ job queues + workers
I/O bottlenecks → Connection pooling, async queues
Read-heavy load → Redis cache + CDN for static assets
Write spikes → Queue writes, batch inserts
Cross-instance coord → Redis pub/sub or NATS messaging
Observability → Structured logs + metrics + traces
Zero-downtime deploy → Health checks + graceful shutdown
1. Stateless App Design
Every app instance must be completely stateless. Nothing lives in process memory that another instance needs to share.
// ❌ Never store state in-memory across requests
class OrderService {
private activeOrders = new Map(); // dies on instance restart
}
// ✅ All shared state in Redis
@Injectable()
export class OrderStateService {
constructor(@InjectRedis() private readonly redis: Redis) {}
async setActive(
orderId: string,
data: OrderState,
ttlSeconds = 3600,
): Promise<void> {
await this.redis.setex(
`order:active:${orderId}`,
ttlSeconds,
JSON.stringify(data),
);
}
async getActive(orderId: string): Promise<OrderState | null> {
const raw = await this.redis.get(`order:active:${orderId}`);
return raw ? JSON.parse(raw) : null;
}
}
Distributed Locking (prevent race conditions across instances)
@Injectable()
export class DistributedLockService {
constructor(@InjectRedis() private readonly redis: Redis) {}
async withLock<T>(
key: string,
ttlMs: number,
fn: () => Promise<T>,
): Promise<T> {
const lockKey = `lock:${key}`;
const token = randomUUID();
const acquired = await this.redis.set(lockKey, token, "PX", ttlMs, "NX");
if (!acquired) throw new ConflictException(`Resource ${key} is locked`);
try {
return await fn();
} finally {
// Lua script: only delete if we own the lock
await this.redis.eval(
`if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end`,
1,
lockKey,
token,
);
}
}
}
2. Caching Strategy
// infrastructure/cache/cache.interceptor.ts
@Injectable()
export class HttpCacheInterceptor implements NestInterceptor {
constructor(@InjectRedis() private readonly redis: Redis) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
const req = context.switchToHttp().getRequest<Request>();
if (req.method !== 'GET') return next.handle(); // cache GET only
const ttl = Reflect.getMetadata(CACHE_TTL_KEY, context.getHandler()) ?? 60;
const cacheKey = this.buildKey(req);
const cached = await this.redis.get(cacheKey);
if (cached) return of(JSON.parse(cached));
return next.handle().pipe(
tap(async (response) => {
await this.redis.setex(cacheKey, ttl, JSON.stringify(response));
}),
);
}
private buildKey(req: Request): string {
// Include user ID for personalized endpoints to prevent cache poisoning
const userId = (req as AuthenticatedRequest).user?.id ?? 'anonymous';
return `cache:${userId}:${req.path}:${new URLSearchParams(req.query as Record<string,string>).toString()}`;
}
}
// Cache invalidation — call when data changes
@Injectable()
export class CacheInvalidator {
async invalidatePattern(pattern: string): Promise<void> {
const keys = await this.redis.keys(`cache:*:${pattern}*`);
if (keys.length) await this.redis.del(...keys);
}
}
// Usage on controller
@CacheTtl(300) // 5 minutes
@UseInterceptors(HttpCacheInterceptor)
@Get('products')
async listProducts() { ... }
3. Queue-Based Background Jobs (BullMQ)
Offload all non-critical work from the request cycle to queues.
// infrastructure/queues/email.queue.ts
export const EMAIL_QUEUE = "email";
@Injectable()
export class EmailQueueProducer {
constructor(@InjectQueue(EMAIL_QUEUE) private readonly queue: Queue) {}
async queueWelcomeEmail(userId: string, email: string): Promise<void> {
await this.queue.add(
"welcome-email",
{ userId, email },
{
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 50 },
},
);
}
}
// infrastructure/queues/email.processor.ts
@Processor(EMAIL_QUEUE)
export class EmailProcessor extends WorkerHost {
private readonly logger = new Logger(EmailProcessor.name);
@Process("welcome-email")
async handleWelcomeEmail(
job: Job<{ userId: string; email: string }>,
): Promise<void> {
const { userId, email } = job.data;
this.logger.log(
`Processing welcome email job ${job.id} for user ${userId}`,
);
await this.emailService.sendWelcome(email);
}
@OnWorkerEvent("failed")
onFailed(job: Job, error: Error): void {
this.logger.error(
`Job ${job.id} failed after ${job.attemptsMade} attempts`,
error.stack,
);
}
}
4. Database Connection Pooling & Query Optimization
// infrastructure/database/typeorm.config.ts
TypeOrmModule.forRootAsync({
useFactory: (config: ConfigService): TypeOrmModuleOptions => ({
type: 'postgres',
host: config.getOrThrow('DB_HOST'),
port: config.get<number>('DB_PORT', 5432),
username: config.getOrThrow('DB_USER'),
password: config.getOrThrow('DB_PASSWORD'),
database: config.getOrThrow('DB_NAME'),
// Connection pool — tune per instance, not per DB
extra: {
max: config.get<number>('DB_POOL_MAX', 20), // max connections per app instance
min: config.get<number>('DB_POOL_MIN', 5),
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
},
// Read replicas for read-heavy load
replication: {
master: { host: config.getOrThrow('DB_HOST') },
slaves: config.get('DB_READ_REPLICAS', '').split(',')
.filter(Boolean)
.map(host => ({ host })),
},
migrations: ['dist/infrastructure/database/migrations/*.js'],
migrationsRun: true,
logging: config.get('NODE_ENV') !== 'production',
}),
inject: [ConfigService],
}),
Query Performance Patterns
// ✅ Paginate large result sets — never return unbounded lists
async findUsers(page: number, limit: number): Promise<PaginatedResult<User>> {
const [items, total] = await this.repo.findAndCount({
take: Math.min(limit, 100), // enforce max page size
skip: (page - 1) * limit,
order: { createdAt: 'DESC' },
// Only select columns you need
select: { id: true, email: true, name: true, createdAt: true },
});
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
}
// ✅ Use QueryBuilder for complex joins — avoid N+1 queries
async findOrdersWithItems(userId: string): Promise<Order[]> {
return this.repo.createQueryBuilder('order')
.leftJoinAndSelect('order.items', 'item')
.leftJoinAndSelect('item.product', 'product')
.where('order.userId = :userId', { userId })
.andWhere('order.status != :status', { status: 'CANCELLED' })
.getMany();
}
5. Health Checks & Graceful Shutdown
// infrastructure/health/health.controller.ts
@Controller("health")
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
private readonly redis: RedisHealthIndicator,
private readonly disk: DiskHealthIndicator,
private readonly memory: MemoryHealthIndicator,
) {}
@Get("live")
@HealthCheck()
liveness() {
// Liveness: "is the process alive?" — restart if fails
return this.health.check([
() => this.memory.checkHeap("memory_heap", 512 * 1024 * 1024), // 512MB
]);
}
@Get("ready")
@HealthCheck()
readiness() {
// Readiness: "can it serve traffic?" — remove from load balancer if fails
return this.health.check([
() => this.db.pingCheck("database", { timeout: 2000 }),
() => this.redis.isHealthy("redis"),
() => this.disk.checkStorage("disk", { path: "/", threshold: 0.9 }),
]);
}
}
// main.ts — graceful shutdown
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks(); // triggers onModuleDestroy lifecycle hooks
const server = await app.listen(3000);
// Give in-flight requests time to complete before shutdown
process.on("SIGTERM", async () => {
server.close(async () => {
await app.close();
process.exit(0);
});
// Force exit after 30s if requests don't drain
setTimeout(() => process.exit(1), 30_000);
});
}
6. Structured Logging & Observability
// infrastructure/logging/logger.service.ts
// Use pino for structured, high-performance JSON logging
import pino from "pino";
@Injectable()
export class AppLogger implements LoggerService {
private readonly logger = pino({
level: process.env.LOG_LEVEL ?? "info",
formatters: {
level: (label) => ({ level: label }), // use string level, not number
},
base: {
service: process.env.APP_NAME,
version: process.env.APP_VERSION,
env: process.env.NODE_ENV,
},
// Never log in pretty print in production — it's slow
transport:
process.env.NODE_ENV !== "production"
? { target: "pino-pretty" }
: undefined,
});
log(message: string, context?: Record<string, unknown>) {
this.logger.info(context ?? {}, message);
}
error(message: string, trace?: string, context?: Record<string, unknown>) {
this.logger.error({ ...context, stack: trace }, message);
}
warn(message: string, context?: Record<string, unknown>) {
this.logger.warn(context ?? {}, message);
}
}
Request Correlation ID (trace requests across services)
// infrastructure/http/middleware/correlation-id.middleware.ts
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction): void {
const id = (req.headers["x-correlation-id"] as string) ?? randomUUID();
req.headers["x-correlation-id"] = id;
res.setHeader("x-correlation-id", id);
// Attach to async context so logger can include it automatically
asyncLocalStorage.run({ correlationId: id }, next);
}
}
7. Feature Flags
Decouple deploy from release. New code ships disabled, enabled via flag without redeploy.
// infrastructure/feature-flags/feature-flag.service.ts
@Injectable()
export class FeatureFlagService {
constructor(@InjectRedis() private readonly redis: Redis) {}
async isEnabled(flag: string, userId?: string): Promise<boolean> {
// Check user-level override first
if (userId) {
const userFlag = await this.redis.get(`feature:user:${userId}:${flag}`);
if (userFlag !== null) return userFlag === 'true';
}
// Fall back to global flag
const global = await this.redis.get(`feature:global:${flag}`);
return global === 'true';
}
}
// Usage in service
async processPayment(dto: PaymentDto, userId: string) {
const useNewProcessor = await this.flags.isEnabled('new-payment-processor', userId);
return useNewProcessor
? this.newPaymentService.process(dto)
: this.legacyPaymentService.process(dto);
}
8. Module Maintainability Conventions
Naming conventions (enforce in code review)
Feature module: users/
├── users.module.ts
├── users.controller.ts # HTTP interface only
├── users.service.ts # orchestration (thin)
├── use-cases/ # one file per use-case
│ ├── create-user.use-case.ts
│ └── deactivate-user.use-case.ts
├── entities/ # domain entities
├── dtos/ # request/response shapes
├── repositories/ # DB access
└── users.spec.ts # co-located tests
Testing conventions — always co-locate tests
// Co-located unit test with mocked dependencies
describe("CreateUserUseCase", () => {
let useCase: CreateUserUseCase;
let userRepo: jest.Mocked<IUserRepository>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
CreateUserUseCase,
{
provide: USER_REPOSITORY,
useValue: { findByEmail: jest.fn(), save: jest.fn() },
},
{ provide: EMAIL_SERVICE, useValue: { sendWelcome: jest.fn() } },
],
}).compile();
useCase = module.get(CreateUserUseCase);
userRepo = module.get(USER_REPOSITORY);
});
it("should return conflict error when email already exists", async () => {
userRepo.findByEmail.mockResolvedValue(buildUser());
const result = await useCase.execute({
email: "test@example.com",
name: "Test",
});
expect(result.isFailure).toBe(true);
expect(result.error).toBeInstanceOf(AppError.ConflictError);
});
});
Scale & Maintainability Review Checklist
When reviewing NestJS code for scalability and maintainability:
- Stateless verification — Does any service store data in class properties that persists between requests? All shared state must be in Redis or DB.
- Request cycle work — Is there any slow I/O (email sending, image processing, external API calls with no SLA) running synchronously in a controller? These belong in a BullMQ queue.
- N+1 query detection — Does any loop contain a DB query? Use
QueryBuilderwith joins orfindByIdsbatching. - Connection pool sizing — Is the pool max larger than the DB's
max_connections / num_instances? Oversized pools starve other instances. - Health check quality — Does the
/health/readyendpoint check all critical dependencies (DB, Redis, external APIs)? Does Kubernetes use it as a readiness probe? - Graceful shutdown — Does the app handle
SIGTERMwith a drain timeout? Will in-flight requests complete cleanly during rolling deploys? - Log structure — Are all log calls passing structured objects, not string interpolation? Is
correlation-idincluded on every log line? - Module size — Does any module have more than ~10 files or any service with more than ~200 lines? If yes, it probably needs splitting into sub-features.
- Test coverage — Does every use-case have a unit test? Does every external integration have a test with mocked HTTP?
- Feature flag coverage — Are risky new features behind a flag? Is there a rollback plan that doesn't require a code deploy?
How to provide feedback
- Classify the issue by scale dimension: stateless, throughput, latency, observability, or deploy safety
- Estimate the blast radius: does this affect one endpoint, one module, or the entire app?
- Provide a before/after snippet for changes under 30 lines; link to the relevant section above for larger patterns
- Flag quick wins (1-2 hours to fix) separately from architectural improvements (sprint-level effort)