nest_08_interceptors - Interceptors¶
Interceptors wrap around your route handlers They run before the handler (like middleware on steroids) and after the handler (which middleware can't do without hacks) Logging , timing , response transformation , caching - interceptors do the jobs that would otherwise pollute your controller methods
what's in here¶
- NestInterceptor interface and intercept method
- Request logging interceptor
- Response transformation and serialization
- Caching interceptor pattern
- Timeout and error handling interceptors
- Interceptor execution order with guards and pipes
the NestInterceptor interface¶
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { tap } from 'rxjs/operators'
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const now = Date.now()
const request = context.switchToHttp().getRequest()
console.log(`--> ${request.method} ${request.url}`)
return next
.handle()
.pipe(
tap(() => console.log(`<-- ${request.method} ${request.url} - ${Date.now() - now}ms`))
)
}
}
intercept() receives the ExecutionContext (same as guards) and a CallHandler next.handle() returns an RxJS Observable - call it to continue the request pipeline Code before next.handle() runs before the handler (pre-phase) Code in .pipe() runs after the handler (post-phase)
request timing interceptor¶
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { tap } from 'rxjs/operators'
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest()
const start = Date.now()
const correlationId = request.headers['x-correlation-id'] || crypto.randomUUID()
// add correlation ID to response headers
const response = context.switchToHttp().getResponse()
response.header('X-Correlation-Id', correlationId)
return next.handle().pipe(
tap({
next: () => {
const duration = Date.now() - start
console.log(`[${correlationId}] ${request.method} ${request.url} - ${duration}ms`)
// slow request alert - security relevant
if (duration > 5000) {
console.warn(`[SLOW] [${correlationId}] ${request.method} ${request.url} - ${duration}ms`)
// slow requests can indicate scanning or DoS attempts
}
},
error: (error) => {
console.error(`[ERROR] [${correlationId}] ${request.method} ${request.url} - ${error.message}`)
}
})
)
}
}
Every request gets a correlation ID - trace it through logs Slow requests log a warning - 5+ seconds on a normal endpoint is suspicious Error logging captures details without leaking stack traces to the client
response transformation interceptor¶
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
export interface ApiResponse<T> {
statusCode: number
message: string
data: T
timestamp: string
path: string
}
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
const request = context.switchToHttp().getRequest()
return next.handle().pipe(
map(data => ({
statusCode: context.switchToHttp().getResponse().statusCode,
message: 'success',
data,
timestamp: new Date().toISOString(),
path: request.url
}))
)
}
}
// applying globally
// main.ts: app.useGlobalInterceptors(new ResponseInterceptor())
Every response is consistently formatted Frontends don't need to guess the response shape - it's always { statusCode, message, data, timestamp, path } Makes API versioning easier because breaking changes to the data shape don't break the wrapper
caching interceptor¶
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'
import { Observable, of } from 'rxjs'
import { tap } from 'rxjs/operators'
@Injectable()
export class CacheInterceptor implements NestInterceptor {
private cache = new Map<string, { data: any; expiry: number }>()
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest()
const key = `${request.method}-${request.url}`
// check cache
const cached = this.cache.get(key)
if (cached && cached.expiry > Date.now()) {
console.log(`[CACHE HIT] ${key}`)
return of(cached.data)
}
return next.handle().pipe(
tap(data => {
console.log(`[CACHE MISS] ${key}`)
this.cache.set(key, {
data,
expiry: Date.now() + 30000 // 30 second TTL
})
})
)
}
}
// use on specific routes
@UseInterceptors(CacheInterceptor)
@Get('public-data')
getPublicData() {
return this.service.getExpensiveData()
}
In-memory cache for demonstration - use Redis for production Cache hits skip the handler entirely (and any downstream processing) Cache carefully: never cache authenticated responses or user-specific data
timeout interceptor¶
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, RequestTimeoutException } from '@nestjs/common'
import { Observable, throwError, TimeoutError } from 'rxjs'
import { catchError, timeout } from 'rxjs/operators'
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
constructor(private readonly timeoutMs: number = 10000) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(this.timeoutMs),
catchError(err => {
if (err instanceof TimeoutError) {
console.warn(`[TIMEOUT] Request exceeded ${this.timeoutMs}ms`)
return throwError(() => new RequestTimeoutException('request timed out'))
}
return throwError(() => err)
})
)
}
}
Prevents slow requests from hanging your server indefinitely Returns a proper HTTP 408 response instead of letting the connection timeout silently Aggressors abusing slow endpoints get a clean rejection instead of tying up connections
interceptors vs middleware¶
Middleware Interceptors
------- -----------
Runs on Express level Runs on Nest level
No access to DI Full DI access
Request/response only ExecutionContext + metadata
Can't read @Body Can access transformed body
Pre-handler only Pre AND post handler
Middleware for low-level concerns (logging raw requests , CORS , security headers) Interceptors for application-level concerns (response formatting , timing , caching , transformation)
applying interceptors¶
// method-level
@UseInterceptors(LoggingInterceptor)
@Get('users')
findAll() {}
// controller-level
@UseInterceptors(TimingInterceptor, ResponseInterceptor)
@Controller('api')
export class ApiController {}
// global (in main.ts)
app.useGlobalInterceptors(new LoggingInterceptor(), new ResponseInterceptor())
// global (through DI - preferred for testability)
// app.module.ts
@Module({
providers: [
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }
]
})
APP_INTERCEPTOR token registers the interceptor globally through DI Global interceptors through DI are testable - the DI container creates them with their dependencies Global interceptors in main.ts don't have DI access - avoid for anything that needs services
prerequisites¶
nest_07_guards - Guards & Authorization