← Back to Blog
Building Scalable APIs with NestJS

NestJS transformed how we built the Catalyst PSA backend. Coming from years of Express development, NestJS's structure, dependency injection, and TypeScript-first approach made building and maintaining a large-scale API dramatically easier.

After building an API handling 50,000+ requests/minute with NestJS, here's everything I learned about building scalable APIs.

Why NestJS Over Express

Express is great for simple APIs, but enterprise applications need structure. NestJS provides:

  • Dependency Injection: Testable, modular code
  • TypeScript: Type safety end-to-end
  • Modularity: Clear boundaries between features
  • Decorators: Clean, declarative syntax
  • Built-in tools: Guards, interceptors, pipes, exception filters

For Catalyst PSA's 320K+ LOC codebase, this structure was essential.

Project Structure

src/
├── main.ts
├── app.module.ts
├── modules/
│   ├── projects/
│   │   ├── projects.module.ts
│   │   ├── projects.controller.ts
│   │   ├── projects.service.ts
│   │   ├── entities/
│   │   ├── dto/
│   │   └── repositories/
│   ├── time-tracking/
│   ├── invoicing/
│   └── users/
├── common/
│   ├── decorators/
│   ├── filters/
│   ├── guards/
│   ├── interceptors/
│   └── pipes/
└── infrastructure/
    ├── database/
    ├── cache/
    └── messaging/

Each feature is a self-contained module with its own controllers, services, and repositories.

Dependency Injection for Testability

NestJS's DI makes testing trivial:

// projects/projects.service.ts
@Injectable()
export class ProjectsService {
  constructor(
    @Inject('ProjectRepository')
    private readonly repository: ProjectRepository,
    private readonly eventBus: EventBus,
    private readonly cache: CacheService
  ) {}
 
  async create(dto: CreateProjectDTO): Promise<Project> {
    const project = Project.create(dto);
 
    await this.repository.save(project);
 
    await this.eventBus.publish(new ProjectCreatedEvent(project));
 
    return project;
  }
}
 
// Testing is easy - inject mocks
describe('ProjectsService', () => {
  let service: ProjectsService;
  let repository: jest.Mocked<ProjectRepository>;
 
  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        ProjectsService,
        {
          provide: 'ProjectRepository',
          useValue: {
            save: jest.fn(),
            findById: jest.fn(),
          },
        },
        {
          provide: EventBus,
          useValue: { publish: jest.fn() },
        },
      ],
    }).compile();
 
    service = module.get(ProjectsService);
    repository = module.get('ProjectRepository');
  });
 
  it('should create project', async () => {
    const dto = { name: 'Test Project', clientId: '123' };
 
    await service.create(dto);
 
    expect(repository.save).toHaveBeenCalled();
  });
});

No need for complex dependency mocking—NestJS DI handles it.

DTOs for Validation

Data Transfer Objects with class-validator ensure type safety and validation:

// dto/create-project.dto.ts
import { IsString, IsUUID, IsOptional, Min, Max } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
 
export class CreateProjectDTO {
  @ApiProperty({ description: 'Project name', example: 'Website Redesign' })
  @IsString()
  @MinLength(1)
  @MaxLength(255)
  name: string;
 
  @ApiProperty({ description: 'Client UUID' })
  @IsUUID()
  clientId: string;
 
  @ApiProperty({ description: 'Project budget', example: 50000, required: false })
  @IsOptional()
  @Min(0)
  @Max(10000000)
  budget?: number;
 
  @ApiProperty({ description: 'Project description', required: false })
  @IsOptional()
  @IsString()
  @MaxLength(5000)
  description?: string;
}
 
// Controller
@Post()
async create(@Body() dto: CreateProjectDTO) {
  // dto is validated automatically!
  return await this.projectsService.create(dto);
}

Invalid requests return detailed error messages:

{
  "statusCode": 400,
  "message": [
    "name should not be empty",
    "clientId must be a UUID",
    "budget must not be greater than 10000000"
  ],
  "error": "Bad Request"
}

Guards for Authorization

Guards control access to routes:

// guards/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
 
  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
 
    if (!requiredRoles) {
      return true;
    }
 
    const request = context.switchToHttp().getRequest();
    const user = request.user;
 
    return requiredRoles.some(role => user.roles?.includes(role));
  }
}
 
// Decorator
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
 
// Usage
@Post('projects')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin', 'project-manager')
async create(@Body() dto: CreateProjectDTO) {
  return await this.projectsService.create(dto);
}

Only admins and project managers can create projects. Guards apply across controllers, ensuring consistent auth.

Interceptors for Logging and Transformation

Interceptors handle cross-cutting concerns:

// interceptors/logging.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  constructor(private logger: Logger) {}
 
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url, user } = request;
    const startTime = Date.now();
 
    return next.handle().pipe(
      tap(() => {
        const duration = Date.now() - startTime;
        this.logger.log(`${method} ${url} - ${duration}ms - User: ${user?.id}`);
      }),
      catchError(error => {
        const duration = Date.now() - startTime;
        this.logger.error(
          `${method} ${url} - ${duration}ms - Error: ${error.message}`,
          error.stack
        );
        throw error;
      })
    );
  }
}
 
// Apply globally
app.useGlobalInterceptors(new LoggingInterceptor(logger));

Every request is logged with duration and user context automatically.

Transform Response Format

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      }))
    );
  }
}
 
// Responses become:
{
  "success": true,
  "data": { "id": "123", "name": "Project" },
  "timestamp": "2024-11-05T10:30:00.000Z"
}

Exception Filters for Error Handling

Centralize error handling:

// filters/http-exception.filter.ts
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  constructor(private logger: Logger) {}
 
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();
 
    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
 
    const message =
      exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal server error';
 
    this.logger.error(
      `${request.method} ${request.url}`,
      exception instanceof Error ? exception.stack : exception
    );
 
    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      message,
    });
  }
}
 
// Apply globally
app.useGlobalFilters(new HttpExceptionFilter(logger));

All exceptions are caught, logged, and returned in consistent format.

Pipes for Transformation and Validation

Transform and validate data:

// pipes/parse-uuid.pipe.ts
@Injectable()
export class ParseUUIDPipe implements PipeTransform {
  transform(value: string): string {
    if (!isUUID(value)) {
      throw new BadRequestException('Invalid UUID format');
    }
    return value;
  }
}
 
// Usage
@Get(':id')
async getProject(@Param('id', ParseUUIDPipe) id: string) {
  return await this.projectsService.findById(id);
}

NestJS includes built-in pipes: ValidationPipe, ParseIntPipe, ParseUUIDPipe, etc.

Caching for Performance

Add caching with decorators:

// Install cache manager
import { CacheModule } from '@nestjs/cache-manager';
 
@Module({
  imports: [
    CacheModule.register({
      ttl: 300, // 5 minutes
      max: 100, // max items in cache
    }),
  ],
})
export class ProjectsModule {}
 
// Controller
@Controller('projects')
export class ProjectsController {
  @Get(':id')
  @UseInterceptors(CacheInterceptor)
  @CacheKey('project')
  @CacheTTL(300)
  async getProject(@Param('id') id: string) {
    return await this.projectsService.findById(id);
  }
}

Requests are cached automatically. For Redis:

import * as redisStore from 'cache-manager-redis-store';
 
CacheModule.register({
  store: redisStore,
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
  ttl: 300,
});

Pagination and Filtering

Standardize pagination:

// dto/pagination.dto.ts
export class PaginationDTO {
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number = 20;
 
  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(0)
  page?: number = 0;
 
  @IsOptional()
  @IsString()
  sortBy?: string = 'createdAt';
 
  @IsOptional()
  @IsEnum(['asc', 'desc'])
  sortOrder?: 'asc' | 'desc' = 'desc';
}
 
// Controller
@Get()
async getProjects(@Query() pagination: PaginationDTO) {
  return await this.projectsService.findAll(pagination);
}
 
// Service
async findAll(pagination: PaginationDTO) {
  const { limit, page, sortBy, sortOrder } = pagination;
 
  const [items, total] = await this.repository.findAndCount({
    take: limit,
    skip: page * limit,
    order: { [sortBy]: sortOrder },
  });
 
  return {
    items,
    total,
    page,
    pageCount: Math.ceil(total / limit),
  };
}

Requests: GET /projects?limit=10&page=2&sortBy=name&sortOrder=asc

Rate Limiting

Protect against abuse:

npm install @nestjs/throttler
import { ThrottlerModule } from '@nestjs/throttler';
 
@Module({
  imports: [
    ThrottlerModule.forRoot({
      ttl: 60,
      limit: 100, // 100 requests per 60 seconds
    }),
  ],
})
export class AppModule {}
 
// Apply to specific routes
@Controller('projects')
@UseGuards(ThrottlerGuard)
export class ProjectsController {
  // All routes limited to 100/min
 
  @Post()
  @Throttle(10, 60) // Override: 10 per minute
  async create(@Body() dto: CreateProjectDTO) {
    return await this.projectsService.create(dto);
  }
}

API Documentation with Swagger

Auto-generate API docs:

// main.ts
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
 
const config = new DocumentBuilder()
  .setTitle('Catalyst PSA API')
  .setDescription('Professional Services Automation Platform API')
  .setVersion('1.0')
  .addBearerAuth()
  .build();
 
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);

Visit /api/docs for interactive API documentation. DTOs with @ApiProperty() appear automatically.

Background Jobs with Bull

Handle async tasks:

npm install @nestjs/bull bull
import { BullModule } from '@nestjs/bull';
 
@Module({
  imports: [
    BullModule.forRoot({
      redis: {
        host: 'localhost',
        port: 6379,
      },
    }),
    BullModule.registerQueue({
      name: 'email',
    }),
  ],
})
export class AppModule {}
 
// Producer
@Injectable()
export class ProjectsService {
  constructor(@InjectQueue('email') private emailQueue: Queue) {}
 
  async create(dto: CreateProjectDTO) {
    const project = await this.repository.save(dto);
 
    // Queue email asynchronously
    await this.emailQueue.add('project-created', {
      projectId: project.id,
      managerId: dto.managerId,
    });
 
    return project;
  }
}
 
// Consumer
@Processor('email')
export class EmailProcessor {
  @Process('project-created')
  async handleProjectCreated(job: Job) {
    const { projectId, managerId } = job.data;
 
    await this.emailService.sendProjectCreatedEmail(managerId, projectId);
  }
}

Emails are sent in the background, not blocking API responses.

Health Checks

Monitor application health:

npm install @nestjs/terminus
import { TerminusModule } from '@nestjs/terminus';
 
@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private redis: RedisHealthIndicator,
  ) {}
 
  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.redis.pingCheck('redis'),
    ]);
  }
}

GET /health returns:

{
  "status": "ok",
  "info": {
    "database": { "status": "up" },
    "redis": { "status": "up" }
  }
}

Scaling Horizontally

NestJS apps are stateless and scale horizontally. We run 10+ instances behind a load balancer.

For shared state, use Redis:

// Shared cache
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';
 
CacheModule.register({
  store: redisStore,
  host: process.env.REDIS_HOST,
});
 
// Shared sessions
import * as session from 'express-session';
import * as connectRedis from 'connect-redis';
 
const RedisStore = connectRedis(session);
 
app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
  })
);

Performance Monitoring

Use APM tools:

npm install @sentry/node @sentry/tracing
import * as Sentry from '@sentry/node';
 
Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 0.1,
  integrations: [
    new Sentry.Integrations.Http({ tracing: true }),
  ],
});
 
app.use(Sentry.Handlers.requestHandler());
app.use(Sentry.Handlers.tracingHandler());
// ... routes ...
app.use(Sentry.Handlers.errorHandler());

Monitor request duration, error rates, and database queries in production.

Lessons Learned

  1. Structure matters: NestJS's module system keeps large codebases organized
  2. DI is essential: Makes testing and mocking trivial
  3. Decorators are powerful: Clean, declarative code
  4. Middleware is composable: Guards, interceptors, and pipes handle cross-cutting concerns
  5. Type safety: End-to-end TypeScript catches bugs early

Conclusion

NestJS transformed how we built the Catalyst PSA API. Its structure, dependency injection, and built-in features made building a scalable, maintainable API handling 50K+ req/min straightforward.

After 27 years of building APIs, NestJS is my go-to framework for enterprise Node.js applications. It provides the structure and patterns needed for large codebases without sacrificing flexibility.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.