# Nestjs Scale Maintainability

> Designs NestJS applications for horizontal scalability and long-term maintainability. Use when architecting for high traffic, microservices decomposition, caching strategies, queue-based workloads, observability, or CI/CD pipeline design. Triggers on requests like "scale my NestJS app", "add Redis caching", "implement message queue", "set up health checks", "add observability", "prepare for microservices", "optimize performance", or "improve code maintainability". Covers stateless design, event-driven patterns, feature flags, database connection pooling, and structured logging.

- Skill: `namds29/nestjs-scale-maintainability` (Agent Skill)
- Install (CLI): `npx skillmds@latest add namds29/nestjs-scale-maintainability`
- Raw SKILL.md: https://api.skillmd.com/api/skills/namds29/nestjs-scale-maintainability/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: namds29 (https://skillmd.com/u/namds29)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/namds29/nestjs-scale-maintainability

---


# 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.

```typescript
// ❌ 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)

```typescript
@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

```typescript
// 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.

```typescript
// 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

```typescript
// 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

```typescript
// ✅ 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

```typescript
// 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 }),
    ]);
  }
}
```

```typescript
// 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

```typescript
// 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)

```typescript
// 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.

```typescript
// 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

```typescript
// 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:

1. **Stateless verification** — Does any service store data in class properties that persists between requests? All shared state must be in Redis or DB.
2. **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.
3. **N+1 query detection** — Does any loop contain a DB query? Use `QueryBuilder` with joins or `findByIds` batching.
4. **Connection pool sizing** — Is the pool max larger than the DB's `max_connections / num_instances`? Oversized pools starve other instances.
5. **Health check quality** — Does the `/health/ready` endpoint check all critical dependencies (DB, Redis, external APIs)? Does Kubernetes use it as a readiness probe?
6. **Graceful shutdown** — Does the app handle `SIGTERM` with a drain timeout? Will in-flight requests complete cleanly during rolling deploys?
7. **Log structure** — Are all log calls passing structured objects, not string interpolation? Is `correlation-id` included on every log line?
8. **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.
9. **Test coverage** — Does every use-case have a unit test? Does every external integration have a test with mocked HTTP?
10. **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)

