Using NestJS Interceptors to Centralize Cross-Cutting Concerns
Controllers often repeat logging, timing and DTO mapping. NestJS interceptors wrap handler execution with RxJS to centralize these concerns without touching the handler code.
27 May 2026, 14:59 UTC

Problem: repetitive cross-cutting code in controllers
In many NestJS applications each controller manually measures request duration, logs entry/exit, and maps domain entities to DTOs. This duplication obscures the actual use case and creates drift when one endpoint forgets a step or formats a log differently.
Thesis: interceptors as a boundary layer
NestJS interceptors wrap the call to a handler with an RxJS Observable. They receive an ExecutionContext and a CallHandler, and must return an Observable of the response. Because they sit outside the handler, they can add logging, timing, or transformation without touching the business logic.
Worked example: a reusable TransformInterceptor
To centralize entity‑to‑DTO mapping we can create a higher‑order factory that returns an interceptor implementing NestInterceptor.
// src/shared/transform.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';
export function toDto<T, D>(mapper: (entity: T) => D) {
@Injectable()
class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<D> {
return next.handle().pipe(map((data: T) => mapper(data)));
}
}
return TransformInterceptor;
}
Apply it at the controller level so it does not affect internal routes such as health checks.
// src/users/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
import { toDto } from '../shared/transform.interceptor';
import { UserDto } from './user.dto';
@Controller('users')
@UseInterceptors(toDto((user) => new UserDto(user)))
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll(); // returns UserEntity[]
}
}
To verify, start the app (npm run start:dev) and call GET /users. The response should contain only the fields defined in UserDto. You can also add a logging interceptor that prints timestamps and confirm the log appears before and after the handler runs.
Trade‑offs and limits
- Global interceptors (registered via
APP_INTERCEPTOR) execute for every route, including health checks and internal endpoints. Scope them explicitly or add an early return based on route metadata to avoid unnecessary work. - Behavior depends on the RxJS version bundled with NestJS. Operator import paths (
import { map } from 'rxjs/operators'vsimport { map } from 'rxjs') change between major releases, so check the interceptor signature against the version you use. - Interceptors should stay stateless and pure. Injecting request‑scoped providers inside an interceptor creates a new instance per request, which can add overhead. Prefer singleton services and pass data via
ExecutionContextif needed. - Interceptors do not replace
Pipefor input validation orGuardfor authorization. Mixing responsibilities makes the pipeline harder to audit.
Actionable steps to adopt
- Generate a shared interceptor with the Nest CLI:
nest g interceptor shared/transform --flat(run from the project root; you need Node ≥ 14 and the Nest CLI installed). - Implement the factory pattern shown above, keeping the mapper function pure.
- Apply the interceptor with
@UseInterceptorson a controller or method first. Verify the output shape and that logs (if you added a logging interceptor) appear in the expected order. - If the pattern proves stable, move the registration to the module providers array using
APP_INTERCEPTORfor module‑wide scope, or keep it controller‑scoped to avoid affecting health checks. - Document the specific concern each interceptor owns (e.g., “maps UserEntity to UserDto”) and avoid adding unrelated logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.