Bỏ qua để đến nội dung
Knowledge Hub

Kiến trúc NestJS: module, provider và controller Cơ bản

Thời gian đọc ước tính: 7 phút

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ủa new: 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.

Đâ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.

orders.service.ts
@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).

Từ module lúc bootstrap đến controller và provider lúc nhận request
Từ module lúc bootstrap đến controller và provider lúc nhận requestBước 1/5: Khi bootstrap, module khai báo controller, provider và import/export. Nest dựng dependency graph và tạo provider theo scope.HTTP clientControllerProviderData source
Controller · Provider
Bước 1/5

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.

Khi OrdersService cần PaymentsService của module khác, ba điều kiện phải cùng đúng:

  1. PaymentsService nằm trong providers của PaymentsModule.
  2. PaymentsModule ghi exports: [PaymentsService].
  3. OrdersModule ghi imports: [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-42

Cá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().

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”.

  1. Controller chỉ nhận và trả HTTP; SQL, transaction và tích hợp nằm trong provider.
  2. Provider dùng ở module khác có đủ ba thứ: providers và exports ở nguồn, imports ở đích.
  3. Chỉ export API công khai nhỏ; client và config nội bộ giữ không export.
  4. @Global() chỉ cho thứ thật sự dùng ở mọi nơi (config, logger).
  5. Không cất state của request (currentUser) trong field của provider scope mặc định.
  6. Module chia theo ranh giới nghiệp vụ, không mỗi endpoint một module; import vòng cần forwardRef là dấu hiệu ranh giới sai.

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ụ:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-architecture/exports-demo.ts
npx --prefix examples ts-node examples/src/nestjs-architecture/main.ts
# terminal khác:
curl -i http://127.0.0.1:3002/orders/42