NNODE LOOP LABruntime observatoryNNEON · Статьи на 90 языках
CONNECTING
v24.18.0linux/x64
Middleware → policy → handler → outcome
12

Жизненный цикл запроса NestJS

Проведите реальные HTTP-запросы через middleware, guard, interceptor, pipe, controller, service и exception filter.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Временная шкала

ГОТОВ
0 ms
События появятся здесьЗапустите выбранный сценарий
#ВРЕМЯИСТОЧНИКСОБЫТИЕ
Ожидаю запуск эксперимента…
ГЛАВА 12
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Жизненный цикл запроса NestJS

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

HTTP-запрос проходит не через одну цепочку одинаковых функций, а через контрольные пункты с разной ответственностью. Middleware работает у входа на уровне HTTP adapter. Guard решает, разрешён ли конкретный handler. Interceptor оборачивает handler. Pipe готовит аргументы. Controller вызывает use case. При необработанной ошибке поезд сходит с обычного маршрута и сразу идёт к подходящему exception filter.

ТЕХНИЧЕСКАЯ ОСНОВА

Nest строит request pipeline поверх platform adapter — обычно Express или Fastify. Для success path общий порядок: middleware → guards → interceptors before → pipes → controller/service → interceptors after → response. Interceptors возвращают RxJS Observable, поэтому post-handler часть разматывается в обратном порядке. Если guard, pipe, controller или service бросает необработанное исключение, оставшийся normal flow пропускается и exceptions layer выбирает filter.

Зачем это знатьПонимание pipeline позволяет положить validation, authorization, logging, mapping и error handling в правильные места. На собеседовании важен не только заученный порядок, но и способность объяснить, почему middleware не заменяет guard, а interceptor — exception filter.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01HTTP ADAPTERExpress/Fastify · middleware
02POLICYguards · interceptors
03ARGUMENTSpipes · controller
04OUTCOMEpost-interceptor · filter
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Middleware

HTTP-level функция request/response/next, исполняемая до route-aware Nest components.

02

ExecutionContext

Обёртка Nest над текущим handler, controller class и transport-specific context.

03

Guard

Route-aware policy, решающая, может ли запрос войти в handler; типичный use case — authorization.

04

Interceptor

Компонент вокруг handler: код до `next.handle()` и Observable pipeline после него.

05

Pipe

Validation или transformation конкретных аргументов до вызова controller method.

06

Exception filter

Обработчик необработанных exceptions, переводящий их в transport-specific response.

07

Controller

Transport boundary, который получает подготовленные аргументы и делегирует application service/use case.

08

Platform adapter

Связка Nest с конкретным HTTP engine, обычно Express или Fastify.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Middleware

    Выполняется global, затем module-bound порядок. Может читать raw headers, добавить request id или завершить response.

  2. 02
    Guards

    Global → controller → route. Используют ExecutionContext и metadata handler-а для authentication/authorization policy.

  3. 03
    Interceptors — вход

    Global → controller → route. Запускают timing, tracing, caching или оборачивают следующий этап.

  4. 04
    Pipes

    Проверяют и преобразуют controller arguments. Ошибка pipe не допускает controller.

  5. 05
    Controller и service

    Controller оркестрирует transport boundary и вызывает application logic через injected providers.

  6. 06
    Interceptors — выход

    Разматываются route → controller → global и могут изменить result или обработать Observable error.

  7. 07
    Exception filter — только error path

    При uncaught exception normal flow прекращается. Поиск filter идёт от route к controller и global.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Middleware не знает конечный handler

У него есть URL и raw HTTP objects, но нет route metadata через ExecutionContext. Поэтому authorization по @Roles обычно относится к Guard.

02

Interceptor работает с обеих сторон

Он может измерить handler, преобразовать response, добавить timeout/cache или перехватить Observable error. Middleware после next не эквивалентен этой Nest abstraction.

03

Filters не образуют обычную цепочку

Самый близкий filter, поймавший exception, завершает обработку; тот же exception не передаётся следующим filters автоматически.

04

Global registration влияет на DI

Component, созданный вручную через app.useGlobalGuards(new Guard()), находится вне module provider graph. APP_GUARD/APP_INTERCEPTOR providers сохраняют полноценную injection.

05

Parameter pipes имеют дополнительный порядок

После global/controller/route pipes parameter-specific pipes запускаются от последнего параметра method к первому.

06

Microservice transport меняет boundary, не все концепции

DI, guards, pipes, interceptors и filters применимы и к message handlers, но HTTP middleware/request/response заменяются transport context и message patterns.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
@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-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/nest-lab.js
NestJS runtime358 строк
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 остановлен');
  }
}

Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

01 · Middleware и Interceptor решают разные задачи

Request id создаётся на HTTP boundary, а timing измеряет конкретный 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 хорошо подходит для raw HTTP normalization.
  • Interceptor знает class/handler и видит завершение Observable.
02

02 · Guard использует route metadata

Authorization policy зависит от @Roles конкретного 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 может подготовить user раньше.
  • Guard отвечает на вопрос допуска, а не изменяет DTO.
03

03 · ValidationPipe на system boundary

Не пускать невалидные и лишние поля в application service.

app.useGlobalPipes(new ValidationPipe({
  transform: true,
  whitelist: true,
  forbidNonWhitelisted: true,
}));

@Post()
create(@Body() command: CreateUserDto) {
  return this.createUser.execute(command);
}
  • DTO validation не заменяет business invariants.
  • transform может превратить primitive route/query values.
04

04 · Filter переводит domain/HTTP error

Централизованно задать transport representation ошибки.

@Catch(UserAlreadyExistsError)
class UserConflictFilter implements ExceptionFilter {
  catch(error: UserAlreadyExistsError, host: ArgumentsHost) {
    host.switchToHttp().getResponse().status(409).json({
      code: 'USER_ALREADY_EXISTS',
      message: error.message,
    });
  }
}
  • Domain error не обязан импортировать HttpException.
  • Не скрывайте unexpected exception без logging/correlation id.
05

05 · Pet-project как modular monolith

Сначала закрепить boundaries, которые позже можно вынести в services.

AppModule
├── IdentityModule       // users, sessions, access
├── CatalogModule        // products, prices
├── OrdersModule         // checkout, order state
├── NotificationsModule  // email/push adapters
└── ObservabilityModule  // logs, metrics, tracing

// Каждый feature module экспортирует use cases,
// но владеет своими repository ports и таблицами.
  • Один deploy проще отлаживать во время обучения.
  • Module boundary полезен даже без network boundary.
  • Будущий раздел БД добавит repository adapters и transactions.
06

06 · Kafka появляется из требования, не из моды

Отделить local transaction от асинхронной доставки event.

// В transaction Orders DB:
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);

// Отдельный relay публикует outbox в Kafka.
// Consumer хранит processed eventId для idempotency.
  • Прямой DB commit + Kafka publish создаёт dual-write problem.
  • Event schema versioning и ownership важнее Nest decorator.
  • Kafka retries означают, что consumer обязан быть idempotent.
07 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Exception filter всегда выполняется после controller.

НА САМОМ ДЕЛЕ

Он запускается только для uncaught exception. Успешный request вообще не проходит через filter.

МИФ

Middleware и interceptor отличаются только названием.

НА САМОМ ДЕЛЕ

Middleware относится к HTTP adapter и next(); interceptor route-aware и оборачивает CallHandler Observable до и после handler.

МИФ

Guard нужен для валидации DTO.

НА САМОМ ДЕЛЕ

Guard отвечает за допуск к handler. Валидация/transform аргументов — задача Pipe.

МИФ

Controller должен содержать бизнес-логику и SQL.

НА САМОМ ДЕЛЕ

Controller является transport boundary; use cases и persistence делегируются providers.

МИФ

Микросервисы с Kafka автоматически делают pet-project senior-level.

НА САМОМ ДЕЛЕ

Без bounded contexts, ownership, delivery semantics, idempotency и observability broker лишь добавляет распределённые отказы.

08 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Почему exception filter отсутствует в successful trace?
  2. Какой компонент выберете для @Roles authorization и почему?
  3. Чем post-handler часть interceptor отличается от кода middleware после next?
  4. Что произойдёт с controller, если Pipe бросит BadRequestException?
  5. Почему global APP_GUARD лучше manually constructed guard при dependency injection?
  6. Какие проблемы должны появиться, прежде чем modular monolith стоит разделить на Kafka microservices?