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

NestJS Dependency Injection: token, provider, useClass vs useExisting Trung cấp

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

CheckoutService cần gọi cổng thanh toán. Nếu nó tự new PaymentGateway(), mỗi test phải dựng gateway thật, và đổi gateway nghĩa là sửa cả luồng checkout. DI đảo chiều việc này: module đăng ký provider dưới một token, service chỉ yêu cầu token.

DI không cứu một thiết kế thiếu ranh giới. Nó chỉ làm rõ ai cung cấp dependency nào và instance sống bao lâu. Bài này nói về cơ chế của container. Cách thiết kế port và adapter cho cổng thanh toán nằm ở Adapter pattern.

Khái niệm Là gì Ví dụ
Token Khoá runtime để tìm provider: class, string hoặc symbol PAYMENT_PORT = Symbol('PAYMENT_PORT')
Provider Cách tạo giá trị cho token: useClass, useValue, useFactory, useExisting { provide: PAYMENT_PORT, useClass: DemoGateway }
Module Phạm vi nhìn thấy: provider chỉ thấy được trong module khai báo, trừ khi export exports: [PAYMENT_PORT]
Scope Vòng đời instance: DEFAULT, REQUEST, TRANSIENT Xem provider scopes

TypeScript interface và type biến mất sau compile, nên không dùng làm token được. Dùng class, symbol hoặc string với @Inject(token); symbol an toàn hơn string vì không trùng tên giữa các module.

Code thật của di-demo.ts:

const PAYMENT_PORT = Symbol('PAYMENT_PORT');
type PaymentPort = { charge(orderId: number): Promise<string> };
@Injectable()
class CheckoutService {
constructor(@Inject(PAYMENT_PORT) private readonly payments: PaymentPort) {}
checkout(orderId: number) { return this.payments.charge(orderId); }
}
@Module({
providers: [CheckoutService, { provide: PAYMENT_PORT, useClass: DemoPaymentGateway }],
})
class CheckoutModule {}

Đang tải diagram…

Khi bootstrap, Nest quét graph module, đăng ký provider rồi resolve constructor theo token. Chạy demo in payment-for-42 và same default-scoped instance: true: hai lần app.get trả cùng instance vì scope mặc định. Instance chỉ dùng chung trong một application context; 10 replica là 10 instance, nên Map trong provider không phải shared state giữa replica.

Hai cách này hay bị nhầm. Cùng đăng ký AuditLog làm provider, rồi tạo thêm một token thứ hai trỏ về nó:

Cấu hình Số instance Kết quả đo (Nest 11)
Trước { provide: 'AUDIT_CLASS', useClass: AuditLog } 2 (mỗi binding tự tạo) app.get(AuditLog) === app.get('AUDIT_CLASS') là false
Sau { provide: 'AUDIT_ALIAS', useExisting: AuditLog } 1 (alias) true
// Muốn hai token cùng một instance (cùng connection, cùng state): dùng useExisting.
providers: [AuditLog, { provide: 'AUDIT_ALIAS', useExisting: AuditLog }]

Dùng useClass khi muốn chọn class theo môi trường (ví dụ fake cho test, adapter thật cho production). Dùng useExisting khi muốn alias. Nếu singleton giữ connection hay state quan trọng, hãy kiểm tra identity thay vì suy ra từ tên class.

Tất cả chạy trong providers-demo.ts; output thật ở dưới.

Nhu cầu Cách Lưu ý
Giá trị tĩnh, fake cho test useValue Không hard-code secret trong module
Khởi tạo cần config hoặc bất đồng bộ useFactory (có thể async) với inject: [...] Nest chờ factory xong rồi mới tạo class phụ thuộc
Dependency có thể vắng @Optional() hoặc { token, optional: true } trong inject Thiếu provider thì nhận undefined, không lỗi lúc khởi động
Module cần cấu hình lúc import Dynamic module: static forRoot(options) trả về DynamicModule Quy ước của Nest: forRoot cấu hình một lần, register cho module gọi, forFeature dùng cấu hình của forRoot và chỉnh một phần cho module gọi
Thứ dùng ở mọi nơi (config, logger) @Global() Chỉ cho thứ thật sự toàn cục; lạm dụng làm mất ranh giới
@Optional thiếu provider -> options = undefined
Async provider: DB bắt đầu kết nối -> DB kết nối xong -> Repo được tạo
Dynamic module -> config = { region: 'ap-southeast-1' }
// async provider
{
provide: 'DB',
useFactory: async () => await connect(),
}
// dynamic module
@Module({})
class ConfigModule {
static forRoot(options: { region: string }): DynamicModule {
return {
module: ConfigModule,
providers: [{ provide: 'CONFIG', useValue: options }],
exports: ['CONFIG'],
};
}
}

Provider tuỳ chỉnh cũng bị giới hạn trong module khai báo. Muốn module khác dùng, exports bằng token (exports: ['CONFIG']) hoặc bằng chính provider object. Factory chỉ nên khởi tạo; connection cần đóng thì dùng lifecycle hook khi shutdown.

Hai thử nghiệm để thấy lỗi cụ thể: xoá binding { provide: PAYMENT_PORT, ... } thì container không resolve được CheckoutService; đổi @Inject sang Symbol('PAYMENT_PORT') mới cũng lỗi vì hai symbol khác nhau dù cùng nhãn. Với test, Test.createTestingModule() kèm overrideProvider() thay provider mà không sửa source.

  1. Dùng symbol hoặc class làm token; không dùng string tự do trùng tên giữa module.
  2. Mỗi module export tối thiểu cần thiết; @Global() chỉ cho config và logger.
  3. Dùng useExisting cho alias, và kiểm tra identity khi singleton giữ state hay connection.
  4. Provider async/factory chỉ khởi tạo; đóng connection ở lifecycle hook khi shutdown.
  5. Nếu cần forwardRef() ở nhiều chỗ, xem lại ranh giới module thay vì che bằng forwardRef.
  6. Constructor có quá nhiều dependency là tín hiệu nên tách use case, không phải lý do tạo service locator.
  7. Không giữ state nghiệp vụ trong field của provider mặc định khi chạy nhiều replica.

Tự kiểm tra: (1) Vì sao type PaymentPort không đủ để Nest resolve? (2) Hai token cùng trỏ về một class: khi nào có hai instance? (3) PaymentsModule có provider nhưng quên export: module khác thấy gì?

Chạy demo:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-di/di-demo.ts
npx --prefix examples ts-node examples/src/nestjs-di/providers-demo.ts