Skip to content
Knowledge Hub

NestJS Dependency Injection: tokens, providers, useClass vs useExisting Intermediate

Estimated reading time: 7 min read

CheckoutService needs to call a payment gateway. If it does new PaymentGateway() itself, every test has to build a real gateway, and switching gateways means editing the whole checkout flow. DI inverts this: a module registers a provider under a token, and the service only asks for the token.

DI does not rescue a design that lacks boundaries. It only makes clear who supplies which dependency and how long an instance lives. This article covers the container’s mechanics. How to design the port and adapter for a payment gateway is in Adapter pattern.

Concept What it is Example
Token The runtime key used to look up a provider: a class, string or symbol PAYMENT_PORT = Symbol('PAYMENT_PORT')
Provider How the value for a token is created: useClass, useValue, useFactory, useExisting { provide: PAYMENT_PORT, useClass: DemoGateway }
Module Visibility scope: a provider is visible only in the module that declares it, unless exported exports: [PAYMENT_PORT]
Scope Instance lifetime: DEFAULT, REQUEST, TRANSIENT See provider scopes

TypeScript interface and type disappear after compilation, so they cannot be tokens. Use a class, symbol or string with @Inject(token); a symbol is safer than a string because names cannot collide across modules.

The real code from 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 {}

Loading diagram…

At bootstrap, Nest scans the module graph, registers providers, then resolves constructors by token. Running the demo prints payment-for-42 and same default-scoped instance: true: two app.get calls return the same instance because of the default scope.

The instance is shared only within one application context; 10 replicas means 10 instances, so a Map inside a provider is not shared state across replicas.

These two are often confused. Register AuditLog as a provider, then create a second token pointing at it:

Configuration Instances Measured result (Nest 11)
Before { provide: 'AUDIT_CLASS', useClass: AuditLog } 2 (each binding creates its own) app.get(AuditLog) === app.get('AUDIT_CLASS') is false
After { provide: 'AUDIT_ALIAS', useExisting: AuditLog } 1 (alias) true
// Want two tokens on one instance (same connection, same state): use useExisting.
providers: [AuditLog, { provide: 'AUDIT_ALIAS', useExisting: AuditLog }]

Use useClass to choose a class by environment (for example a fake for tests, the real adapter for production). Use useExisting when you want an alias. If a singleton holds a connection or important state, check identity instead of inferring it from the class name.

All of these run in providers-demo.ts; the real output is below.

Need How Note
Static value, test fake useValue Don’t hard-code secrets in a module
Initialization needs config or is asynchronous useFactory (can be async) with inject: [...] Nest waits for the factory before creating dependent classes
A dependency may be absent @Optional() or { token, optional: true } in inject A missing provider yields undefined, no startup error
A module needs configuration at import time Dynamic module: static forRoot(options) returns a DynamicModule Nest convention: forRoot configures once, register is per calling module, forFeature uses the forRoot configuration and tweaks part of it for the calling module
Something used everywhere (config, logger) @Global() Only for truly global things; overuse erases boundaries
@Optional missing provider -> options = undefined
Async provider: DB starts connecting -> DB connected -> Repo created
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'],
};
}
}

Custom providers are also confined to the declaring module. For another module to use one, exports it by token (exports: ['CONFIG']) or by the provider object itself. A factory should only initialize; connections that need closing belong in a lifecycle hook at shutdown.

Two experiments that surface concrete errors: delete the { provide: PAYMENT_PORT, ... } binding and the container cannot resolve CheckoutService; change @Inject to a new Symbol('PAYMENT_PORT') and it also fails, because the two symbols differ despite the same label.

For tests, Test.createTestingModule() with overrideProvider() swaps a provider without editing the source.

  1. Use symbols or classes as tokens; avoid free-form strings that can collide across modules.
  2. Each module exports the minimum necessary; @Global() only for config and logger.
  3. Use useExisting for aliases, and check identity when a singleton holds state or a connection.
  4. Async/factory providers only initialize; close connections in a lifecycle hook at shutdown.
  5. If you need forwardRef() in many places, revisit module boundaries instead of papering over them with forwardRef.
  6. A constructor with too many dependencies is a signal to split the use case, not a reason to build a service locator.
  7. Don’t keep business state in fields of default-scoped providers when running multiple replicas.

Self-check: (1) Why isn’t type PaymentPort enough for Nest to resolve? (2) Two tokens point to the same class: when are there two instances? (3) PaymentsModule has the provider but forgot to export it: what do other modules see?

Run the demos:

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