Interceptors
Interceptors wrap the execution of your route handlers, giving you a single place to run code before and after controllers execute. Use them for logging, response mapping, caching, exception translation, or any cross-cutting behavior.
Key pipeline order: middleware ➜ guards ➜ interceptors ➜ pipes ➜ handler ➜ filters.
Interceptors share the same HttpExecutionContext as guards and can short-circuit requests by returning
their own Response objects.
Implementing an interceptor
Interceptors implement the HttpInterceptor interface. They receive the execution context and a
CallHandler that invokes the next interceptor (or ultimately the route handler).
import { Injectable } from '@nl-framework/core';import { CallHandler, HttpInterceptor, HttpExecutionContext } from '@nl-framework/http';@Injectable()export class LoggingInterceptor implements HttpInterceptor { async intercept(context: HttpExecutionContext, next: CallHandler) { const start = Date.now(); console.log('Before handler', context.getRoute().handlerName); const result = await next.handle(); console.log('After handler', Date.now() - start, 'ms'); return result; }}Binding interceptors
Use the @UseInterceptors() decorator to attach interceptors to controllers or individual handlers.
Order matters—the first decorator argument wraps everything that follows. Import the decorator from
@nl-framework/core (each transport re-exports it for backwards compatibility) so HTTP, GraphQL, and
microservice handlers share the exact same metadata.
import { Controller, Get } from '@nl-framework/http';import { UseInterceptors } from '@nl-framework/core';@Controller('/reports')@UseInterceptors(LoggingInterceptor)export class ReportsController { @Get() async list() { return { reports: [] }; } @Get('/daily') @UseInterceptors(RequestTimingInterceptor) daily() { return { status: 'ok' }; }}Functional interceptors
For lightweight cases you can pass an InterceptorFunction. Functional interceptors are great for caching
or analytics because they require no class registration.
import { InterceptorFunction } from '@nl-framework/http';export const CacheInterceptor: InterceptorFunction = async (context, next) => { const cacheKey = context.getRoute().controller?.name + ':' + context.getRequest().url; const cached = await context.getContainer().resolve(CacheStore).get(cacheKey); if (cached) { return new Response(JSON.stringify(cached), { headers: { 'content-type': 'application/json' }, }); } const result = await next.handle(); await context.getContainer().resolve(CacheStore).set(cacheKey, result); return result;};@Controller('/inventory')export class InventoryController { @Get() @UseInterceptors(CacheInterceptor) list() { return this.service.all(); }}Global interceptors
Call registerHttpInterceptor() during bootstrap to run an interceptor for every request. Global
interceptors execute outside controller scopes, so prefer functional interceptors or stateless classes.
import { registerHttpInterceptor } from '@nl-framework/http';registerHttpInterceptor(LoggingInterceptor);const app = await createHttpApplication(AppModule, { port: 3000 });await app.listen();GraphQL resolvers
The same @UseInterceptors() decorator now wraps GraphQL resolvers. Combine controller-scoped and global
registerGraphqlInterceptor() hooks to envelope responses, cache resolver results, or run observability spans
without duplicating logic in HTTP land.
import { ObjectType, Field } from '@nl-framework/graphql';import { Resolver, Query } from '@nl-framework/graphql';import { UseInterceptors } from '@nl-framework/core';import { GraphqlCallHandler, GraphqlInterceptor, registerGraphqlInterceptor,} from '@nl-framework/graphql';class EnvelopeInterceptor implements GraphqlInterceptor { async intercept(_context: unknown, next: GraphqlCallHandler) { const result = await next.handle(); return { data: result }; }}registerGraphqlInterceptor(EnvelopeInterceptor);@ObjectType()class Report { @Field() message!: string;}@Resolver(() => Report)@UseInterceptors(EnvelopeInterceptor)export class ReportResolver { @Query(() => Report) stats() { return { message: 'report' }; }}Transforming responses
Wrap handler results before they leave the server. This is helpful for enforcing envelope shapes, stripping null values, or appending metadata.
import { CallHandler, HttpExecutionContext, HttpInterceptor } from '@nl-framework/http';export class EnvelopeInterceptor implements HttpInterceptor { async intercept(_context: HttpExecutionContext, next: CallHandler) { const result = await next.handle(); return { data: result, at: new Date().toISOString() }; }}@Controller('/users')@UseInterceptors(EnvelopeInterceptor)export class UsersController { @Get() list() { return [{ id: 1, email: 'ada@example.com' }]; }}Translating exceptions
Because interceptors surround the handler, they can catch exceptions and transform them into framework-friendly responses. This is a good place to unify error payloads across HTTP and GraphQL transports.
import { HttpInterceptor, CallHandler, HttpExecutionContext } from '@nl-framework/http';import { ApplicationException } from '@nl-framework/core';export class ErrorsInterceptor implements HttpInterceptor { async intercept(_context: HttpExecutionContext, next: CallHandler) { try { return await next.handle(); } catch (error) { if (error instanceof ApplicationException) { return new Response( JSON.stringify({ code: error.code, message: error.message }), { status: 502, headers: { 'content-type': 'application/json' } }, ); } throw error; } }}Short-circuiting or timing out
Interceptors can completely bypass the handler. Return a cached Response, or race the handler against a
timeout promise and throw when it takes too long.
import { HttpInterceptor, HttpExecutionContext, CallHandler } from '@nl-framework/http';export class TimeoutInterceptor implements HttpInterceptor { constructor(private readonly timeoutMs = 5000) {} async intercept(_context: HttpExecutionContext, next: CallHandler) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { return await Promise.race([ next.handle(), new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('Request timeout')))), ]); } finally { clearTimeout(timeout); } }}