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
┌─────────────────────────────────────────────────────────────┐ │ BƯỚC 1: Module load — decorator chạy, GHI vào reflect-metadata │ └─────────────────────────────────────────────────────────────┘ @Get('photos/:photoId/comments') @Param('photoId', ParseIntPipe) ↓ Reflect.defineMetadata('path', 'photos/:photoId/comments', target, 'findByPhoto') Reflect.defineMetadata('method', 'GET', target, 'findByPhoto') Reflect.defineMetadata('__routeArgs__', [{index:0, data:'photoId', pipes:[ParseIntPipe]}], target, 'findByPhoto') ┌─────────────────────────────────────────────────────────────┐ │ BƯỚC 2: App bootstrap — RouterExplorer QUÉT metadata │ └─────────────────────────────────────────────────────────────┘ RouterExplorer.explore(UserController) ↓ Đọc: path = 'photos/:photoId/comments' method = 'GET' argsMetadata = [{index:0, data:'photoId', pipes:[ParseIntPipe]}] ┌─────────────────────────────────────────────────────────────┐ │ BƯỚC 3: RouterExplorer TỰ SINH RA 1 hàm handler (closure) │ └─────────────────────────────────────────────────────────────┘ RouterExecutionContext.create(instance, 'findByPhoto', argsMetadata) ↓ Trả về 1 hàm mới — đây chính là hàm mình viết tay ở câu trả lời trước: function handler(req, res) { // check guard (nếu có) let value = req.params['photoId']; // đọc theo argsMetadata value = new ParseIntPipe().transform(value); // chạy pipe theo argsMetadata const result = instance.findByPhoto(value); res.json(result); } ┌─────────────────────────────────────────────────────────────┐ │ BƯỚC 4: Nest gọi app.get() THẬT của Express, đăng ký handler │ └─────────────────────────────────────────────────────────────┘ app.get('photos/:photoId/comments', handler) ↑ ↑ lấy từ metadata 'path' hàm vừa sinh ra ở Bước 3 ┌─────────────────────────────────────────────────────────────┐ │ BƯỚC 5: Request thật tới — Express xử lý theo đúng cơ chế của nó│ └─────────────────────────────────────────────────────────────┘ GET /photos/42/comments ↓ Express tự khớp regex path-to-regexp → req.params = { photoId: '42' } ↓ Express gọi handler(req, res) ← đây là hàm Nest đã sinh ở Bước 3 ↓ Bên trong handler: đọc req.params.photoId, chạy Pipe, gọi findByPhoto(42)``` Điều quan trọng cần nhấn mạnh: **Express hoàn toàn không biết gì về Nest, decorator, hay metadata.** Với Express, nó chỉ nhận **1 callback function bình thường** qua đúng API chuẩn `app.get(path, handler)` — như mọi route Express khác. Toàn bộ "thông minh" (guard, pipe, resolve tham số) nằm trong **thân của closure** mà `RouterExplorer` tự viết ra, chứ không nằm ở tầng Express. --- ## 7. Vì sao cần `ParseIntPipe` `req.params` từ Express **luôn luôn là string**, vì URL vốn chỉ là text. Kiểu `photoId: number` trong TypeScript chỉ là khai báo type lúc compile-time, **không tự động convert giá trị lúc runtime**. Nếu bỏ `ParseIntPipe`, `photoId` bên trong hàm vẫn sẽ nhận `'42'` (string), dù TypeScript không báo lỗi gì — vì type-checking của TS chỉ chạy lúc compile, không giám sát được giá trị thật lúc app đang chạy. `ParseIntPipe.transform()` chính là bước convert type thật sự, được `RouterExplorer` chèn vào giữa lúc lấy `req.params.photoId` và lúc gọi hàm gốc. --- ## 8. Vì sao chỉ có 1 hàm xử lý chung, dù có hàng chục controller Một câu hỏi tự nhiên: nếu có 10 controller, mỗi controller vài method, code xử lý guard/pipe có bị lặp lại không? **Không.** NestJS chỉ có **1 hàm/class xử lý chung** (đơn giản hóa gọi là `buildHandler`, thực tế Nest dùng `RouterExecutionContext`), được **gọi lại nhiều lần** — mỗi lần cho 1 route, với metadata riêng của route đó: ```javascript for (const ControllerClass of allControllers) { for (const methodName of getMethodNames(ControllerClass)) { // GỌI CÙNG 1 HÀM, chỉ đổi tham số (metadata khác nhau theo route) const handler = buildHandler(instance, methodName); app[httpMethod](fullPath, handler); } }
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.
