Why NestJS Changes How You Think About Node.js
Express and Fastify are frameworks. NestJS is an application platform — it enforces architectural patterns through its module system and dependency injection container. This opinionation is its superpower: a new developer joining your team immediately understands where everything lives.
The Module System — NestJS's Core Abstraction
Everything in NestJS lives inside a module. Modules are TypeScript classes decorated with @Module():
// users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([User]), // Register User entity
AuthModule, // Import for JwtService
],
controllers: [UsersController], // Route handlers
providers: [UsersService], // Business logic
exports: [UsersService], // Make available to other modules
})
export class UsersModule {}Module Types
| Type | Purpose | Example |
|---|---|---|
| Feature Module | Encapsulates a domain feature | UsersModule, PostsModule |
| Shared Module | Reusable services across modules | DatabaseModule, LoggerModule |
| Core Module | App-wide singletons, imported once | CoreModule (in AppModule only) |
| Dynamic Module | Configurable with factory methods | ConfigModule.forRoot({...}) |
Dependency Injection — The Engine Under the Hood
NestJS's DI container manages object lifecycles:
// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { HashingService } from '../auth/hashing.service';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
// Injected from AuthModule's exports
private readonly hashingService: HashingService,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const { password, ...rest } = createUserDto;
const hashedPassword = await this.hashingService.hash(password);
const user = this.userRepository.create({
...rest,
password: hashedPassword
});
return this.userRepository.save(user);
}
async findOneByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
}
async findById(id: string): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
if (!user) throw new NotFoundException(`User #${id} not found`);
return user;
}
}Provider Scopes
// Default: Singleton — one instance for entire app lifecycle
@Injectable()
class AppService {}
// Request-scoped — new instance per HTTP request
@Injectable({ scope: Scope.REQUEST })
class RequestContextService {}
// Transient — new instance every injection
@Injectable({ scope: Scope.TRANSIENT })
class TransientService {}The Request Pipeline
Understanding NestJS's request processing order is critical:
Incoming Request
↓
Middleware ← Express-style, runs before routing
↓
Guards ← Authentication & authorization (return true/false)
↓
Interceptors ← Before handler: logging, transform request
↓
Pipes ← Validate & transform DTOs
↓
Handler ← Your @Get() / @Post() method
↓
Interceptors ← After handler: transform response, handle errors
↓
Exception Filters ← Catch and format thrown exceptions
↓
Response sentGuards — Authentication & Authorization
// auth/guards/jwt-auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { Request } from 'express';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private reflector: Reflector,
) {}
canActivate(context: ExecutionContext): boolean {
// Allow @Public() routes to bypass auth
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('No token provided');
try {
const payload = this.jwtService.verify(token);
request['user'] = payload; // Attach user to request
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}
private extractToken(request: Request): string | null {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : null;
}
}Interceptors — Cross-Cutting Concerns
// common/interceptors/logging.interceptor.ts
import {
Injectable, NestInterceptor, ExecutionContext,
CallHandler, Logger,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const { method, url, user } = request;
const start = Date.now();
return next.handle().pipe(
tap({
next: () => {
const duration = Date.now() - start;
this.logger.log(
`${method} ${url} — ${duration}ms — user:${user?.sub ?? 'anonymous'}`
);
},
error: (err) => {
const duration = Date.now() - start;
this.logger.error(
`${method} ${url} FAILED — ${duration}ms — ${err.message}`
);
},
}),
);
}
}Custom Decorators
// auth/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
// Usage in controller
@Get('profile')
@UseGuards(JwtAuthGuard)
getProfile(@CurrentUser() user: JwtPayload) {
return this.usersService.findById(user.sub);
}
// Or extract just one field
@Get('me')
@UseGuards(JwtAuthGuard)
getMe(@CurrentUser('email') email: string) {
return { email };
}Microservices with NestJS
NestJS has first-class support for microservices via its Transporter abstraction:
// Notification microservice — standalone NestJS app
// main.ts
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
NotificationModule,
{
transport: Transport.TCP,
options: { host: '0.0.0.0', port: 3001 },
},
);
await app.listen();
}
// notification/notification.controller.ts
@Controller()
export class NotificationController {
@MessagePattern('send_email')
async sendEmail(@Payload() payload: SendEmailDto) {
return this.notificationService.sendEmail(payload);
}
@EventPattern('user_registered')
async onUserRegistered(@Payload() data: UserRegisteredEvent) {
await this.notificationService.sendWelcomeEmail(data.email);
}
}// From the main API gateway — calling the notification microservice
@Injectable()
export class AuthService {
constructor(
@Inject('NOTIFICATION_SERVICE')
private readonly notificationClient: ClientProxy,
) {}
async register(dto: RegisterDto) {
const user = await this.usersService.create(dto);
// Fire and forget — emit event
this.notificationClient.emit('user_registered', {
email: user.email,
name: user.name,
});
return user;
}
}Production Application Structure
src/
├── app.module.ts ← Root module
├── main.ts ← Bootstrap with global middleware
├── common/
│ ├── decorators/ ← @Public(), @Roles()
│ ├── filters/ ← GlobalExceptionFilter
│ ├── guards/ ← JwtAuthGuard, RolesGuard
│ ├── interceptors/ ← LoggingInterceptor, TimeoutInterceptor
│ └── pipes/ ← ValidationPipe config
├── config/
│ └── configuration.ts ← Joi-validated env config
├── modules/
│ ├── auth/ ← JWT auth, refresh tokens
│ ├── users/ ← User CRUD
│ └── posts/ ← Blog posts
└── shared/
├── database/ ← TypeORM config module
└── email/ ← Email service moduleConclusion
NestJS's structured approach to backend development pays dividends at scale. The DI container eliminates manual wiring. The module system enforces domain boundaries. The request pipeline gives you clean extension points for cross-cutting concerns. For complex Node.js backends, NestJS is not just a framework — it's an architecture.


