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/throttlerimport { 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 bullimport { 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/terminusimport { 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/tracingimport * 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
- Structure matters: NestJS's module system keeps large codebases organized
- DI is essential: Makes testing and mocking trivial
- Decorators are powerful: Clean, declarative code
- Middleware is composable: Guards, interceptors, and pipes handle cross-cutting concerns
- 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.

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.