Đặt logic phân quyền ở Middleware? Tại sao mã của bạn "trông có vẻ đúng" nhưng lại phá vỡ kiến trúc NestJS.
Ai học NestJS cũng thuộc lòng luồng đi cơ bản:
Nhìn thì có vẻ là một đường thẳng tuần tự đơn giản, cứ chỗ nào chặn được request sớm nhất thì đặt logic vào đó cho "tối ưu". Nhưng chính suy nghĩ này là cái bẫy khiến không ít dev mới đặt nhầm logic phân quyền (Authorization) vào Middleware, và phải trả giá khi dự án scale lên.
Bài viết này mổ xẻ ranh giới thật sự giữa hai thế giới, Express thô (Middleware) và ngữ cảnh NestJS (Guard) qua một case study cụ thể.
1. Điểm mù kiến trúc: Middleware và Execution Context
Middleware không hề biết request này sẽ được điều hướng vào Controller nào, Method nào.
Middleware trong NestJS thực chất vẫn là middleware của Express/Fastify. Nó chạy trước khi Nest routing diễn ra, nên chỉ làm việc với Request/Response thuần ở tầng HTTP. Vì vậy, middleware không có ExecutionContext, không biết Controller/Handler nào sẽ được gọi, và cũng không truy cập được Metadata từ các decorator như @SetMetadata() hay @Roles().
Guard thì ngược lại, nó được khởi tạo bên trong ExecutionContext của Nest, nên nó biết chính xác class nào, method nào sắp thực thi, và có Reflector để đọc Metadata trên router đó.
Đây chính là điểm mù mà nhiều dev không để ý. Đặt logic phân quyền dựa trên Route Metadata vào Middleware là bất khả thi về mặt kiến trúc, chứ không chỉ là "nên tránh".
2. Anti-pattern: Xử lý Authorization trong Middleware
Bối cảnh: Xây dựng nền tảng SaaS multi-tenant. Cần đọc X-Tenant-ID để xác định không gian làm việc và kiểm tra quyền ADMIN của người dùng. Dev quyết định gộp cả hai vào một TenantAuthMiddleware:
// tenant-auth.middleware.ts
@Injectable()
export class TenantAuthMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
const tenantId = req.headers['x-tenant-id'] as string;
const user = req['user'];
const membership = await this.tenantService.getMembership(tenantId, user.id);
// Sai lầm: Hardcode URL vì không đọc được Metadata
if (req.url.includes('/admin') && membership.role !== 'ADMIN') {
throw new ForbiddenException();
}
next();
}
}
Trong khi đó, ở tầng Controller, NestJS đã có sẵn cơ chế khai báo quyền qua Decorator @Roles('ADMIN') đúng ra đây mới là nơi nên khai báo và kiểm tra phân quyền:
// admin-comic.controller.ts
@Controller('admin/comics')
export class AdminComicController {
@Roles('ADMIN') // Metadata này bị Middleware bỏ qua hoàn toàn
@Delete(':id')
deleteComic(@Param('id') id: string) { /* ... */ }
}
Nhưng vì TenantAuthMiddleware chạy ở tầng middleware trước khi request đến Controller và middleware trong NestJS không có khả năng đọc metadata của Decorator (đây là việc của Guard kết hợp Reflector), nên @Roles('ADMIN') chỉ là một khai báo "chết", không có bất kỳ cơ chế nào thực sự đọc và áp dụng nó.
Hậu quả: Logic bị phân mảnh, dễ vỡ khi thay đổi route và không tận dụng được sức mạnh của NestJS Decorators.
Điều kiện req.url.includes('/admin') là một chuỗi hardcode dễ vỡ, chỉ cần đổi tên route (/admin/comics → /management/comics) là logic phân quyền âm thầm mất tác dụng, không có compiler hay test nào báo lỗi.
Decorator @Roles('ADMIN') tồn tại trên Controller nhưng hoàn toàn vô nghĩa, không có cơ chế nào đọc nó ở tầng Middleware.
Mỗi route mới cần phân quyền lại phải thêm một điều kiện if (req.url.includes(...)) mới → Middleware phình to, không thể test riêng biệt, không thể tái sử dụng.
3. Nguyên tắc "Chia để trị"
Nguyên tắc “Chia để trị” trong thiết kế hệ thống nhằm tách biệt rõ ràng giữa việc xử lý dữ liệu và việc đưa ra quyết định nghiệp vụ. Middleware và Guard sẽ đảm nhận hai vai trò hoàn toàn khác nhau, tránh chồng chéo trách nhiệm và giúp hệ thống dễ hiểu, dễ bảo trì hơn.
Middleware chỉ xử lý dữ liệu đầu vào: đọc header (X-Tenant-ID), xác thực JWT và gắn thông tin như req.user, req.tenantId. Không thực hiện kiểm tra quyền hay đưa ra quyết định.
Guard chịu trách nhiệm phân quyền: sử dụng dữ liệu từ Middleware, đọc metadata (ví dụ @Roles('ADMIN')) bằng Reflector, rồi quyết định cho phép truy cập hoặc ném ForbiddenException.
Các bước cụ thể:
Rút gọn Middleware, chỉ giữ lại phần extract/transform thô.
Viết RolesGuard dùng Reflector để đọc metadata @Roles().
Gắn @Roles('ADMIN') đúng nghĩa, giờ đây nó thực sự được đọc.
Việc áp dụng nguyên tắc "Chia để trị" giúp hệ thống trở nên rõ ràng về trách nhiệm, giảm thiểu lỗi do logic bị trộn lẫn, đồng thời tạo điều kiện thuận lợi cho việc mở rộng và bảo trì về sau.
4. Refactoring: Triển khai Guard chuẩn NestJS
Middleware chỉ extract, không quyết định:
// tenant-context.middleware.ts
@Injectable()
export class TenantContextMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
// Chỉ extract, không quyết định
req['tenantId'] = req.headers['x-tenant-id'];
next();
}
}
Custom decorator để khai báo quyền yêu cầu:
// roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) return true;
const request = context.switchToHttp().getRequest();
return roles.includes(request.membership.role);
}
}
Tại Controller, decorator giờ đã có tác dụng thật
@Controller('admin/comics')
@UseGuards(RolesGuard)
export class AdminComicController {
@Roles('ADMIN')
@Delete(':id')
deleteComic(@Param('id') id: string) {
// ...
}
}
Sau khi refactor theo hướng chuẩn của NestJS:
Middleware chỉ còn nhiệm vụ chuẩn bị dữ liệu (như tenantId, user), không can thiệp vào logic phân quyền.
Guard trở thành nơi duy nhất xử lý authorization, đọc metadata từ @Roles() và đưa ra quyết định truy cập.
Decorator @Roles() giờ đây thực sự có ý nghĩa vì đã được Guard sử dụng đúng cách.
5. Đừng lạm dụng Guard
Tách bạch Middleware/Guard không phải lúc nào cũng là lựa chọn tối ưu tuyệt đối:
Khi logic hoàn toàn không liên quan đến route/metadata: những việc như helmet(), cors(), parse body, rate-limiting theo IP, logging request thô thì cứ để nguyên ở Middleware. Đừng cố "nâng cấp" chúng lên Guard chỉ vì Guard trông tốt hơn. Bản chất chúng không cần biết Controller nào sắp chạy.
Khi cần chặn request cực sớm vì lý do bảo mật/hiệu năng nghiêm ngặt (ví dụ: chặn IP blacklist, giới hạn kích thước body trước khi Nest parse) Middleware (hoặc thậm chí thấp hơn, ở tầng reverse proxy như Nginx) vẫn là lựa chọn đúng, vì lúc đó bạn muốn chặn trước khi routing xảy ra, tiết kiệm tài nguyên xử lý routing/DI không cần thiết.
Khi ứng dụng nhỏ, chỉ có 1-2 role đơn giản và không có kế hoạch mở rộng: over-engineering với Reflector + custom decorator có thể là dư thừa. Một điều kiện if (user.role !== 'admin') ngay trong Controller đôi khi đủ dùng và dễ đọc hơn cho một dự án nhỏ.
Khi Guard không có đủ dữ liệu cần thiết . Guard được thực thi trước Pipe trong lifecycle của NestJS, do đó dữ liệu request chưa được validate hoặc transform. Nếu logic phân quyền phụ thuộc vào dữ liệu đã xử lý từ request body, việc đặt logic trong Guard sẽ không phù hợp. Khi đó, nên cân nhắc sử dụng Interceptor hoặc xử lý tại Service.
Nguyên tắc chọn tầng đúng
Middleware: cần can thiệp Request/Response thô, không quan tâm route đích (logger, helmet, cors, parse body, extract tenant ID).
Guard: logic cần biết Route/Controller sắp thực thi, cần đọc Custom Decorator, liên quan Authentication/Authorization theo ngữ cảnh ứng dụng.
Interceptor: cần biến đổi Response, đo thời gian, bọc logic thực thi, catch lỗi đặc thù.
Kết luận
Không có layer nào vượt trội tuyệt đối về mặt kỹ thuật. Giá trị thực sự nằm ở việc sử dụng đúng công cụ tại đúng vị trí trong request lifecycle. Việc đặt sai logic vào sai tầng có thể không gây lỗi ngay lập tức, nhưng sẽ làm hệ thống trở nên khó mở rộng, khó kiểm thử và khó bảo trì theo thời gian. Cần thường xuyên rà soát lại codebase để đảm bảo:
Middleware không thực hiện các quyết định nghiệp vụ phức tạp.
Guard không bị lạm dụng cho các tác vụ không liên quan đến ngữ cảnh route.
Đây là nền tảng quan trọng để duy trì một kiến trúc rõ ràng và bền vững trong các dự án NestJS.
Bài viết này có hữu ích?
Phản hồi của bạn giúp chúng tôi viết tốt hơn.
Thảo luận
0 bình luậnMuốn tham gia thảo luận?
Đăng nhập để gửi bình luận và theo dõi phản hồi.
Chưa có bình luận nào. Hãy là người đầu tiên chia sẻ.