An HTTP request crosses checkpoints with different responsibilities, not one list of identical callbacks. Middleware works at the HTTP adapter entrance, a guard decides whether the handler may run, an interceptor wraps the handler, a pipe prepares arguments, and the controller invokes a use case. An unhandled error leaves the normal route and goes directly to a matching exception filter.
Understanding: NestJS request lifecycle
You can read this chapter before running the experiment. Then return to the live trace and match each concept to a real event.
Nest builds a request pipeline over Express or Fastify. A general success order is middleware, guards, interceptors before, pipes, controller and service, interceptors after, and response. Interceptors return RxJS Observables, so the post-handler side unwinds in reverse order. An unhandled exception from a guard, pipe, controller, or service skips the rest of the normal path and enters the exceptions layer.
Terms used in this experiment
Understand the words first, then the execution order.
Middleware
An HTTP-level request, response, and next function that runs before route-aware Nest components.
ExecutionContext
A Nest wrapper exposing the current handler, controller class, and transport-specific context.
Guard
A route-aware policy deciding whether a request can enter a handler, commonly for authorization.
Interceptor
A wrapper with code before next.handle() and an Observable pipeline after it.
Pipe
Validation or transformation of controller method arguments before invocation.
Exception filter
A handler that converts an unhandled exception into a transport-specific response.
Controller
A transport boundary receiving prepared arguments and delegating to an application service or use case.
Platform adapter
The Nest integration with an HTTP engine, usually Express or Fastify.
What happens step by step
Each step maps to an observable runtime state.
- 01Middleware
Global then module-bound middleware can normalize raw HTTP, attach request IDs, or terminate a response.
- 02Guards
Global, controller, then route guards use ExecutionContext and metadata for authentication and authorization.
- 03Interceptors — inbound
Global, controller, then route interceptors start timing, tracing, caching, or handler wrapping.
- 04Pipes
Controller arguments are validated and transformed; a pipe error prevents controller execution.
- 05Controller and service
The controller handles the transport boundary and delegates application logic to providers.
- 06Interceptors — outbound
Route, controller, then global interceptors can map the result or handle Observable errors.
- 07Exception filter — error only
An uncaught exception stops normal flow; filters resolve from route to controller to global.
Where the result needs context
These details explain why similar code can sometimes produce a different trace.
Middleware does not know the final handler
It has raw HTTP objects but not the handler metadata exposed by ExecutionContext, so @Roles authorization belongs in a guard.
An interceptor works on both sides
It can time, map, cache, timeout, or catch the handler Observable; code after middleware next is not the same abstraction.
Filters are not a normal chain
The nearest filter that catches an exception completes processing; the same exception is not automatically passed onward.
Global registration affects DI
A manually constructed global guard lives outside module DI, while APP_GUARD or APP_INTERCEPTOR providers keep full injection.
Parameter pipes have extra ordering
After global, controller, and route pipes, parameter-specific pipes run from the last method parameter toward the first.
Microservices change the boundary, not every concept
DI, guards, pipes, interceptors, and filters also apply to message handlers, while HTTP middleware becomes transport context and message patterns.
First understand which parts of Node participate in execution.
Then remove instrumentation and focus on the central mechanism.
Finally match the model to the code that produces the live trace.
A minimal model without instrumentation
@Controller('users')
@UseGuards(AuthGuard)
@UseInterceptors(TimingInterceptor)
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get(':id')
getOne(@Param('id', ParseIntPipe) id: number) {
return this.users.getOne(id);
}
}The complete code executed by the scenario
This is not an alternative example: these are the functions and files used by the Run button.
The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.
import 'reflect-metadata';
import {
BadRequestException,
Bind,
Catch,
Controller,
Dependencies,
ForbiddenException,
Get,
Injectable,
Module,
Param,
Scope,
UseFilters,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ContextIdFactory,
NestFactory,
REQUEST,
} from '@nestjs/core';
import { map } from 'rxjs';
const DI_CONFIG = Symbol('DI_CONFIG');
const AUDIT_LOGGER = Symbol('AUDIT_LOGGER');
const USER_SERVICE_ALIAS = Symbol('USER_SERVICE_ALIAS');
let requestProbeSequence = 0;
class DatabaseConnection {
constructor(config) {
this.config = config;
this.id = `db:${config.database}`;
}
}
Dependencies(DI_CONFIG)(DatabaseConnection);
Injectable()(DatabaseConnection);
class UsersService {
constructor(database, auditLogger) {
this.database = database;
this.auditLogger = auditLogger;
}
describeResolution() {
this.auditLogger.log('UsersService resolved');
return {
databaseId: this.database.id,
environment: this.database.config.environment,
};
}
}
Dependencies(DatabaseConnection, AUDIT_LOGGER)(UsersService);
Injectable()(UsersService);
class RequestScopedProbe {
constructor() {
requestProbeSequence += 1;
this.instanceId = requestProbeSequence;
}
}
Injectable({ scope: Scope.REQUEST })(RequestScopedProbe);
class NestDiLabModule {}
Module({
providers: [
{
provide: DI_CONFIG,
useValue: Object.freeze({
environment: 'learning',
database: 'users',
}),
},
DatabaseConnection,
{
provide: AUDIT_LOGGER,
useFactory: (config) => ({
prefix: config.environment,
messages: [],
log(message) {
this.messages.push(`[${this.prefix}] ${message}`);
},
}),
inject: [DI_CONFIG],
},
UsersService,
{
provide: USER_SERVICE_ALIAS,
useExisting: UsersService,
},
RequestScopedProbe,
],
})(NestDiLabModule);
export async function nestDependencyInjection(emit) {
emit(
'module',
'start',
'Nest читает metadata модуля и строит граф provider tokens',
);
const application = await NestFactory.createApplicationContext(
NestDiLabModule,
{ logger: false },
);
try {
const users = application.get(UsersService);
const usersAgain = application.get(UsersService);
const alias = application.get(USER_SERVICE_ALIAS);
const description = users.describeResolution();
const logger = application.get(AUDIT_LOGGER);
emit(
'container',
'result',
`Constructor injection: ${description.databaseId}, environment=${description.environment}`,
);
emit(
'singleton',
'result',
`DEFAULT scope: повторный get вернул тот же instance = ${
users === usersAgain
}`,
);
emit(
'custom-provider',
'result',
`useExisting alias указывает на тот же UsersService = ${
users === alias
}`,
);
emit(
'factory',
'result',
`useFactory получил DI_CONFIG; audit=${logger.messages.at(-1)}`,
);
const requestA = ContextIdFactory.create();
const requestB = ContextIdFactory.create();
const probeA1 = await application.resolve(RequestScopedProbe, requestA);
const probeA2 = await application.resolve(RequestScopedProbe, requestA);
const probeB = await application.resolve(RequestScopedProbe, requestB);
emit(
'scope',
'result',
`REQUEST scope: context A ${probeA1.instanceId}/${probeA2.instanceId}, context B ${probeB.instanceId}`,
);
emit(
'scope',
'info',
`Внутри одного ContextId instance общий = ${
probeA1 === probeA2
}; между запросами новый = ${probeA1 !== probeB}`,
);
} finally {
await application.close();
emit('lifecycle', 'done', 'Application context закрыт');
}
}
function requestTrace(request) {
request.nestLifecycleTrace ??= [];
return request.nestLifecycleTrace;
}
class LifecycleService {
execute(id, trace) {
trace.push('service');
return { id, entity: `user-${id}` };
}
}
Injectable()(LifecycleService);
class TraceGuard {
canActivate(context) {
const request = context.switchToHttp().getRequest();
const trace = requestTrace(request);
if (request.headers['x-lab-access'] !== 'allow') {
trace.push('guard:deny');
throw new ForbiddenException('x-lab-access must equal allow');
}
trace.push('guard');
return true;
}
}
Injectable()(TraceGuard);
class TraceInterceptor {
intercept(context, next) {
const request = context.switchToHttp().getRequest();
const trace = requestTrace(request);
trace.push('interceptor:before');
return next.handle().pipe(
map((value) => {
trace.push('interceptor:after');
return { ...value, trace: [...trace] };
}),
);
}
}
Injectable()(TraceInterceptor);
class TraceIdPipe {
constructor(request) {
this.request = request;
}
transform(value) {
const trace = requestTrace(this.request);
trace.push('pipe');
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new BadRequestException('id must be an integer');
}
return parsed;
}
}
Dependencies(REQUEST)(TraceIdPipe);
Injectable({ scope: Scope.REQUEST })(TraceIdPipe);
class TraceExceptionFilter {
catch(exception, host) {
const context = host.switchToHttp();
const request = context.getRequest();
const response = context.getResponse();
const status =
typeof exception.getStatus === 'function'
? exception.getStatus()
: 500;
const trace = requestTrace(request);
trace.push(`exception-filter:${status}`);
response.status(status).json({
statusCode: status,
message: exception.message,
trace: [...trace],
});
}
}
Catch()(TraceExceptionFilter);
class LifecycleController {
constructor(request, service) {
this.request = request;
this.service = service;
}
getOne(id) {
const trace = requestTrace(this.request);
trace.push('controller');
return this.service.execute(id, trace);
}
}
Dependencies(REQUEST, LifecycleService)(LifecycleController);
Bind(Param('id', TraceIdPipe))(
LifecycleController.prototype,
'getOne',
Object.getOwnPropertyDescriptor(LifecycleController.prototype, 'getOne'),
);
Get(':id')(
LifecycleController.prototype,
'getOne',
Object.getOwnPropertyDescriptor(LifecycleController.prototype, 'getOne'),
);
UseGuards(TraceGuard)(LifecycleController);
UseInterceptors(TraceInterceptor)(LifecycleController);
UseFilters(TraceExceptionFilter)(LifecycleController);
Controller('nest-lifecycle')(LifecycleController);
class NestLifecycleLabModule {}
Module({
controllers: [LifecycleController],
providers: [
LifecycleService,
TraceGuard,
TraceInterceptor,
TraceIdPipe,
TraceExceptionFilter,
],
})(NestLifecycleLabModule);
async function readJson(response) {
const body = await response.json();
return { status: response.status, body };
}
export async function nestRequestLifecycle(emit) {
const application = await NestFactory.create(NestLifecycleLabModule, {
logger: false,
});
application.use((request, _response, next) => {
requestTrace(request).push('middleware');
next();
});
await application.listen(0, '127.0.0.1');
const address = application.getHttpServer().address();
const origin = `http://127.0.0.1:${address.port}`;
try {
emit(
'nest-server',
'ready',
`Временный Nest HTTP server готов: ${origin}; далее выполняются 3 независимых запроса`,
);
emit(
'request-1',
'schedule',
'Запрос 1/3 · GET /nest-lifecycle/42 · header x-lab-access: allow · ожидается HTTP 200',
);
const success = await readJson(
await fetch(`${origin}/nest-lifecycle/42`, {
headers: { 'x-lab-access': 'allow' },
}),
);
emit(
'request-1',
'result',
`Запрос 1/3 завершён · HTTP ${success.status} · ${success.body.trace.join(' → ')}`,
);
emit(
'request-2',
'schedule',
'Запрос 2/3 · GET /nest-lifecycle/not-a-number · header x-lab-access: allow · ожидается HTTP 400 (Pipe отклоняет id)',
);
const invalid = await readJson(
await fetch(`${origin}/nest-lifecycle/not-a-number`, {
headers: { 'x-lab-access': 'allow' },
}),
);
emit(
'request-2',
'result',
`Запрос 2/3 завершён · HTTP ${invalid.status} · ${invalid.body.trace.join(' → ')}`,
);
emit(
'request-3',
'schedule',
'Запрос 3/3 · GET /nest-lifecycle/42 · header x-lab-access отсутствует · ожидается HTTP 403 (Guard запрещает доступ)',
);
const denied = await readJson(
await fetch(`${origin}/nest-lifecycle/42`),
);
emit(
'request-3',
'result',
`Запрос 3/3 завершён · HTTP ${denied.status} · ${denied.body.trace.join(' → ')}`,
);
emit(
'comparison',
'info',
'Middleware видит raw HTTP раньше route context; Interceptor знает handler и оборачивает его до/после',
);
} finally {
await application.close();
emit(
'teardown',
'success',
'Завершение сценария: временный Nest-сервер закрыт штатно (exitCode 0)',
);
}
}
The application instruments its own live trace: rows and timestamps are recorded by real emit(...) calls, while the scenario supplies source and lane labels. This is not a V8/libuv profiler or a direct view of their internal queues. await and Promise keep the HTTP stream open until the scenario completes.
Practical patterns worth keeping nearby
Compare the goal, code, and caveats instead of memorizing syntax without a model.
01 · Middleware and interceptor
Create a request ID at the HTTP boundary and measure one selected route handler.
// middleware
use(req, res, next) {
req.id = req.headers['x-request-id'] ?? randomUUID();
next();
}
// interceptor
intercept(context: ExecutionContext, next: CallHandler) {
const startedAt = performance.now();
return next.handle().pipe(
finalize(() => recordLatency(context.getHandler(), startedAt)),
);
}- Middleware fits raw HTTP normalization.
- The interceptor knows the class and handler and observes Observable completion.
02 · Guard with route metadata
Authorization policy reads @Roles from the selected handler.
@Injectable()
class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext) {
const roles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
return roles?.some(role => currentUser(context).roles.includes(role))
?? true;
}
}- Authentication can attach the current user earlier.
- The guard answers access policy; it does not transform the DTO.
03 · ValidationPipe at the boundary
Keep invalid and unknown fields out of the application service.
app.useGlobalPipes(new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}));
@Post()
create(@Body() command: CreateUserDto) {
return this.createUser.execute(command);
}- DTO validation does not replace business invariants.
- transform can convert primitive route and query values.
04 · Exception filter translation
Define one transport representation for a domain error.
@Catch(UserAlreadyExistsError)
class UserConflictFilter implements ExceptionFilter {
catch(error: UserAlreadyExistsError, host: ArgumentsHost) {
host.switchToHttp().getResponse().status(409).json({
code: 'USER_ALREADY_EXISTS',
message: error.message,
});
}
}- The domain error does not need to import HttpException.
- Do not hide unexpected exceptions without logging and a correlation ID.
05 · Pet project as a modular monolith
Establish boundaries that could later become separate services.
AppModule
├── IdentityModule // users, sessions, access
├── CatalogModule // products, prices
├── OrdersModule // checkout, order state
├── NotificationsModule // email/push adapters
└── ObservabilityModule // logs, metrics, tracing
// Each feature exports use cases while owning
// its repository ports and database tables.- One deployment is easier to debug while learning.
- Module boundaries remain useful without a network boundary.
- The database section can add repository adapters and transactions.
06 · Kafka follows a requirement
Separate a local database transaction from asynchronous event delivery.
// Inside the Orders DB transaction:
await orders.save(order, transaction);
await outbox.append({
id: eventId,
topic: 'order.created.v1',
key: order.id,
payload: { orderId: order.id, userId: order.userId },
}, transaction);
// A relay publishes the outbox to Kafka.
// The consumer stores processed eventId for idempotency.- A direct DB commit plus Kafka publish creates a dual-write problem.
- Event schema versioning and ownership matter more than a Nest decorator.
- Kafka retries require idempotent consumers.
How a learning mistake becomes an incident
A realistic service: the original code, observable failure, corrected implementation, and why the correction works.
One Nest interceptor handles authentication, validation, and errors
A team centralizes every cross-cutting concern in one interceptor. It reads tokens, mutates input, maps exceptions, records timing, and invokes the controller.
Responsibilities run at different lifecycle stages. The oversized interceptor has ambiguous ordering, is hard to reuse, and may execute work before access is rejected.
@Injectable()
export class EverythingInterceptor
implements NestInterceptor {
async intercept(context, next) {
validateToken(context);
context.switchToHttp()
.getRequest().body = validateBody(context);
try {
return await lastValueFrom(next.handle());
} catch (error) {
throw mapToHttpError(error);
}
}
}Authorization, transformation, handler wrapping, and exception mapping have different contracts. Combining them hides the request order and makes unit tests depend on the entire pipeline.
@UseFilters(DomainExceptionFilter)
@UseInterceptors(LoggingInterceptor)
@UseGuards(JwtAuthGuard)
@Controller('orders')
export class OrdersController {
@Post()
create(
@Body(new ValidationPipe({
transform: true,
whitelist: true,
}))
input: CreateOrderDto,
) {
return this.orders.create(input);
}
}
// Middleware: raw HTTP context/correlation ID
// Guard: may this request continue?
// Pipe: validate and transform arguments
// Interceptor: wrap handler before and after
// Filter: map uncaught exceptionsEach concern uses the Nest extension point designed for its timing and contract. The order is visible, each component can be tested separately, and rejected requests never reach the controller.
What the unfamiliar calls from both code samples actually do.
NestInterceptor / next.handle()- An interceptor runs around a handler; next.handle starts the next pipeline stage and returns an Observable.
lastValueFrom(observable)- Converts an RxJS Observable into a Promise of its last value; the bad example uses it to flatten the natural interceptor pipeline.
@UseGuards(JwtAuthGuard)- A guard decides whether the request may continue. On denial, the controller and its parameter pipes do not run.
ValidationPipe- Validates an input DTO and, with transform enabled, converts accepted values to expected types.
@UseInterceptors(...)- Attaches a wrapper for logging, timing, caching, or response transformation to the selected handler.
@UseFilters(...)- Attaches an exception filter that maps an otherwise unhandled exception to a controlled HTTP response.
Common misconceptions
The myth is on the left; the accurate model is on the right.
An exception filter always runs after the controller.
It runs only for an uncaught exception; successful requests never enter it.
Middleware and interceptors differ only in naming.
Middleware belongs to the HTTP adapter and next(); an interceptor is route-aware and wraps a CallHandler Observable.
A guard validates DTOs.
A guard decides handler access; pipes validate and transform arguments.
A controller should contain business logic and SQL.
A controller is a transport boundary; use cases and persistence belong to injected providers.
Kafka microservices automatically make a pet project senior-level.
Without bounded contexts, ownership, delivery semantics, idempotency, and observability, a broker only adds distributed failures.
Explain it in your own words
If you can explain the answer without quoting documentation, your mental model is starting to take shape.
- Why is the exception filter absent from the successful trace?
- Which component should enforce @Roles authorization and why?
- How does an interceptor after next.handle differ from middleware after next?
- What happens to the controller when a pipe throws BadRequestException?
- Why can APP_GUARD be preferable to a manually constructed global guard?
- Which problems should exist before splitting a modular monolith into Kafka microservices?