NestJS

NestJS is the answer to “I want Express, but I want my team to stop arguing about how to structure everything.” It forces an Angular-style module system on top of Node.js, with built-in dependency injection, TypeScript first-class support, and a decorator-based API that makes the structure predictable across large codebases.

Under the hood it runs on Express by default, or Fastify if you need the extra throughput. The abstraction means you mostly never touch the underlying HTTP layer directly.


🟢 Junior

How the pieces fit together

Every NestJS application organizes code into modules, controllers, and providers. The mental model:

AppModule
  ├── UsersModule
  │     ├── UsersController   (handles HTTP, routes)
  │     ├── UsersService      (business logic)
  │     └── UsersRepository   (data access)
  └── AuthModule
        ├── AuthController
        └── AuthService

Modules declare what they own and what they share. Controllers handle incoming requests and delegate to services. Services contain business logic and receive their dependencies via constructor injection — they don’t instantiate anything themselves.

Modules

A module is a class decorated with @Module. It declares which controllers and providers it owns, and what it exports for other modules to use.

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

exports controls what crosses module boundaries. If AuthModule needs to call something in UsersModule, it only gets what UsersModule explicitly exports. This forces you to think about public APIs between feature modules rather than importing things freely.

Controllers

Controllers map routes to handlers. They should be thin — extract the parameters, call a service method, return the result.

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(+id);
  }

  @Post()
  @HttpCode(201)
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}

NestJS automatically serializes whatever your handler returns to JSON. No res.json() needed. If you need direct control over the response object, inject @Res() — but doing so opts you out of automatic serialization and interceptors.

Providers and Services

Anything decorated with @Injectable() is a provider — it can be injected into controllers or other providers via the constructor. Services are the most common type.

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User) private usersRepo: Repository<User>,
  ) {}

  findOne(id: number) {
    return this.usersRepo.findOneByOrFail({ id });
  }
}

The DI container wires these together at startup. You never call new UsersService() — Nest instantiates and manages the lifecycle.


🟡 Medior

The request pipeline

Understanding the order that Nest processes a request is essential for debugging. Everything runs in this sequence:

Request
  → Middleware        (app.use / NestMiddleware)
  → Guards            (canActivate — auth/authz)
  → Interceptors      (before — transform request)
  → Pipes             (validate / transform DTO)
  → Controller handler
  → Interceptors      (after — transform response)
  → Exception Filters (catch errors from any stage)
  → Response

Guards run before anything touches the handler. If a guard returns false, the request is rejected immediately — no pipes, no handler. Exception filters catch errors thrown anywhere in the chain.

Guards

Guards are for authentication and authorization. The most common pattern is extending Passport’s AuthGuard:

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req) {
  return req.user;
}

Apply @UseGuards to individual routes, entire controllers, or globally via app.useGlobalGuards(). Global guards are useful for requiring auth everywhere except specific public endpoints.

Pipes — Validation and transformation

Validation pipes are where NestJS earns its keep. Set it up globally once and every incoming request body gets validated automatically against your DTO class:

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: true,
  transform: true,
}));

export class CreateUserDto {
  @IsEmail()
  email: string;

  @MinLength(8)
  password: string;
}

whitelist: true strips properties not declared in the DTO. forbidNonWhitelisted: true rejects the request instead of silently stripping. transform: true coerces types — a route param comes in as a string "42", gets transformed to the number 42. Always pair all three.

Interceptors

Interceptors wrap handler execution. They run before the handler, get the result, and can transform it on the way out. The most common use is wrapping all responses in a consistent envelope:

@Injectable()
export class TransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(map(data => ({ data, success: true })));
  }
}

Note that interceptors use RxJS Observables. This trips up developers new to Nest. For most use cases you just chain .pipe() operators — you don’t need to understand RxJS deeply.

Exception Filters

Without a filter, NestJS returns a default error shape. With one, you control exactly what error responses look like:

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    ctx.getResponse().status(exception.getStatus()).json({
      statusCode: exception.getStatus(),
      message: exception.message,
      timestamp: new Date().toISOString(),
    });
  }
}

Register it globally with app.useGlobalFilters(new HttpExceptionFilter()).

Authentication with Passport

Passport strategies integrate cleanly via @nestjs/passport. The JWT strategy is the standard starting point:

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET,
    });
  }
  validate(payload: any) {
    return { userId: payload.sub, email: payload.email };
  }
}

Whatever validate() returns gets attached to req.user. The @Request() req in your controller handler then has access to it after the JwtAuthGuard runs.

Testing

NestJS’s test module lets you swap out any provider with a mock, giving you the DI wiring without the full application:

const module = await Test.createTestingModule({
  providers: [
    UsersService,
    { provide: getRepositoryToken(User), useValue: mockRepository },
  ],
}).compile();

const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = await moduleRef.createNestApplication().init();
await request(app.getHttpServer()).get('/users/1').expect(200);

Unit tests mock the repository. E2e tests spin up the full application. Both use the same TestingModule pattern.


🔴 Senior

Custom providers and scopes

When you need to inject a value that’s computed at startup, or inject something from a third-party library that has no @Injectable() decorator, use a factory provider:

{
  provide: 'MAILER',
  useFactory: (config: ConfigService) => new MailerService(config.get('SMTP')),
  inject: [ConfigService],
}

constructor(@Inject('MAILER') private mailer: MailerService) {}

Provider scopes control lifecycle:

DEFAULT — singleton, shared across the entire application. This is the right choice for almost everything.

REQUEST — a new instance per HTTP request. Useful if you need to attach request-scoped data (like the current user) to the provider. The trade-off: any provider that depends on a REQUEST-scoped provider also becomes request-scoped, which can cascade unexpectedly and adds real overhead.

TRANSIENT — a new instance every time it’s injected. Rare — mostly useful for stateful helpers that shouldn’t be shared.

Dynamic modules

Dynamic modules let you configure a module at import time, which is how libraries like ConfigModule and TypeOrmModule work:

@Module({})
export class DatabaseModule {
  static forRoot(options: DbOptions): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [{ provide: DB_OPTIONS, useValue: options }],
      exports: [DB_OPTIONS],
      global: true,
    };
  }
}

global: true makes all exported providers available application-wide without importing the module in every feature module. Use this sparingly — it’s a global, and globals make it harder to reason about dependencies.

Microservices

NestJS supports transports beyond HTTP. Switching to TCP, Kafka, RabbitMQ, or Redis pub/sub is mostly a config change:

const app = await NestFactory.createMicroservice(AppModule, {
  transport: Transport.TCP,
  options: { port: 3001 },
});

@MessagePattern({ cmd: 'get_user' })
getUser(@Payload() data: { id: number }) {
  return this.usersService.findOne(data.id);
}

The same guards, interceptors, and pipes work in microservice context — ExecutionContext lets them inspect both HTTP and message contexts uniformly.

Senior Gotchas

Circular dependencies between modules require forwardRef(() => ModuleX) on both sides. When you see one, it’s usually a sign the two modules are too tightly coupled and should be reorganized, with shared logic extracted into a third module.

Interceptors run in reverse order on the way out. If you apply interceptors A, B, C to a route, they wrap the request in order A → B → C, then unwrap the response C → B → A. The outer interceptor’s after-handler runs last.

whitelist: true without forbidNonWhitelisted: true silently strips unexpected properties. This means a client sending extra data gets no feedback — the data just disappears. Always add both.

@Global() on feature modules creates hidden coupling. Every module in the app can now access your module’s exports without declaring the import. This makes it hard to understand what depends on what. Prefer explicit imports everywhere.

ExecutionContext is how you write handlers that work across HTTP, WebSocket, and microservice contexts. A guard that reads context.switchToHttp().getRequest() will throw in a microservice context. Use context.getType() to guard against it.