Nest: жизненный цикл и идиомы
В уроке 06 мы собрали модуль и поняли контейнер. Но контроллер был тонким, а
вся сквозная логика (аутентификация, логирование, формат ответа, обработка
ошибок) висела в воздухе. В Fastify такие задачи мы решали хуками: помнишь
конвейер onRequest → preHandler → handler → onSend из урока 03? У Nest
есть свой конвейер, только из четырёх типов сущностей. Этот урок про него и про
идиомы, которые делают Nest-код таким, каким его пишут в проде.
Главный практический сюжет урока, в § 7: как из сервисов возвращать
типизированные ошибки в стиле railway (через fp-ts TaskEither) и аккуратно
распаковывать их одним interceptor, не засоряя контроллеры try/catch. Это
паттерн из боевого Nest-проекта, и он отлично ложится на функциональный стиль,
который мы держим во всём курсе.
1. Конвейер запроса
Каждый входящий запрос в Nest проходит фиксированную цепочку:
middleware → guards → interceptors (before) → pipes → handler
→ interceptors (after) → (при ошибке) exception filters
Разберём по ролям:
- middleware, самый ранний слой, Express/Fastify-совместимый. Работает ещё до того, как Nest сопоставил запрос с обработчиком. Сюда кладут то, что не зависит от роута: CORS, helmet, разбор cookie, сырое логирование.
- guards, отвечают на вопрос «пускать или нет». Метод
canActivateвозвращаетtrue/false(или промис/Observableот них).false, это 403, дальше не идём. Здесь живут аутентификация и авторизация. - interceptors, оборачивают вызов обработчика. У них две половины: до handler (старт таймера, открытие транзакции) и после (трансформация результата, кэш, замер длительности). Это самая «эффектовая» точка, к ней вернёмся в § 7.
- pipes, валидируют и преобразуют аргументы обработчика.
ValidationPipeпроверяет DTO,ParseIntPipeприводит типы. Невалидный вход, это 400, и handler не вызовется. - handler, метод контроллера. Зовёт сервис, возвращает значение.
- exception filters, ловят любое исключение из любого слоя выше и превращают его в HTTP-ответ. Это единая воронка ошибок.
Параллель с Fastify прямая, но сущностей меньше и они типизированы ролями.
Где в Fastify был один preHandler, в котором ты сам и аутентифицировал, и
валидировал, в Nest это разнесено по guard и pipe с чёткой ответственностью.
Прокрути запрос по конвейеру и посмотри, как разные сбои сходятся в одной точке, в фильтре исключений:
- middleware
- guards
- interceptors (before)
- pipes
- handler
- interceptors (after)
- exception filters (сбор ошибок)
Запрос ещё не вошёл в конвейер.
Ключевое наблюдение из виджета: любая ошибка, в каком бы слое она ни возникла, приходит в exception filter. Это и есть фундамент идиомы из § 7: сервису достаточно сообщить об ошибке, а единый формат ответа соберёт фильтр.
2. Четыре сущности конвейера в коде
Минимальные примеры каждого слоя, чтобы было от чего отталкиваться.
Guard, реализует CanActivate:
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<string[]>('roles', context.getHandler());
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.includes(user?.role);
}
}
Reflector, штатный сервис, читающий метаданные, которые ты вешаешь своим
декоратором (@Roles('admin')). Это уже знакомая по уроку 06 рефлексия:
декоратор кладёт метку, guard её читает.
Interceptor, реализует NestInterceptor, работает через RxJS:
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const started = performance.now();
const { method, url } = context.switchToHttp().getRequest();
// до handler: код здесь выполнится перед вызовом
return next.handle().pipe(
// после handler: tap видит результат
tap(() => this.logger.log(`${method} ${url} · ${Math.round(performance.now() - started)} ms`)),
);
}
}
next.handle(), это Observable
результата обработчика. Всё до return, выполняется до handler; операторы в
.pipe(...), после. Interceptor, единственный слой, который видит обе стороны
вызова, поэтому в нём и трансформируют ответ.
Pipe, реализует PipeTransform:
@Injectable()
export class ParsePositiveIntPipe implements PipeTransform<string, number> {
transform(value: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new BadRequestException('Ожидалось положительное целое');
}
return parsed;
}
}
Exception filter, реализует ExceptionFilter, ловит указанный класс ошибок:
@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter {
catch(exception: DomainError, host: ArgumentsHost): void {
const response = host.switchToHttp().getResponse();
response.status(exception.httpCode).send({
success: false,
error: { type: exception.type, details: exception.details },
});
}
}
3. Куда вешать: метод, контроллер, глобально
Каждую из четырёх сущностей можно навесить на трёх уровнях, и это важная часть идиоматики:
// на конкретный метод
@UseGuards(RolesGuard)
@Get(':id')
findById() {}
// на весь контроллер
@UseGuards(RolesGuard)
@Controller('users')
export class UsersController {}
// глобально, для всего приложения
app.useGlobalGuards(new RolesGuard());
Но глобальная регистрация через useGlobalGuards/useGlobalFilters отрезает
сущность от контейнера: new RolesGuard() ты создаёшь руками, и зависимости в
него не внедрятся. Идиоматичный способ повесить guard/interceptor/pipe/filter
глобально с DI, через токены APP_GUARD, APP_INTERCEPTOR, APP_PIPE,
APP_FILTER:
@Module({
providers: [
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_FILTER, useClass: DomainExceptionFilter },
{
provide: APP_PIPE,
useValue: new ValidationPipe({ whitelist: true, transform: true }),
},
],
})
export class CoreModule {}
Так контейнер сам создаёт guard и внедряет в него Reflector. Этот
CoreModule с глобальными провайдерами, типовая раскладка боевого проекта:
один модуль собирает всю сквозную инфраструктуру, остальные импортируют его.
Порядок interceptors при этом, порядок их регистрации в providers, это
пригодится в § 7.
4. Scopes: сколько живёт провайдер
По умолчанию провайдер, синглтон: контейнер создаёт его один раз на всё
приложение. Это scope DEFAULT,
и в 95% случаев он правильный. Есть ещё два:
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
readonly id = randomUUID(); // свой на каждый запрос
}
@Injectable({ scope: Scope.TRANSIENT })
export class CounterService {} // новый инстанс при каждом внедрении
REQUEST, новый инстанс на каждый HTTP-запрос. Нужен, когда провайдер хранит состояние запроса (текущий пользователь, request id, открытая транзакция).TRANSIENT, новый инстанс каждому, кто его внедряет. Нужен редко: например, логгер, который хочет знать имя класса-владельца.
Цена REQUEST-scope реальна: он заразен вверх по графу. Если сервис
request-scoped, то и контроллер, который его внедряет, становится
request-scoped, и контейнер пересобирает эту ветку графа на каждый запрос.
Для горячего пути это заметные накладные расходы. Идиома: не тянуть состояние
запроса в провайдеры, а возить его в ExecutionContext или через
AsyncLocalStorage (мост на контекст в Node),
оставляя сервисы синглтонами.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.