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 runtime368 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(
      '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.

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 · PRODUCTION CASES

How a learning mistake becomes an incident

A realistic service: the original code, observable failure, corrected implementation, and why the correction works.

CASE 01

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.

INCIDENT CONTEXT

Responsibilities run at different lifecycle stages. The oversized interceptor has ambiguous ordering, is hard to reuse, and may execute work before access is rejected.

BEFOREPROBLEMATIC IMPLEMENTATION
@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.

AFTERCORRECTED IMPLEMENTATION
@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 exceptions

Each 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.

FUNCTIONS AND CONSTRUCTS

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.
WHY THE CORRECTION WORKS

Middleware and interceptors are not interchangeable: middleware works with raw HTTP before route execution, while an interceptor knows the selected handler and wraps its result stream.

WHAT PRODUCTION SHOWED
  • Unauthorized requests still execute validation or database work.
  • Changing error mapping unexpectedly changes authentication behavior.
  • End-to-end tests are the only practical way to test one concern.
08 · 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.

09 · 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?