NestJS Zero to Hero 05 — Request Lifecycle và Execution Pipeline
Lần theo middleware, guard, interceptor, pipe, controller và exception filter; đặt auth, validation, logging và error mapping đúng tầng.
Nest có nhiều “điểm móc”: middleware, guard, interceptor, pipe và exception filter. Chúng không phải năm cách tương đương để chạy code trước controller. Mỗi loại nhìn thấy context khác nhau, có nhiệm vụ và thứ tự riêng.
Sau bài này, bạn có thể:
- vẽ đúng request lifecycle hai chiều;
- chọn enhancer theo câu hỏi cần trả lời;
- hiểu global/controller/route binding và thứ tự thực thi;
- tạo request ID, authorization guard, timing interceptor và error envelope;
- tránh logic nghiệp vụ trong cross-cutting pipeline.
1. Bản đồ lifecycle
Với HTTP request thành công:
request
→ middleware (global → module)
→ guards (global → controller → route)
→ interceptors.before (global → controller → route)
→ pipes (global → controller → route → parameter)
→ controller handler
→ service/use case
→ interceptors.after (route → controller → global)
→ response
Khi exception thoát ra, exception filter phù hợp gần nhất xử lý nó. Interceptor có thể quan sát/transform cả success lẫn error qua RxJS stream.
Mental model theo câu hỏi:
| Thành phần | Câu hỏi chính |
|---|---|
| middleware | request thô cần enrich/normalize gì trước routing context? |
| guard | request này có được vào handler không? |
| interceptor | bao quanh invocation để đo, transform, cache, timeout? |
| pipe | argument này có hợp lệ và cần transform thế nào? |
| filter | exception này map thành transport response nào? |
Request lifecycle chính thức là trang nên bookmark; đoán thứ tự là nguồn bug rất phổ biến.
2. Middleware: làm việc với request/response thô
Tạo request ID sớm để mọi log sau đó dùng được:
// src/common/http/request-id.middleware.ts
import { randomUUID } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
export function requestId(
req: Request,
res: Response,
next: NextFunction
): void {
const incoming = req.header('x-request-id');
const id =
incoming && /^[a-zA-Z0-9_-]{8,64}$/.test(incoming)
? incoming
: randomUUID();
res.setHeader('x-request-id', id);
Reflect.set(req, 'requestId', id);
next();
}
Đăng ký functional middleware trong root module:
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
@Module({ imports: [TasksModule] })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(requestId).forRoutes('{*splat}');
}
}
{*splat} là syntax Express 5 khớp cả root. Middleware phù hợp request ID,
cookie parser, raw-body capture cho webhook signature. Nó chưa có
ExecutionContext giàu metadata, nên authorization theo handler không thuộc đây.
Luôn gọi next() hoặc kết thúc response. Quên cả hai làm request treo.
3. Guard: quyết định admission
Guard nhận ExecutionContext, biết handler và controller đang chạy. Tạm tạo API
key guard để học mechanics; production auth sẽ ở bài 11.
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';
@Injectable()
export class ApiKeyGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const key = request.header('x-api-key');
if (key !== process.env.DEV_API_KEY) {
throw new UnauthorizedException('Invalid API key');
}
return true;
}
}
Bind route/controller bằng @UseGuards(ApiKeyGuard), hoặc global qua DI:
import { APP_GUARD } from '@nestjs/core';
providers: [ApiKeyGuard, { provide: APP_GUARD, useExisting: ApiKeyGuard }];
Đăng ký global enhancer bằng APP_GUARD cho phép guard inject dependency.
app.useGlobalGuards(new ApiKeyGuard()) tự new có thể bỏ qua container.
Guard trả false thường tạo 403; khi credential thiếu/sai, ném 401 có chủ đích.
Guard nên quyết định quyền dựa trên principal/policy, không đổi task state.
4. Pipe: validate/transform argument
Built-in ParseUUIDPipe từ chối ID sai trước handler:
@Get(':taskId')
findOne(
@Param('taskId', new ParseUUIDPipe({ version: '4' })) taskId: string,
) {
return this.getTask.execute(taskId);
}
Pipe chạy cho parameter đã được route extraction. Nó phù hợp:
- parse string thành integer/boolean/array;
- validate DTO body/query;
- normalize một argument có contract rõ.
Không query database trong pipe để “kiểm tra task tồn tại”. Việc đó biến validation thành I/O ẩn, khó kiểm soát transaction và có race condition giữa check với use case. Use case/repository phải sở hữu existence rule.
Built-in pipes gồm ParseIntPipe, ParseBoolPipe, ParseArrayPipe và ParseUUIDPipe. Validation DTO toàn cục sẽ được hoàn thiện ở bài 7.
5. Interceptor: bao quanh handler
Interceptor nhận CallHandler; next.handle() trả Observable. Đo latency:
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor,
} from '@nestjs/common';
import type { Observable } from 'rxjs';
import { finalize } from 'rxjs/operators';
@Injectable()
export class TimingInterceptor implements NestInterceptor {
private readonly logger = new Logger(TimingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const startedAt = performance.now();
const handler = context.getHandler().name;
const controller = context.getClass().name;
return next.handle().pipe(
finalize(() => {
this.logger.log({
event: 'request_completed',
controller,
handler,
durationMs: Number((performance.now() - startedAt).toFixed(2)),
});
})
);
}
}
finalize() chạy cả success và error. Không log raw body/token/password. Không
dùng raw URL có ID làm metric label — cardinality sẽ bùng nổ; observability bài
19 sẽ dùng route template.
Interceptor cũng có thể:
- map response envelope;
- timeout handler;
- cache representation;
- attach tracing span;
- bỏ field nhạy cảm qua serialization.
Không dùng interceptor để tự mở transaction chung cho mọi route: transaction boundary thuộc use case và không phải request nào cũng cần DB.
6. Exception filter: map error ở transport boundary
Nest xử lý HttpException sẵn. Ta cần stable error contract cho domain error và
unknown exception:
export interface ApiErrorBody {
error: {
code: string;
message: string;
requestId?: string;
};
}
Filter toàn cục:
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import type { Request, Response } from 'express';
@Catch()
export class ApiExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(ApiExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const req = http.getRequest<Request>();
const res = http.getResponse<Response>();
const known = exception instanceof HttpException;
const status = known
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
if (!known) {
this.logger.error('Unhandled request error', exception);
}
res.status(status).json({
error: {
code: known ? `HTTP_${status}` : 'INTERNAL_ERROR',
message: known ? exception.message : 'Internal server error',
requestId: Reflect.get(req, 'requestId') as string | undefined,
},
} satisfies ApiErrorBody);
}
}
Đăng ký bằng APP_FILTER để filter do DI quản lý. Production không trả stack,
SQL, path nội bộ hoặc message của unknown error. Log chi tiết ở server với
request/trace ID; client nhận code ổn định.
Ở bài 10, filter sẽ map TaskNotFoundError/InvalidTransitionError, để domain
không import @nestjs/common.
7. Binding scope và thứ tự
Enhancer có thể bind global, controller hoặc route:
@UseGuards(WorkspaceMemberGuard)
@UseInterceptors(TaskSerializationInterceptor)
@Controller('tasks')
export class TasksController {
@UseGuards(TaskEditorGuard)
@Patch(':taskId')
update() {}
}
Incoming guard chạy global → controller → route. Interceptor outgoing unwind ngược lại như nested function:
Global.before
Controller.before
Route.before
handler
Route.after
Controller.after
Global.after
Đừng phụ thuộc ngầm vào thứ tự hai global enhancer đăng ký rải rác. Gom cross-cutting registration ở một module và viết integration test cho behavior.
Lab quan sát pipeline
Tạo một mảng event chỉ dùng trong test, thêm marker ở mỗi enhancer và handler:
middleware
guard:global
guard:controller
interceptor:global:before
interceptor:route:before
pipe
handler
interceptor:route:after
interceptor:global:after
Gọi route success rồi route ném error. So sánh event. Đây là cách biến lifecycle từ kiến thức thuộc lòng thành bằng chứng.
Sau đó thêm endpoint public qua metadata:
export const IS_PUBLIC = Symbol('IS_PUBLIC');
export const Public = () => SetMetadata(IS_PUBLIC, true);
Global guard dùng Reflector.getAllAndOverride() trên handler/controller để bỏ
qua auth. Pattern metadata này sẽ được tái dùng cho permission ở bài 12.
Bài tập bắt buộc
- Thêm request-ID middleware, trả ID trong success header và error body.
- Bind
ApiKeyGuardglobal bằngAPP_GUARD;/health/livephải public qua metadata, không hard-code path trong guard. - Thêm timing interceptor; xác minh nó log cả 2xx và 5xx.
- Dùng
ParseUUIDPipecho mọi task/workspace ID. - Viết E2E test chứng minh pipeline order và unknown error không lộ stack.
Acceptance criteria
- Mỗi concern được đặt đúng enhancer và có một câu giải thích “vì sao”.
- Global enhancer được container quản lý, không tự
newdependency graph. - Guard trả đúng 401/403; validation lỗi trước handler.
- Error body có stable code + request ID, không lộ internal detail.
- Log không chứa credential/body nhạy cảm.
Tài liệu tham chiếu
- NestJS — Request lifecycle
- NestJS — Middleware
- NestJS — Guards
- NestJS — Interceptors
- NestJS — Pipes
- NestJS — Exception filters
- NestJS — Execution context
- NestJS — Reflection and metadata
Phần 6 khóa bootstrap bằng config đã validate và structured logging: app phải fail trước khi listen nếu environment sai, không fail ở request đầu tiên.