NNODE LOOP LABruntime observatoryNNEON · Articles in 90 languages
CONNECTING
v24.18.0linux/x64
Middleware → policy → handler → outcome
12

NestJS request lifecycle

Send real requests through Nest middleware, guards, interceptors, pipes, a controller, service, and exception filter.

PROCESS IDcurrent server
UPTIMEsince startup
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Timeline

READY
0 ms
Events will appear hereRun the selected scenario
#TIMESOURCEEVENT
Waiting for an experiment…
CHAPTER 12
DEEP DIVE · FROM BASICS TO CODE

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.

FIRST, IN PLAIN LANGUAGE

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.

TECHNICAL FOUNDATION

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.

Why it mattersKnowing the pipeline puts validation, authorization, logging, response mapping, and error translation in the correct layer. A senior explanation includes the reason middleware does not replace a guard and an interceptor does not replace an exception filter.
WHERE THE WORK RUNS
01HTTP ADAPTERExpress/Fastify · middleware
02POLICYguards · interceptors
03ARGUMENTSpipes · controller
04OUTCOMEpost-interceptor · filter
01 · GLOSSARY

Terms used in this experiment

Understand the words first, then the execution order.

01

Middleware

An HTTP-level request, response, and next function that runs before route-aware Nest components.

02

ExecutionContext

A Nest wrapper exposing the current handler, controller class, and transport-specific context.

03

Guard

A route-aware policy deciding whether a request can enter a handler, commonly for authorization.

04

Interceptor

A wrapper with code before next.handle() and an Observable pipeline after it.

05

Pipe

Validation or transformation of controller method arguments before invocation.

06

Exception filter

A handler that converts an unhandled exception into a transport-specific response.

07

Controller

A transport boundary receiving prepared arguments and delegating to an application service or use case.

08

Platform adapter

The Nest integration with an HTTP engine, usually Express or Fastify.

02 · MECHANICS

What happens step by step

Each step maps to an observable runtime state.

  1. 01
    Middleware

    Global then module-bound middleware can normalize raw HTTP, attach request IDs, or terminate a response.

  2. 02
    Guards

    Global, controller, then route guards use ExecutionContext and metadata for authentication and authorization.

  3. 03
    Interceptors — inbound

    Global, controller, then route interceptors start timing, tracing, caching, or handler wrapping.

  4. 04
    Pipes

    Controller arguments are validated and transformed; a pipe error prevents controller execution.

  5. 05
    Controller and service

    The controller handles the transport boundary and delegates application logic to providers.

  6. 06
    Interceptors — outbound

    Route, controller, then global interceptors can map the result or handle Observable errors.

  7. 07
    Exception filter — error only

    An uncaught exception stops normal flow; filters resolve from route to controller to global.

03 · CONTEXT

Where the result needs context

These details explain why similar code can sometimes produce a different trace.

01

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.

02

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.

03

Filters are not a normal chain

The nearest filter that catches an exception completes processing; the same exception is not automatically passed onward.

04

Global registration affects DI

A manually constructed global guard lives outside module DI, while APP_GUARD or APP_INTERCEPTOR providers keep full injection.

05

Parameter pipes have extra ordering

After global, controller, and route pipes, parameter-specific pipes run from the last method parameter toward the first.

06

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.

01
Theory

First understand which parts of Node participate in execution.

02
Simplified code

Then remove instrumentation and focus on the central mechanism.

03
Runtime code

Finally match the model to the code that produces the live trace.

04 · Simplified code

A minimal model without instrumentation

src/demos.js · educational snippetJavaScript
@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);
  }
}
05 · Runtime code

The complete code executed by the scenario

This is not an alternative example: these are the functions and files used by the Run button.

ACTUAL SOURCE

The source is generated from the real server function. Scenarios using a child process or Worker include every participating file.

src/nest-lab.js
NestJS runtime358 lines
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(
      'http',
      'schedule',
      'Отправляем успешный запрос в настоящий ephemeral Nest HTTP server',
    );
    const success = await readJson(
      await fetch(`${origin}/nest-lifecycle/42`, {
        headers: { 'x-lab-access': 'allow' },
      }),
    );
    emit(
      'success-path',
      'result',
      `HTTP ${success.status}: ${success.body.trace.join(' → ')}`,
    );

    emit(
      'http',
      'schedule',
      'Отправляем id=not-a-number: Pipe прерывает normal flow',
    );
    const invalid = await readJson(
      await fetch(`${origin}/nest-lifecycle/not-a-number`, {
        headers: { 'x-lab-access': 'allow' },
      }),
    );
    emit(
      'error-path',
      'result',
      `HTTP ${invalid.status}: ${invalid.body.trace.join(' → ')}`,
    );

    emit(
      'http',
      'schedule',
      'Отправляем запрос без доступа: Guard не допускает Interceptor, Pipe и Controller',
    );
    const denied = await readJson(
      await fetch(`${origin}/nest-lifecycle/42`),
    );
    emit(
      'guard-path',
      'result',
      `HTTP ${denied.status}: ${denied.body.trace.join(' → ')}`,
    );

    emit(
      'comparison',
      'info',
      'Middleware видит raw HTTP раньше route context; Interceptor знает handler и оборачивает его до/после',
    );
  } finally {
    await application.close();
    emit('lifecycle', 'done', 'Ephemeral Nest HTTP server остановлен');
  }
}

The emit(...) calls become live-trace rows. await and Promise keep the HTTP stream open until the scenario completes.

06 · RECIPES

Practical patterns worth keeping nearby

Compare the goal, code, and caveats instead of memorizing syntax without a model.

01

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

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

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

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

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

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.
07 · DO NOT CONFUSE

Common misconceptions

The myth is on the left; the accurate model is on the right.

MYTH

An exception filter always runs after the controller.

ACTUALLY

It runs only for an uncaught exception; successful requests never enter it.

MYTH

Middleware and interceptors differ only in naming.

ACTUALLY

Middleware belongs to the HTTP adapter and next(); an interceptor is route-aware and wraps a CallHandler Observable.

MYTH

A guard validates DTOs.

ACTUALLY

A guard decides handler access; pipes validate and transform arguments.

MYTH

A controller should contain business logic and SQL.

ACTUALLY

A controller is a transport boundary; use cases and persistence belong to injected providers.

MYTH

Kafka microservices automatically make a pet project senior-level.

ACTUALLY

Without bounded contexts, ownership, delivery semantics, idempotency, and observability, a broker only adds distributed failures.

08 · SELF-CHECK

Explain it in your own words

If you can explain the answer without quoting documentation, your mental model is starting to take shape.

  1. Why is the exception filter absent from the successful trace?
  2. Which component should enforce @Roles authorization and why?
  3. How does an interceptor after next.handle differ from middleware after next?
  4. What happens to the controller when a pipe throws BadRequestException?
  5. Why can APP_GUARD be preferable to a manually constructed global guard?
  6. Which problems should exist before splitting a modular monolith into Kafka microservices?