Guards, interceptors и exception filters
Request lifecycle в Nest фиксирован — middleware, guard, interceptor (pre), pipe, handler, interceptor (post), filter. Авторизацию — в guard, валидацию — в pipe, логирование/трансформацию — в interceptor, форму ошибки — в filter.
Ревьюер задаёт один вопрос в твоём PR: «почему проверка роли внутри контроллера?» Хендлер начинается с if (req.user.role !== 'admin') throw new ForbiddenException(), потом валидирует тело руками, потом логирует длительность через Date.now() сверху и снизу, потом форматирует каждую ошибку в один и тот же JSON. Это работает. И это четыре сквозные задачи, размазанные по бизнес-логике и скопированные в девятнадцать хендлеров, — а в день, когда кто-то забудет строку с ролью в хендлере двадцать, это уедет в прод. У Nest уже есть слот для каждой из них. Ревьюер не просит тебя перенести код; он спрашивает, знаешь ли ты request lifecycle.
Lifecycle — это весь урок
Каждый HTTP-запрос в Nest проходит одну и ту же упорядоченную цепочку до и после твоего хендлера. Знание порядка — не мелочь: оно говорит, где каждая задача может стоять и, что не менее важно, что она видит, когда выполняется.
Последовательность: middleware → guards → interceptors (pre) → pipes → handler → interceptors (post) → exception filters (при выбросе). Guard выполняется после middleware, но до любого interceptor или pipe. Pipes выполняются после того, как guards уже решили, что запрос может пройти, — так что к моменту, когда pipe трансформирует тело, авторизация уже улажена. Interceptors — единственный этап, который выполняется дважды: на входе (до хендлера) и на выходе (оборачивая ответ). А exception filters стоят сбоку, ловя всё, что выброшено где угодно ниже.
Две тонкости отделяют сеньоров от тех, кто пролистал доки. Первая: interceptors на выходе разворачиваются по принципу first-in-last-out — на входе они идут global → controller → route, но ответ разматывается route → controller → global, как возвраты вложенных функций. Вторая: filter — это catch-all сток — pipe, который выбросил, guard, который выбросил, хендлер, который выбросил, и даже стрим interceptor, который зафейлился, — всё приземляется в один и тот же filter. Поэтому форматирование ошибки живёт там и нигде больше.
Guards: решение да/нет, прочитанное из метаданных
Guard реализует CanActivate. Он возвращает boolean (или Promise/Observable от него) — true пропускает запрос, false (или выброшенное исключение) останавливает. Это дом для аутентификации и авторизации. Guard получает ExecutionContext — надмножество ArgumentsHost, которое к тому же знает, какой хендлер и контроллер вот-вот выполнятся, — а это ровно то, что нужно, чтобы читать метаданные уровня маршрута.
Канонический паттерн — декоратор @Roles(), который штампует метаданные на хендлер, плюс RolesGuard, который читает их назад через Reflector:
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required) return true; // нет @Roles() на маршруте -> открыт
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role));
}
}getAllAndOverride читает метаданные сначала с хендлера, затем откатывается на контроллер, так что @Roles('admin') на уровне маршрута перекрывает дефолт контроллера. Выбрасывай ForbiddenException вместо возврата false, если хочешь 403 с сообщением, а не голый отказ.
Interceptors: оборачивают хендлер с обеих сторон
intercept(context, next) interceptor выполняет код до хендлера, затем вызывает next.handle() — который возвращает RxJS Observable с будущим ответом — и может выполнить код после, навесив операторы на этот стрим. Эта двойная позиция делает interceptors верным инструментом для всего, что обрамляет вызов: логирования и тайминга, кэширования, формирования ответа и таймаутов.
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now();
return next.handle().pipe(
tap(() => console.log(`took ${Date.now() - start}ms`)), // side-effect, данные не меняются
map((data) => ({ data })), // переформировать каждый ответ
);
}
}tap — для side-effect’ов, которые не трогают payload (логирование, метрики); map трансформирует тело ответа — здесь оборачивает возврат каждого хендлера в { data: ... }, чтобы весь API говорил одним конвертом. Поскольку next.handle() ленивый, pre-код выполняется синхронно в момент вызова intercept, но ничто после next.handle() не выполнится, пока хендлер не зарезолвится. Interceptor также может закоротить цепочку (вернуть свой Observable, не вызывая next.handle(), например отдав попадание в кэш) или ловить ошибки ниже оператором catchError.
Exception filters: формируй ошибку, один раз
Когда что-то в цепочке выбрасывает, встроенный слой исключений Nest ловит это и формирует ответ — HttpException и его наследники мапятся на свой статус-код, всё остальное становится 500. Кастомный @Catch()-filter позволяет владеть этой формой ответа глобально, вместо форматирования ошибок руками в каждом хендлере.
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
@Catch(HttpException)
export class HttpErrorFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
const status = exception.getStatus();
res.status(status).json({
statusCode: status,
message: exception.getResponse(),
timestamp: new Date().toISOString(),
});
}
}@Catch(HttpException) сужает filter до типа; @Catch() без аргумента ловит всё. Поскольку filter — единственный сток для каждого выброса в pipeline, форматирование ошибок живёт здесь и только здесь — guard, pipe или хендлер просто выбрасывают нужное исключение и доверяют filter его отрисовать.
Куда идёт каждая задача?
Это то сеньорское суждение, к которому ведёт весь урок. Ошибка в хуке была не в плохом коде — она была в четырёх задачах, живущих не в своём слоте. Сопоставь задачу с этапом lifecycle:
| Этап | Задача | Когда выполняется | Может блокировать запрос? | Видит ответ? |
|---|---|---|---|---|
| Guard | Authn / authz (да/нет) | После middleware, до interceptor + pipe | Да — false / throw останавливает | Нет |
| Pipe | Валидация + трансформация входа | После guard, прямо перед хендлером | Да — выбрасывает на невалидном входе | Нет |
| Interceptor | Логирование, кэш, трансформация, таймаут | До И после хендлера | Да — может пропустить next.handle() | Да — мапит стрим |
| Filter | Формирует ответ-ошибку | Только когда что-то выброшено | N/A — обрабатывает выброс | Видит ошибку, не успешное тело |
Каждый из четырёх можно привязать на трёх уровнях: метод (@UseGuards() на хендлере), контроллер (тот же декоратор на классе) или глобально (app.useGlobalGuards() в bootstrap, или токен-провайдер для DI-дружелюбных глобалов). Правило: привязывай широко и перекрывай узко — глобальный filter для дефолтной формы ошибки, глобальный ValidationPipe, auth-guard на уровне контроллера и @Roles()-guard плюс метаданные на маршрут там, где живёт гранулярное решение.
▸Почему это работает
Почему для авторизации guard, а не middleware? Middleware выполняется первым и является фреймворк-агностичным кодом Express/Fastify — у него нет доступа к ExecutionContext Nest, поэтому он не видит, какой хендлер или контроллер — цель, и не может читать метаданные маршрута вроде @Roles(). Guard выполняется на шаг позже, с полным execution context, — именно поэтому авторизация, зависящая от маршрута, принадлежит guard. Middleware — для задач, которым не нужно знать пункт назначения: разбор сырого тела, request ID, CORS.
Нужно обеспечить, чтобы эндпоинт могли вызывать только пользователи с ролью 'admin', и иначе вернуть чистый 403. Какой компонент lifecycle владеет этим?
В request lifecycle Nest когда guard выполняется относительно pipes и interceptors?
Ты хочешь оборачивать каждый ответ в { data: ... } и логировать, сколько занял каждый хендлер. Какой компонент и как?
- 01Назови порядок request lifecycle в Nest и объясни, что особенного в том, где сидят interceptors и exception filters.
- 02Сопоставь каждую из аутентификации/авторизации, валидации входа, логирования запроса и форматирования ошибок с её верным компонентом lifecycle и назови уровни привязки.
Guards (охранники-авторизаторы), interceptors (перехватчики), pipes и exception filters — это слой сквозных задач Nest, и request lifecycle прикалывает каждый к фиксированному слоту: middleware, затем guard, затем pre-код interceptor, затем pipe, затем хендлер, затем post-код interceptor, с exception filter, ловящим любой выброс по пути. Guard реализует CanActivate, возвращает boolean или выбрасывает и читает метаданные маршрута через ExecutionContext и Reflector — что делает его домом аутентификации и авторизации, улаженной до валидации любого входа. Interceptor оборачивает хендлер с обеих сторон: он прогоняет Observable из next.handle() через RxJS-операторы вроде tap (side-effect’ы вроде логирования или тайминга) и map (формирование ответа) и может закоротить цепочку или ловить ошибки ниже. Pipe валидирует и трансформирует вход прямо перед хендлером. Exception filter, объявленный через @Catch(), — единственный сток для каждого выброса, так что форматирование ошибок живёт там одно. Сеньорское суждение — это размещение: authz — это guard, валидация — это pipe, логирование/трансформация/таймаут — это interceptor, форма ошибки — это filter, — каждый привязываемый на уровне метода, контроллера или глобально, где ты привязываешь широко и перекрываешь узко. Теперь, когда увидишь проверку роли внутри хендлера или try/catch, форматирующий ошибки прямо в сервисе, — сразу поймёшь, в какой слот это перенести.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.