As a frontend developer who primarily works with Angular, picking up NestJS felt like coming home. The framework borrows heavily from Angular's architecture: modules, dependency injection, decorators, and a clear separation of concerns. But NestJS applies these patterns to the server side, and after building several production APIs with it, I can say it brings a level of structure to Node.js that I did not know I was missing.
Modules: Organizing Your API
NestJS modules are the foundation of application organization. Each module encapsulates a set of related controllers, services, and other providers. In a chat application API, you might have a module for users, one for conversations, and one for messages:
// conversations/conversations.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Conversation, Participant])],
controllers: [ConversationsController],
providers: [ConversationsService, ConversationsGateway],
exports: [ConversationsService]
})
export class ConversationsModule {}
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useClass: DatabaseConfig
}),
UsersModule,
ConversationsModule,
MessagesModule,
AuthModule
]
})
export class AppModule {}
This structure makes it immediately clear where to find things. When a new developer joins the team and needs to fix a bug in conversation creation, they know exactly which module, controller, and service to look at. Compare this to a typical Express project where route handlers, middleware, and business logic are often scattered across the codebase with no enforced structure.
Dependency Injection: Why It Matters
Dependency injection is the pattern that makes NestJS testable and maintainable. Instead of importing and instantiating dependencies directly, you declare what you need in the constructor and NestJS provides it:
@Injectable()
export class ConversationsService {
constructor(
@InjectRepository(Conversation)
private conversationRepo: Repository<Conversation>,
private usersService: UsersService,
private eventEmitter: EventEmitter2
) {}
async create(dto: CreateConversationDto, creatorId: string) {
const creator = await this.usersService.findById(creatorId);
if (!creator) throw new NotFoundException('User not found');
const conversation = this.conversationRepo.create({
name: dto.name,
participants: [{ user: creator, role: 'admin' }]
});
const saved = await this.conversationRepo.save(conversation);
this.eventEmitter.emit('conversation.created', saved);
return saved;
}
}
When testing, you can easily swap out the real repository with a mock. When refactoring, you can change the implementation of UsersService without touching any of its consumers. This is the same benefit Angular developers get from DI on the frontend, and it is just as valuable on the backend.
Guards and Interceptors
Guards handle authorization. They run before the route handler and decide whether the request should proceed. Interceptors wrap around the route handler and can transform the request, response, or both. Here is how I typically implement JWT authentication:
// auth/guards/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
handleRequest(err: any, user: any) {
if (err || !user) {
throw new UnauthorizedException('Invalid or expired token');
}
return user;
}
}
// Apply globally or per-controller
@Controller('conversations')
@UseGuards(JwtAuthGuard)
export class ConversationsController {
@Post()
@UseGuards(RolesGuard)
@Roles('admin', 'agent')
create(@Body() dto: CreateConversationDto, @CurrentUser() user: User) {
return this.conversationsService.create(dto, user.id);
}
}
Interceptors are useful for cross-cutting concerns like logging, response transformation, and caching. One interceptor I use in every project wraps responses in a consistent format:
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map(data => ({
success: true,
data,
timestamp: new Date().toISOString()
}))
);
}
}
Validation Pipes
Input validation is one of those things that is easy to get wrong. NestJS pairs beautifully with the class-validator library. You define your DTOs with validation decorators, enable the global validation pipe, and NestJS automatically validates and transforms incoming data:
// dto/create-conversation.dto.ts
export class CreateConversationDto {
@IsString()
@MinLength(1)
@MaxLength(100)
name: string;
@IsArray()
@IsUUID('4', { each: true })
@ArrayMinSize(1)
participantIds: string[];
@IsOptional()
@IsString()
@MaxLength(500)
description?: string;
}
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip unknown properties
forbidNonWhitelisted: true, // Throw on unknown properties
transform: true // Auto-transform payloads to DTO instances
}));
The whitelist option is critical for security. It strips any properties from the request body that are not defined in the DTO. Without it, a malicious client could send additional fields that might get saved to the database. The forbidNonWhitelisted option goes a step further and rejects the request entirely if unknown properties are present.
Error Handling
NestJS has a built-in exception filter that catches unhandled exceptions and returns appropriate HTTP responses. But for production APIs, I always create a custom exception filter that logs errors properly and returns consistent error responses:
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
constructor(private readonly 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.message
: 'Internal server error';
this.logger.error(`${request.method} ${request.url}`, {
status,
message,
stack: exception instanceof Error ? exception.stack : undefined
});
response.status(status).json({
success: false,
error: { status, message, timestamp: new Date().toISOString() }
});
}
}
NestJS has become my go-to for any backend work. The learning curve is minimal if you already know Angular, and the structure it provides pays dividends as the codebase grows. If you are a frontend developer who occasionally needs to build APIs, give it a serious look. The patterns will feel immediately familiar, and you will ship more reliable APIs because of the built-in guardrails.