Building Scalable APIs with NestJS
How I structure NestJS applications for scale — modules, dependency injection, DTOs and validation, guards, interceptors, the database layer, testing, and when microservices make sense.
- NestJS
- TypeScript
- API Design
- Backend
- Architecture

I've built plenty of Express apps, and they all eventually hit the same wall: as the team and the codebase grow, the lack of structure becomes the bottleneck. Routes scattered across files, business logic tangled into controllers, no consistent way to share services. NestJS is my default now precisely because it solves that — it imposes an opinionated, modular architecture without locking you out of the underlying platform (it runs on Express or Fastify).
This post is how I actually structure NestJS services for production, and the trade-offs behind each decision. It's not an exhaustive API reference — it's the patterns that keep an API maintainable as it scales past one developer and a handful of endpoints.
The architecture: modules, controllers, providers
NestJS organizes code around three concepts, and getting these right is most of the battle.
- Modules group related features. Every Nest app has at least a root module, and you compose the app from feature modules (
UsersModule,OrdersModule, etc.). - Controllers handle incoming requests and return responses. They should be thin — parse the request, call a service, return the result. No business logic.
- Providers are anything that can be injected: services, repositories, factories. This is where your business logic lives.
A typical feature module:
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // make it available to other modules
})
export class UsersModule {}
The exports array is the key to clean boundaries: a provider is private to its module unless explicitly exported. This forces you to think about which parts of a module are public API and which are internal.
Why structure matters
It's tempting to dismiss this as ceremony. It isn't. The modular structure pays off in three concrete ways:
- Dependency injection is testable by design. Because services receive their dependencies through the constructor rather than importing them directly, you can swap real implementations for mocks in tests without touching the code under test.
- Boundaries are enforced. A
OrdersModulecan only use whatUsersModuleexports. You can't accidentally reach into another feature's internals. - It scales to teams. Different people own different modules. Merge conflicts drop because the surface area each person touches is contained.
The DI container is the heart of it. You declare a dependency and Nest resolves it:
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
constructor(private readonly usersRepository: UsersRepository) {}
findById(id: string) {
return this.usersRepository.findOne(id);
}
}
You never call new UsersService() yourself. Nest constructs the graph, injecting UsersRepository (and its dependencies, recursively) for you.
DTOs and validation
Never trust incoming data. In NestJS the clean way to validate request bodies is DTOs (Data Transfer Objects) combined with a validation pipe. I use class-validator and class-transformer decorators on a DTO class:
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsOptional()
@IsString()
displayName?: string;
}
Then enable the global ValidationPipe. Two options I always turn on:
import { ValidationPipe } from '@nestjs/common';
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip properties not in the DTO
forbidNonWhitelisted: true, // throw if extra properties are sent
transform: true, // auto-convert payloads to DTO instances
}),
);
whitelist is a quiet security win — it drops fields the client shouldn't be sending. Now the controller method receives a validated, typed object and you never write manual validation again.
If your team prefers schema-first validation, nestjs-zod lets you define schemas with Zod and use a ZodValidationPipe instead. Either approach works; the principle is the same — validate at the boundary, before the data touches your business logic.
Controllers stay thin
With validation handled by the pipe and logic in services, the controller is almost boring — which is exactly what you want:
import { Controller, Post, Body, Get, Param } from '@nestjs/common';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findById(id);
}
}
No try/catch noise, no validation, no data access. If you find yourself writing an if statement in a controller, it probably belongs in the service.
Guards, interceptors, pipes, and filters
NestJS gives you four request-lifecycle primitives, and knowing which one to reach for keeps cross-cutting concerns out of your business code.
- Pipes transform and validate input (the
ValidationPipeabove). - Guards decide whether a request proceeds — authentication and authorization.
- Interceptors wrap the request/response — logging, caching, response shaping, timing.
- Exception filters catch thrown errors and turn them into HTTP responses.
A simple auth guard:
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly tokenService: TokenService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const token = request.headers['authorization']?.split(' ')[1];
if (!token || !this.tokenService.verify(token)) {
throw new UnauthorizedException();
}
request.user = this.tokenService.decode(token);
return true;
}
}
Apply it per-route with @UseGuards(AuthGuard), or globally. An interceptor for logging request duration:
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now();
const { method, url } = context.switchToHttp().getRequest();
return next.handle().pipe(
tap(() => console.log(`${method} ${url} - ${Date.now() - start}ms`)),
);
}
}
The win here is separation: authentication lives in a guard, logging in an interceptor, error formatting in a filter. None of it pollutes your services.
Configuration
Hardcoded config is a production incident waiting to happen. Use @nestjs/config to load environment variables, and validate them at startup so the app refuses to boot with bad config rather than failing on the first request.
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: configValidationSchema, // a Joi or Zod schema
}),
],
})
export class AppModule {}
Inject ConfigService where you need a value. The validation schema is the part people skip — don't. Catching a missing DATABASE_URL at boot is far cheaper than discovering it under load.
The database layer
Nest is ORM-agnostic. The two I reach for are TypeORM and Prisma.
- TypeORM integrates tightly via
@nestjs/typeorm, uses the repository pattern, and feels native to the decorator-heavy Nest style. - Prisma gives you a generated, fully type-safe client and an excellent migration workflow. I tend to prefer Prisma on greenfield projects for the type safety.
A Prisma service wrapped as an injectable provider:
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
}
Then inject it into a repository or service. Whichever ORM you choose, keep data access behind a repository/service layer — controllers should never know whether you're using Prisma, TypeORM, or raw SQL. That abstraction is what lets you swap or optimize the data layer later without rippling changes through the codebase.
Testing
The DI architecture makes testing genuinely pleasant. Nest's Test.createTestingModule builds an isolated module where you provide mock implementations:
import { Test } from '@nestjs/testing';
describe('UsersService', () => {
let service: UsersService;
const mockRepo = { findOne: jest.fn() };
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
UsersService,
{ provide: UsersRepository, useValue: mockRepo },
],
}).compile();
service = moduleRef.get(UsersService);
});
it('returns a user by id', async () => {
mockRepo.findOne.mockResolvedValue({ id: '1', email: 'a@b.com' });
await expect(service.findById('1')).resolves.toEqual({ id: '1', email: 'a@b.com' });
});
});
Unit-test services with mocked dependencies. For controllers and the full request pipeline (guards, pipes, filters all firing), write e2e tests with supertest against a real-ish app instance. I aim for thorough unit coverage on services and a thinner layer of e2e tests covering the critical request flows.
Modular and layered design at scale
As an app grows, I keep a consistent layering inside each feature module: controller → service → repository. The controller handles HTTP, the service holds business logic, the repository handles persistence. Cross-cutting concerns go in guards/interceptors/filters.
When modules need to share, they do it through exported providers, not by reaching into each other. If two modules start depending heavily on each other's internals, that's a signal the boundary is wrong — either merge them or extract the shared piece into its own module.
The microservices option
NestJS has first-class microservices support, and it's genuinely good — transport layers for TCP, Redis, NATS, RabbitMQ, gRPC, and Kafka, with the same DI and module model you already use.
But I want to be blunt: most APIs don't need microservices. They add network hops, distributed-tracing complexity, eventual-consistency headaches, and a deployment story that's an order of magnitude harder. Start with a well-structured modular monolith. Because Nest forces clean module boundaries, that monolith is already organized along the seams you'd eventually split on.
Reach for microservices when you have a concrete reason — independent scaling of one component, separate deployment cadences across teams, or true fault isolation. When you do, Nest makes the transition smoother because your modules are already decoupled:
import { MessagePattern } from '@nestjs/microservices';
@Controller()
export class OrdersMessageController {
@MessagePattern({ cmd: 'create_order' })
create(data: CreateOrderDto) {
return this.ordersService.create(data);
}
}
The same service, exposed over a message transport instead of HTTP.
Final thoughts
NestJS's value isn't any single feature — it's the discipline it enforces. Thin controllers, business logic in injectable services, validation at the boundary with DTOs, cross-cutting concerns in guards and interceptors, and clear module boundaries. Get those habits right and the framework scales cleanly from a weekend prototype to a service a whole team works on.
My advice: resist the urge to over-engineer early. Build a clean modular monolith, lean on dependency injection for testability, validate everything coming in, and only split into microservices when you have a problem that actually requires it. The structure you put in place on day one is what makes that future decision easy instead of painful.
