Kiến trúc NestJS: module, provider và controller Cơ bản
Thời gian đọc ước tính: 7 phút
Vấn đề
Phần tiêu đề “Vấn đề”Route GET /orders/42 ban đầu chỉ trả một object cố định. Vài sprint sau, handler phải đọc database, kiểm tra quyền, gọi dịch vụ thanh toán. Nếu mọi thứ nằm trong controller, test route nào cũng phải dựng cả đống dependency, và đổi data source cũng chạm code HTTP.
NestJS chia ba vai để đặt trách nhiệm đúng chỗ:
| Thành phần | Trả lời câu hỏi | Không nên giao |
|---|---|---|
| Controller | HTTP request đi vào đâu, response ra sao? | SQL, transaction, tích hợp dài dòng |
| Provider | Logic nào dùng lại được, dependency nào cần inject? | State riêng của request (khi ở scope mặc định) |
| Module | Thành phần nào thuộc cùng ranh giới, cái gì được công bố ra ngoài? | Đóng vai handler hay lưu dữ liệu runtime |
Hai hiểu lầm hay gặp:
- Module không phải “hộp chạy” riêng cho mỗi request: nó là metadata để dựng graph lúc khởi động.
@Injectable()không phải cách viết tắt củanew: instance do container quản lý, mặc định dùng chung trong cả application.
Cơ chế token và đăng ký provider nằm ở bài DI container.
Một module hoàn chỉnh
Phần tiêu đề “Một module hoàn chỉnh”Đây là code thật của examples/src/nestjs-architecture/, chạy với NestJS 11 và @nestjs/platform-express. Dữ liệu giữ trong Map để thấy kiến trúc; chưa có database hay validation.
@Injectable()export class OrdersService { private readonly orders = new Map([[42, { id: 42, status: 'created' }]]);
findById(id: number) { const order = this.orders.get(id); if (!order) throw new NotFoundException('Order not found'); return order; }}
// orders.controller.ts@Controller('orders')export class OrdersController { constructor(private readonly orders: OrdersService) {}
@Get(':id') findById(@Param('id') rawId: string) { const id = Number(rawId); if (!Number.isInteger(id)) throw new BadRequestException('id must be an integer'); return this.orders.findById(id); }}
// orders.module.ts@Module({ controllers: [OrdersController], providers: [OrdersService] })export class OrdersModule {}
// main.ts@Module({ imports: [OrdersModule] })class AppModule {}const app = await NestFactory.create(AppModule);await app.listen(3002);GET /orders/42 trả { id: 42, status: 'created' }; /orders/99 trả 404 qua NotFoundException. Trong production hãy thay Number() bằng pipe hoặc DTO (request lifecycle).
Khi bootstrap, module khai báo controller, provider và import/export. Nest dựng dependency graph và tạo provider theo scope.
Module chỉ làm việc lúc bootstrap: Nest đọc metadata, dựng graph, tạo provider rồi nối vào constructor. Lúc có request, nó đi qua router tới controller đã được nối sẵn, không chạy lại module.
Import và export giữa hai module
Phần tiêu đề “Import và export giữa hai module”Khi OrdersService cần PaymentsService của module khác, ba điều kiện phải cùng đúng:
PaymentsServicenằm trongproviderscủaPaymentsModule.PaymentsModuleghiexports: [PaymentsService].OrdersModuleghiimports: [PaymentsModule].
Đang tải diagram…
@Module({ providers: [PaymentsService], exports: [PaymentsService] })class PaymentsModule {}
@Module({ imports: [PaymentsModule], providers: [OrdersService] })class OrdersModule {}Bỏ exports thì Nest báo lỗi lúc bootstrap. Output thật từ exports-demo.ts (Nest 11):
Thiếu exports -> Nest can't resolve dependencies of the OrdersService (?). Please make sure that the argument PaymentsService at index [0] is available in the BrokenOrdersModule module.Có exports -> charged-42Cách đọc thông báo: dòng này cho hai thông tin, token nào thiếu (PaymentsService ở vị trí 0) và module nào đang tạo class cần nó (BrokenOrdersModule). Hãy trả lời hai câu hỏi đó trước khi thêm @Global().
Khi nào tách module, khi nào không
Phần tiêu đề “Khi nào tách module, khi nào không”| Nên tách module | Không nên |
|---|---|
| Có ranh giới nghiệp vụ rõ (orders, payments) và API công khai nhỏ | Mỗi endpoint một module |
| Provider cần che khỏi phần còn lại (không export) | Chỉ để “cho gọn thư mục” |
| Muốn thay hoặc test riêng một phần | Hai module import vòng nhau (dùng forwardRef là dấu hiệu ranh giới sai) |
@Global() chỉ hợp cho thứ thật sự dùng ở mọi nơi (config, logger). Tài liệu Nest nói rõ biến mọi thứ thành global “is not a recommended design practice”.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Checklist khi review một module
Phần tiêu đề “Checklist khi review một module”- Controller chỉ nhận và trả HTTP; SQL, transaction và tích hợp nằm trong provider.
- Provider dùng ở module khác có đủ ba thứ:
providersvàexportsở nguồn,importsở đích. - Chỉ export API công khai nhỏ; client và config nội bộ giữ không export.
@Global()chỉ cho thứ thật sự dùng ở mọi nơi (config, logger).- Không cất state của request (
currentUser) trong field của provider scope mặc định. - Module chia theo ranh giới nghiệp vụ, không mỗi endpoint một module; import vòng cần
forwardReflà dấu hiệu ranh giới sai.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) OrdersController báo thiếu OrdersService: kiểm tra những trường nào của module? (2) Vì sao không cất currentUser trong provider mặc định? (3) OrdersModule có chạy lại mỗi request không?
Chạy các ví dụ:
npx --prefix examples ts-node examples/src/nestjs-architecture/exports-demo.tsnpx --prefix examples ts-node examples/src/nestjs-architecture/main.ts# terminal khác:curl -i http://127.0.0.1:3002/orders/42