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

Dynamic module trong NestJS: forRoot, forRootAsync và vì sao import hai lần thành hai instance Trung cấp

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

Team viết PaymentModule bọc cổng thanh toán, dùng lại cho hai app: shop và admin. Mỗi app có API key và base URL riêng (sk_live_shop_abc với …/shop, sk_live_admin_xyz với …/admin). Key không được hard-code: app đọc từ ConfigService lúc khởi động.

Module tĩnh không nhận tham số. Cần module nhận cấu hình lúc import, chờ config sẵn sàng rồi mới tạo PaymentClient.

Câu hỏi: viết module nhận cấu hình thế nào, đặt tên method ra sao, và vì sao gọi forRoot() ở hai chỗ lại sinh hai PaymentClient?

Tình huống Chọn Điều kiện
Cấu hình biết sẵn (test, giá trị cố định) PaymentModule.forRoot({ apiKey, baseUrl }) Không đọc secret trực tiếp từ code
Cấu hình đến từ ConfigService, secret manager forRootAsync({ imports, inject, useFactory }) imports chứa module export provider được inject (trừ provider global)
Viết module cấu hình được, ít boilerplate ConfigurableModuleBuilder Module tự khai báo exports cho provider công khai
Cả app dùng một client Gọi forRoot một lần ở AppModule, isGlobal: true hoặc export lại qua một module chung Không gọi forRoot lại ở feature module
Mỗi module cần cấu hình riêng register() ở từng module Chấp nhận mỗi lần import là một instance riêng

Static module khai báo metadata cố định trong @Module(). Dynamic module là một object kiểu DynamicModule do một static method trả về; Nest coi object đó như metadata bổ sung cho class module, rồi DI container tạo provider như bình thường.

Thuộc tính Ý nghĩa Trong PaymentModule
module Class module được mở rộng (bắt buộc) PaymentModule
providers Provider sinh theo tham số Token options + PaymentClient
exports Provider module khác được thấy PaymentClient
imports Module mà provider bên trong cần ConfigModule (bản async)
global Đăng ký ở global scope, không cần import lại Chỉ khi cố ý

Bản viết tay ngắn nhất, chạy trong demo:

@Module({})
class ManualPaymentModule {
static forRoot(options: PaymentOptions): DynamicModule {
return {
module: ManualPaymentModule,
providers: [{ provide: 'MANUAL_PAYMENT_OPTIONS', useValue: options }],
exports: ['MANUAL_PAYMENT_OPTIONS'],
};
}
}

Tên method là quy ước: docs ghi “không có luật cứng”, nhưng các package @nestjs/* theo hướng dẫn sau, mỗi method thường kèm bản …Async():

Method Theo docs Ví dụ
register() Cấu hình cho riêng module gọi nó; mỗi chỗ import có thể khác nhau HTTP client với base URL theo feature
forRoot() Cấu hình một lần, dùng lại ở nhiều nơi Kết nối database, PaymentModule của app
forFeature() Dùng cấu hình của forRoot(), chỉnh phần riêng cho module gọi Repository của từng entity

forRootAsync không tạo gì ngay. Nó trả object chứa một factory provider cho token options.

forRootAsync: factory chờ ConfigService, options vào token, rồi PaymentClient mới được tạo
forRootAsync: factory chờ ConfigService, options vào token, rồi PaymentClient mới được tạoBước 1/6: AppModule gọi PaymentModule.forRootAsync(). Hàm chỉ trả một object DynamicModule; chưa provider nào được tạo.PaymentModule(instance doforRootAsync tạo)AppModuleapp shopforRootAsync()trả DynamicModuleConfigServiceexport từ ConfigModuleuseFactoryinject: [ConfigService]PAYMENT_MODULE_OPTIONS{ apiKey, baseUrl }PaymentClient@Inject(token)imports
AppModule → forRootAsync()imports
Bước 1/6

AppModule gọi PaymentModule.forRootAsync(). Hàm chỉ trả một object DynamicModule; chưa provider nào được tạo.

  1. Import: forRootAsync() chạy khi decorator của AppModule được đánh giá, trả { module, imports, providers }. Chưa có instance nào.
  2. Compile: PaymentModule import ConfigModule và có provider PAYMENT_MODULE_OPTIONS (useFactory, inject: [ConfigService]).
  3. Chờ dependency: factory cần ConfigService. PaymentModule chỉ thấy provider của chính nó và export của module nó import.
  4. Resolve options: factory có thể async; Nest chờ promise xong rồi gán giá trị cho token.
  5. Tạo client: PaymentClient nhận options qua @Inject(PAYMENT_MODULE_OPTIONS). Bootstrap xong.

Vì sao phải imports: [ConfigModule]? Factory provider thuộc PaymentModule, không thuộc AppModule. AppModule import ConfigModule không làm PaymentModule thấy ConfigService. Ngoại lệ: ConfigModule.forRoot({ isGlobal: true }) của @nestjs/config đăng ký global, khi đó không cần import lại.

ConfigurableModuleBuilder (trong @nestjs/common) sinh boilerplate:

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN: PAYMENT_OPTIONS } =
new ConfigurableModuleBuilder<PaymentOptions>({ moduleName: 'Payment' })
.setClassMethodName('forRoot')
.setExtras({ isGlobal: false }, (definition, extras) => ({
...definition,
global: extras.isGlobal,
}))
.build();
@Injectable()
export class PaymentClient {
constructor(@Inject(PAYMENT_OPTIONS) readonly options: PaymentOptions) {}
}
@Module({ providers: [PaymentClient], exports: [PaymentClient] })
export class PaymentModule extends ConfigurableModuleClass {}
  • Mặc định class sinh ra có register() và registerAsync(). setClassMethodName('forRoot') đổi thành forRoot() và forRootAsync().
  • MODULE_OPTIONS_TOKEN: có moduleName: 'Payment' thì là string PAYMENT_MODULE_OPTIONS, không có thì là chuỗi ngẫu nhiên. Luôn import hằng số.
  • Bản async nhận useFactory + inject, hoặc useClass/useExisting với method create() (đổi bằng setFactoryMethodName).
  • setExtras thêm tham số không thuộc options. Theo source 11.2.6, isGlobal bị loại khỏi giá trị inject qua PAYMENT_OPTIONS; chỉ hàm transform nhận nó.
  • Builder không tự thêm exports. Provider công khai phải nằm trong exports của @Module() trên class kế thừa.
  • Nest 11 định danh dynamic module theo tham chiếu object (moduleIdGeneratorAlgorithm mặc định 'reference'). Mỗi lần gọi forRoot() trả object mới, nên là một module mới.

Demo ở examples/src/nestjs-dynamic-modules/ (NestJS 11.2.6). @nestjs/config không có trong examples, nên config.module.ts là bản tối giản: ConfigService đọc process.env, được ConfigModule export.

PaymentModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService): Promise<PaymentOptions> => ({
apiKey: config.getOrThrow('PAYMENT_API_KEY'),
baseUrl: config.getOrThrow('PAYMENT_BASE_URL'),
timeoutMs: Number(config.get('PAYMENT_TIMEOUT_MS') ?? 3000),
}),
})
Terminal window
npx --prefix examples ts-node examples/src/nestjs-dynamic-modules/main.ts

Output thật (object in trên một dòng, lỗi xuống dòng cho dễ đọc):

[2] shop: factory chạy, ConfigService đã sẵn sàng -> PaymentClient được tạo
options = { apiKey: 'sk_live_shop_abc', baseUrl: 'https://pay.example.test/shop', timeoutMs: 3000 }
[2] admin: factory chạy, ConfigService đã sẵn sàng -> PaymentClient được tạo
options = { apiKey: 'sk_live_admin_xyz', baseUrl: 'https://pay.example.test/admin', timeoutMs: 3000 }
[3] quên imports: [ConfigModule] -> Nest can't resolve dependencies of the PAYMENT_MODULE_OPTIONS (?).
Please make sure that the argument ConfigService at index [0] is available in the PaymentModule module.
[4] quên exports -> Nest can't resolve dependencies of the CheckoutService (?).
Please make sure that the argument "PAYMENT_CLIENT" at index [0] is available in the NoExportApp module.
[5a] forRoot() gọi 2 lần: PaymentClient tạo ra = 2, orders === refunds: false
[5b] dùng chung một const: PaymentClient tạo ra = 1, orders === refunds: true
[5c] gọi 2 lần + moduleIdGeneratorAlgorithm: 'deep-hash': PaymentClient tạo ra = 1, orders === refunds: true
[5d] forRoot({ isGlobal: true }) ở AppModule, feature module không import: PaymentClient tạo ra = 1, orders === refunds: true

Ở [5a], OrdersModule và RefundsModule cùng gọi forRoot(OPTS) với cùng options vẫn ra hai client. [5b]–[5d] là ba cách về một instance.

Đang tải diagram…

Hai client nghĩa là hai connection pool, hai bộ đếm rate limit, hai circuit breaker tách rời.

Phần còn thiếu cho production: validate options lúc khởi động (xem validate env), timeout và retry cho HTTP call, idempotency key cho lệnh charge.

Ưu điểm

  • Một module dùng lại cho nhiều app, cấu hình nằm ở chỗ import.

  • forRootAsync cho phép đọc secret, gọi secret manager trước khi tạo client.

  • ConfigurableModuleBuilder sinh sẵn token, method sync/async, kiểu options.

  • < slot="cons">
  • Số instance phụ thuộc số lần gọi method, không nhìn thấy từ tên module.

  • Lỗi DI của factory nằm trong module được sinh, thông báo trỏ tới token khó đọc nếu không đặt moduleName.

  • global: true tiện nhưng che mất dependency giữa các module.

Nhược điểm

  1. Gọi forRoot/forRootAsync một lần ở AppModule (hoặc một module hạ tầng) cho mỗi client dùng chung.
  2. Feature module chỉ import module đã cấu hình hoặc dựa vào global, không gọi lại forRoot.
  3. forRootAsync khai báo đủ imports cho mọi provider trong inject, trừ provider global.
  4. Đặt moduleName cho builder để thông báo lỗi đọc được; khai báo exports cho đúng provider công khai; không export token options nếu feature module không cần.
  5. Validate options trong factory và fail fast; không log nguyên apiKey.
  6. Chỉ bật isGlobal cho module hạ tầng dùng khắp nơi (config, logger), không cho module nghiệp vụ.
  7. Viết test kiểm tra identity: app.get(OrdersService).payments === app.get(RefundsService).payments.

Lỗi đắt nhất là nhân bản provider mà không báo lỗi:

Gọi forRoot ở từng feature module

Hay gặp

@Module({ imports: [PaymentModule.forRoot(OPTS)] }) class OrdersModule {}
@Module({ imports: [PaymentModule.forRoot(OPTS)] }) class RefundsModule {}

Nên viết

@Module({ imports: [PaymentModule.forRoot({ ...OPTS, isGlobal: true }), OrdersModule, RefundsModule] })
class AppModule {}

Vì sao

Nest 11 so dynamic module theo tham chiếu object: hai lần gọi là hai module, hai PaymentClient (demo [5a]). Gọi một lần ở gốc, hoặc dùng chung một hằng số PAYMENT_MODULE = PaymentModule.forRoot(...).

Hai lỗi còn lại thường lộ ngay lúc bootstrap:

Cách hiểu sai Hệ quả Cần làm
“Cùng options thì Nest gộp làm một” Hai client, hai pool, state tách rời Gọi một lần; kiểm tra bằng identity
“AppModule import ConfigModule là đủ cho factory” UnknownDependenciesException ở PaymentModule Thêm imports trong forRootAsync
“global: true cho tiện” Module nào cũng dùng được, không thấy ai phụ thuộc ai Chỉ dùng cho hạ tầng
  1. forRootAsync() được gọi lúc nào, và lúc đó PaymentClient đã tồn tại chưa?
  2. Vì sao lỗi thiếu ConfigService nhắc PaymentModule chứ không phải AppModule?
  3. Hai feature module cùng gọi forRoot(OPTS) với cùng object OPTS. Vì sao vẫn ra hai instance, và ba cách nào đưa về một?