HTTP-запрос проходит не через одну цепочку одинаковых функций, а через контрольные пункты с разной ответственностью. Middleware работает у входа на уровне HTTP adapter. Guard решает, разрешён ли конкретный handler. Interceptor оборачивает handler. Pipe готовит аргументы. Controller вызывает use case. При необработанной ошибке поезд сходит с обычного маршрута и сразу идёт к подходящему exception filter.
Разбираем: Жизненный цикл запроса NestJS
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
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.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Middleware
HTTP-level функция request/response/next, исполняемая до route-aware Nest components.
ExecutionContext
Обёртка Nest над текущим handler, controller class и transport-specific context.
Guard
Route-aware policy, решающая, может ли запрос войти в handler; типичный use case — authorization.
Interceptor
Компонент вокруг handler: код до `next.handle()` и Observable pipeline после него.
Pipe
Validation или transformation конкретных аргументов до вызова controller method.
Exception filter
Обработчик необработанных exceptions, переводящий их в transport-specific response.
Controller
Transport boundary, который получает подготовленные аргументы и делегирует application service/use case.
Platform adapter
Связка Nest с конкретным HTTP engine, обычно Express или Fastify.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Middleware
Выполняется global, затем module-bound порядок. Может читать raw headers, добавить request id или завершить response.
- 02Guards
Global → controller → route. Используют ExecutionContext и metadata handler-а для authentication/authorization policy.
- 03Interceptors — вход
Global → controller → route. Запускают timing, tracing, caching или оборачивают следующий этап.
- 04Pipes
Проверяют и преобразуют controller arguments. Ошибка pipe не допускает controller.
- 05Controller и service
Controller оркестрирует transport boundary и вызывает application logic через injected providers.
- 06Interceptors — выход
Разматываются route → controller → global и могут изменить result или обработать Observable error.
- 07Exception filter — только error path
При uncaught exception normal flow прекращается. Поиск filter идёт от route к controller и global.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Middleware не знает конечный handler
У него есть URL и raw HTTP objects, но нет route metadata через ExecutionContext. Поэтому authorization по @Roles обычно относится к Guard.
Interceptor работает с обеих сторон
Он может измерить handler, преобразовать response, добавить timeout/cache или перехватить Observable error. Middleware после next не эквивалентен этой Nest abstraction.
Filters не образуют обычную цепочку
Самый близкий filter, поймавший exception, завершает обработку; тот же exception не передаётся следующим filters автоматически.
Global registration влияет на DI
Component, созданный вручную через app.useGlobalGuards(new Guard()), находится вне module provider graph. APP_GUARD/APP_INTERCEPTOR providers сохраняют полноценную injection.
Parameter pipes имеют дополнительный порядок
После global/controller/route pipes parameter-specific pipes запускаются от последнего параметра method к первому.
Microservice transport меняет boundary, не все концепции
DI, guards, pipes, interceptors и filters применимы и к message handlers, но HTTP middleware/request/response заменяются transport context и message patterns.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
@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);
}
}Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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)',
);
}
}
Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Практические шаблоны, которые можно подсмотреть
Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.
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 · 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 · 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 · 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 · 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 · 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.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Один interceptor делает auth, validation и обработку ошибок
Чтобы «не размазывать код», команда помещает проверку JWT, DTO и преобразование всех исключений в один глобальный interceptor.
Компонент запускается не на том этапе, знает слишком много и превращает 4xx/5xx в одинаковый 200 response. Guards и pipes невозможно переиспользовать по metadata, observability теряет настоящий outcome.
@Injectable()
export class EverythingInterceptor {
async intercept(context, next) {
const req = context.switchToHttp().getRequest();
req.user = await verifyJwt(req.headers.authorization);
validateCreateOrder(req.body);
try {
return await lastValueFrom(next.handle());
} catch (error) {
return { ok: false, message: error.message };
}
}
}Auth policy, argument transformation, around-handler logic и exception mapping имеют разные lifecycle contracts. Возврат объекта из catch также меняет HTTP error на успешный response.
@Controller('orders')
@UseGuards(JwtAuthGuard)
@UseInterceptors(TracingInterceptor)
export class OrdersController {
@Post()
create(
@Body(new ValidationPipe({
whitelist: true,
transform: true,
}))
dto: CreateOrderDto,
) {
return this.orders.create(dto);
}
}
// Domain errors → global ExceptionFilter
// Correlation id → middleware
// Timing/trace → interceptorКаждая задача находится в компоненте с подходящим ExecutionContext и порядком. Filter сохраняет HTTP status, interceptor видит реальный success/error, guard блокирует handler до pipes.
Что делают непривычные вызовы из обоих фрагментов кода.
NestInterceptor / next.handle()- Interceptor выполняет код вокруг handler, а next.handle запускает следующую часть pipeline и возвращает Observable.
lastValueFrom(observable)- Преобразует RxJS Observable в Promise с последним значением; в плохом примере это маскирует естественную interceptor pipeline.
@UseGuards(JwtAuthGuard)- Guard решает, разрешён ли запрос. При отказе controller handler и pipe его параметров не выполняются.
ValidationPipe- Проверяет входной DTO и при transform=true преобразует допустимые значения к ожидаемым типам.
@UseInterceptors(...)- Подключает обёртку для logging, timing, cache или преобразования результата выбранного handler.
@UseFilters(...)- Подключает exception filter, который превращает необработанное исключение в контролируемый HTTP-ответ.
Популярные заблуждения
Миф слева, корректная модель справа.
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 лишь добавляет распределённые отказы.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему exception filter отсутствует в successful trace?
- Какой компонент выберете для @Roles authorization и почему?
- Чем post-handler часть interceptor отличается от кода middleware после next?
- Что произойдёт с controller, если Pipe бросит BadRequestException?
- Почему global APP_GUARD лучше manually constructed guard при dependency injection?
- Какие проблемы должны появиться, прежде чем modular monolith стоит разделить на Kafka microservices?