NestJS Decorator: Từ @Get(), @Param() Đến Nguyên Lý Runtime Bên Dưới
Mở đầu
Nếu bạn từng viết NestJS, chắc hẳn đã quen thuộc với đoạn code sau:
@Controller('photos') export class PhotosController { @Get(':photoId/comments') findByPhoto(@Param('photoId', ParseIntPipe) photoId: number) { return this.commentsService.findByPhoto(photoId); } }
Chỉ vài dòng, bạn có ngay 1 API endpoint hoàn chỉnh: nhận request, trích tham số từ URL, tự động convert type, rồi gọi service. Nhưng điều gì thực sự xảy ra phía sau những ký hiệu @ này? Bài viết này sẽ đi từ khái niệm cơ bản của decorator, qua cách TypeScript biên dịch chúng, đến cách NestJS dùng decorator để dựng lên cả một hệ thống routing hoàn chỉnh trên Express.
1. Decorator là gì, và vấn đề nó giải quyết
Decorator là một loại hàm đặc biệt, cho phép gắn thêm hành vi hoặc metadata vào class, method, property, parameter — mà không cần sửa trực tiếp logic bên trong đối tượng đó.
Trước decorator, muốn thêm hành vi chung (logging, validation, dependency injection...) cho nhiều class/method, bạn phải:
- Lặp code thủ công ở mỗi nơi cần dùng, hoặc
- Viết wrapper function, nhưng phải tự gọi (
withLogging(fn)) ở từng chỗ sử dụng — dễ quên, dễ nhầm, và hành vi bị tách rời khỏi định nghĩa gốc.
Decorator giải quyết bằng cách tách rõ:
- Logic nghiệp vụ — nằm trong method/class, giữ nguyên, sạch sẽ.
- Cross-cutting concern (logging, validate, routing...) — khai báo ngay tại chỗ, bằng cú pháp
@.
Đây chính là ý tưởng của Aspect-Oriented Programming (AOP).
2. TypeScript compile decorator ra sao
TypeScript không có khái niệm decorator ở runtime — nó chỉ tồn tại lúc biên dịch. Khi bật experimentalDecorators: true, TS transpile decorator thành các lệnh gọi hàm thông qua 1 helper tên __decorate.
Ví dụ, với method decorator đơn giản:
function Log(target: any, key: string, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = function (...args: any[]) { console.log(`Calling ${key}`); return original.apply(this, args); }; } class UserService { @Log getUser(id: string) { return { id }; } }
Sẽ được biên dịch thành:
class UserService { getUser(id) { return { id }; } } __decorate([Log], UserService.prototype, "getUser", null);
Nguyên lý cốt lõi: @Log chỉ là cú pháp đường (syntax sugar) cho việc gọi Log(target, key, descriptor), rồi dùng kết quả trả về (descriptor mới) để ghi đè lại method gốc trên prototype bằng Object.defineProperty.
3. Bốn loại decorator và sự khác biệt cốt lõi
| Loại | Chữ ký hàm | Có thể sửa hành vi trực tiếp? |
|---|---|---|
| Class decorator | (target) | Có — có thể thay cả constructor |
| Method decorator | (target, key, descriptor) | Có — sửa descriptor.value |
| Property decorator | (target, key) | Không — chỉ gắn metadata |
| Parameter decorator | (target, key, index) | Không — chỉ ghi lại vị trí (index) của tham số |
Điểm quan trọng nhất: parameter decorator không có quyền sửa gì cả. Nó không nhận descriptor, nên không thể bọc hay chặn hành vi hàm. Nó chỉ làm được 1 việc duy nhất: ghi metadata, kiểu như dán 1 sticker "chú ý tham số ở vị trí này" — còn ai đọc sticker đó và hành động, là chuyện của bên khác.
4. Parameter decorator luôn cần "người bạn đồng hành"
Vì tự thân không làm gì được, parameter decorator luôn phải kết hợp với method decorator (hoặc 1 hệ thống đọc metadata riêng) để metadata có ý nghĩa thực tế.
Ví dụ minh họa nguyên lý này — 1 validator đơn giản:
const positiveParams = new Map<string, number[]>(); // Parameter decorator — chỉ ghi metadata function Positive(target: any, methodName: string, index: number) { const key = `${target.constructor.name}_${methodName}`; if (!positiveParams.has(key)) positiveParams.set(key, []); positiveParams.get(key)!.push(index); } // Method decorator — đọc metadata, thực sự bọc hành vi function ValidateParams(target: any, methodName: string, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = function (...args: any[]) { const key = `${target.constructor.name}_${methodName}`; const indexes = positiveParams.get(key) || []; for (const i of indexes) { if (args[i] <= 0) throw new Error(`Argument at index ${i} must be positive`); } return original.apply(this, args); }; } class PaymentService { @ValidateParams processPayment(userId: string, @Positive amount: number, @Positive tax: number) { console.log('Payment processed:', userId, amount, tax); } }
Thứ tự thực thi lúc class được định nghĩa (quan trọng để hiểu tại sao code hoạt động đúng):
@Positivechạy trước (do decorator TypeScript xử lý mảng từ dưới lên) → ghi index[2, 1]vàoMap.@ValidateParamschạy sau → đọcMap(đã có sẵn dữ liệu) → tạo hàm mới bọc quanh hàm gốc, gán đè lêndescriptor.value.
Đây là 2 giai đoạn tách biệt hoàn toàn về thời điểm:
- Giai đoạn Setup (module load, chạy 1 lần): ghi metadata + tạo hàm bọc.
- Giai đoạn Execution (mỗi lần gọi method): logic validate thực sự chạy, dựa trên metadata đã có sẵn từ Setup.
5. @Get() và @Param() trong NestJS — cùng nguyên lý, khác cách "thực thi"
Quay lại ví dụ đầu bài:
@Get(':photoId/comments') findByPhoto(@Param('photoId', ParseIntPipe) photoId: number) { return this.commentsService.findByPhoto(photoId); }
Khác với cặp @Positive + @ValidateParams, ở đây không decorator nào trực tiếp sửa descriptor.value. Cả @Get() và @Param() chỉ ghi metadata:
// Từ @Get(':photoId/comments') Reflect.defineMetadata('method', 'GET', target, 'findByPhoto'); Reflect.defineMetadata('path', ':photoId/comments', target, 'findByPhoto'); // Từ @Param('photoId', ParseIntPipe) Reflect.defineMetadata('__routeArgs__', [ { index: 0, type: 'param', data: 'photoId', pipes: [ParseIntPipe] } ], target, 'findByPhoto');
Method findByPhoto trên prototype vẫn là hàm gốc, chưa bị sửa gì. Vậy ai thực thi "phép màu" biến metadata thành API thật? — Đó là NestJS core, cụ thể là class RouterExplorer, chạy lúc app bootstrap (NestFactory.create()), không phải lúc class được định nghĩa.
6. Full flow — từ decorator đến request thật
class CreateCommentDto { @IsString() @IsNotEmpty() comment_text: string; } @Controller() export class CommentsController { constructor(private readonly commentsService: CommentsService) {} @Post('photos/:photoId/comments') @UseGuards(JwtAuthGuard) create( @Param('photoId', ParseIntPipe) photoId: number, @Body() body: CreateCommentDto, @CurrentUser() user: User, ) { return this.commentsService.create(body.comment_text, user.id, photoId); } }
┌─────────────────────────────────────────────────────────────────────┐ │ BƯỚC 1: Module load — TẤT CẢ decorator chạy, chỉ GHI metadata │ └─────────────────────────────────────────────────────────────────────┘ 1a. Trên CreateCommentDto: @IsString(), @IsNotEmpty() trên comment_text ↓ Reflect.defineMetadata('__validators__', [ { property: 'comment_text', type: 'isString' }, { property: 'comment_text', type: 'isNotEmpty' }, ], CreateCommentDto.prototype) 1b. Trên method create(): @Post('photos/:photoId/comments') ↓ Reflect.defineMetadata('method', 'POST', target, 'create') Reflect.defineMetadata('path', 'photos/:photoId/comments', target, 'create') @UseGuards(JwtAuthGuard) ↓ Reflect.defineMetadata('__guards__', [JwtAuthGuard], target, 'create') 1c. Trên từng tham số của create() — parameter decorator: @Param('photoId', ParseIntPipe) → index 0 @Body() → index 1 @CurrentUser() → index 2 ↓ Reflect.defineMetadata('__routeArgs__', [ { index: 0, source: 'params', data: 'photoId', pipe: ParseIntPipe }, { index: 1, source: 'body', metatype: CreateCommentDto }, ← lấy từ design:paramtypes { index: 2, source: 'custom', resolver: (req) => req.user }, ], target, 'create') // design:paramtypes tự động được TS compiler chèn (emitDecoratorMetadata: true) // → cho biết tham số index 1 có type thật là CreateCommentDto ┌─────────────────────────────────────────────────────────────────────┐ │ BƯỚC 2: App bootstrap — RouterExplorer QUÉT metadata │ └─────────────────────────────────────────────────────────────────────┘ RouterExplorer.explore(CommentsController) ↓ Đọc: method = 'POST' path = 'photos/:photoId/comments' guards = [JwtAuthGuard] argsMetadata = [ { index:0, source:'params', data:'photoId', pipe:ParseIntPipe }, { index:1, source:'body', metatype:CreateCommentDto }, { index:2, source:'custom', resolver:(req)=>req.user }, ] Nest cũng biết: app.useGlobalPipes(new ValidationPipe({...})) → globalValidationPipe được inject sẵn vào bước sinh handler ┌─────────────────────────────────────────────────────────────────────┐ │ BƯỚC 3: RouterExplorer TỰ SINH RA 1 hàm handler (closure) │ └─────────────────────────────────────────────────────────────────────┘ RouterExecutionContext.create(instance, 'create', guards, argsMetadata, globalValidationPipe) ↓ Trả về 1 hàm mới: async function handler(req, res) { // (a) GUARD — chạy trước tiên for (const GuardClass of guards) { // [JwtAuthGuard] if (!new GuardClass().canActivate(req)) { return res.status(403).json({ message: 'Forbidden' }); } } // (b) RESOLVE ARGS — lặp qua argsMetadata, đúng thứ tự index const args = []; // index 0 — @Param('photoId', ParseIntPipe) let photoId = req.params['photoId']; // '42' (string) photoId = new ParseIntPipe().transform(photoId); // 42 (number) args[0] = photoId; // index 1 — @Body() body: CreateCommentDto // ValidationPipe làm 3 việc: tạo instance, whitelist, validate theo __validators__ args[1] = globalValidationPipe.transform(req.body, { metatype: CreateCommentDto }); // index 2 — @CurrentUser() args[2] = req.user; // (c) GỌI HANDLER GỐC try { const result = instance.create(...args); res.json(result); } catch (err) { res.status(400).json({ message: err.message }); } } ┌─────────────────────────────────────────────────────────────────────┐ │ BƯỚC 4: Nest gọi app.post() THẬT của Express, đăng ký handler │ └─────────────────────────────────────────────────────────────────────┘ app.post('photos/:photoId/comments', handler) ↑ ↑ lấy từ metadata 'path' hàm vừa sinh ra ở Bước 3 (Express không biết gì về Nest, guard, pipe, hay metadata — chỉ thấy 1 callback bình thường) ┌─────────────────────────────────────────────────────────────────────┐ │ BƯỚC 5: Request thật tới — Express + handler xử lý tuần tự │ └─────────────────────────────────────────────────────────────────────┘ POST /photos/42/comments Header: Authorization: Bearer <jwt> Body: { "comment_text": "" , "hacker_field": "xxx" } ↓ Express khớp path pattern → req.params = { photoId: '42' } (middleware auth trước đó đã gắn req.user = { id: 7 }) ↓ Express gọi handler(req, res) ← hàm Nest đã sinh ở Bước 3 ↓ (a) GUARD: JwtAuthGuard.canActivate(req) → req.user tồn tại → PASS (b) RESOLVE ARGS: index 0: req.params.photoId = '42' → ParseIntPipe → 42 index 1: globalValidationPipe.transform(req.body, {metatype: CreateCommentDto}) ├─ new CreateCommentDto() + Object.assign(instance, req.body) ├─ whitelist: xóa "hacker_field" (không có trong DTO) ├─ tra __validators__ của CreateCommentDto (ghi từ Bước 1a) │ - comment_text phải là string → "" là string → PASS │ - comment_text không được rỗng → "" rỗng → FAIL └─ throw Error("comment_text should not be empty") (c) HANDLER CATCH lỗi từ ValidationPipe ↓ res.status(400).json({ message: "comment_text should not be empty" }) ⛔ create() KHÔNG BAO GIỜ ĐƯỢC GỌI — vì lỗi đã throw ở bước resolve args, trước khi tới dòng "const result = instance.create(...args)"
Mỗi lần gọi buildHandler tạo ra 1 closure độc lập — code logic bên trong giống nhau 100%, nhưng mỗi closure "nhớ" (nhờ cơ chế closure của JavaScript) dữ liệu riêng (guards, pipes, argsMetadata) của route đó. Giống như dùng 1 cái khuôn bánh, đổ nguyên liệu khác nhau vào, ra nhiều cái bánh khác nhau — nhưng khuôn chỉ có 1.
9. Đặt trong bức tranh lớn hơn — Request Lifecycle
@Get() và @Param() chỉ là 2 mảnh trong toàn bộ chuỗi xử lý request của NestJS:
Middleware → Guard → Interceptor (before) → Pipe → Handler → Interceptor (after) → Exception Filter
| Bước | Decorator tương ứng |
|---|---|
| Middleware | Không có decorator — dùng configure() + NestMiddleware trong Module |
| Guard | @UseGuards() |
| Interceptor (before/after) | @UseInterceptors() |
| Pipe | @UsePipes() hoặc gắn trực tiếp trong @Param(), @Body(), @Query() |
| Handler | @Get(), @Post(), @Put(), @Delete(), @Patch() |
| Exception Filter | @UseFilters() |
Tất cả các decorator này (trừ middleware) hoạt động theo cùng nguyên lý: ghi metadata lúc module load → NestJS core đọc lại và nhúng logic vào closure handler lúc bootstrap. Không có decorator nào tự mình thực thi logic guard/pipe/interceptor ngay lúc class được định nghĩa — tất cả đều chờ đến khi RouterExplorer "kích hoạt" chúng.
Tổng kết
- Decorator chỉ là cú pháp đường cho việc gọi hàm và (với method decorator) ghi đè
descriptor.value— hoàn toàn biến mất sau khi TypeScript compile. - Parameter decorator (
@Param) không có quyền sửa hành vi — nó chỉ ghi metadata, cần "người đọc" khác để metadata có ý nghĩa. - Với
@Get()+@Param()trong NestJS, "người đọc" đó không phải 1 decorator khác trong cùng cặp, mà là RouterExplorer — chạy lúc bootstrap, đọc toàn bộ metadata (path, args, pipes, guards...) rồi tự sinh ra 1 closure handler, sau đó giao hàm đó cho Express qua đúng API chuẩn (app.get()). - Express không biết gì về Nest — nó chỉ thấy 1 callback function bình thường. Toàn bộ "phép màu" nằm ở lớp trung gian mà NestJS tự dựng lên phía trên Express.
Hiểu được nguyên lý này giúp bạn không còn thấy decorator là "ma thuật", mà là 1 hệ thống rõ ràng: ghi nhãn lúc định nghĩa → đọc nhãn và hành động lúc cần — đúng chuẩn nguyên lý Aspect-Oriented Programming ngay từ đầu bài viết.
