Skip to content

Providers and Custom Providers

Aman Kumar Singh8 min read
Part 3 of 40From the NestJS Production Guide series
Providers and Custom Providers — article by Aman Kumar Singh

In Modules and Dependency Injection we covered how modules group related functionality and how the Nest injector resolves dependencies between them. That article treated providers mostly as a given: you decorate a class with @Injectable(), list it in a module's providers array, and it shows up wherever you ask for it. That's true, and it covers most of what you'll write day to day. It's also only the simplest of several ways Nest lets you register something in its container.

This is part of the NestJS Production Guide, and providers are worth a dedicated stop. So much of what makes a Nest app testable, configurable, and swappable at runtime comes down to how you define providers, more than which class happens to implement the logic. A service that reads a database connection string, a factory that swaps out an SMS gateway for a mock in tests, a feature flag that changes which payment processor gets injected: all of that runs through the same provider system, once you know it goes beyond @Injectable().

The goal here is to know which one to reach for. Just as important, know when the default useClass shorthand already does the job, so a custom provider doesn't end up solving a problem you don't have yet.

What a provider actually is

A provider, in Nest's terms, is anything registered with the dependency injection container under a token, and returned when something asks the container to resolve that token. Most providers are classes: you write a UsersService, decorate it @Injectable(), add it to a module's providers array, and Nest treats the class itself as the token. Ask for UsersService in a constructor and you get an instance managed by the container, its own dependencies already resolved.

That "class is the token" pattern is shorthand for a more explicit form:

// users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
})
export class UsersModule {}

is equivalent to:

@Module({
  providers: [
    {
      provide: UsersService,
      useClass: UsersService,
    },
  ],
})
export class UsersModule {}

The expanded form matters because it reveals the shape every provider follows: a provide token and a strategy for producing the value behind it. useClass is one strategy among several. Once you see the object form, useValue, useFactory, and useExisting stop looking like special cases and start looking like the same mechanism with a different production rule.

Value, factory, and existing providers

useValue binds a token directly to a value you've already constructed, no instantiation involved. This is the right tool for constants, configuration objects, or a mock you want to inject during testing without going through a factory.

// config/app-config.provider.ts
export const APP_CONFIG = Symbol('APP_CONFIG');

export interface AppConfig {
  port: number;
  jwtSecret: string;
  redisUrl: string;
}

export const appConfigProvider = {
  provide: APP_CONFIG,
  useValue: {
    port: Number(process.env.PORT ?? 3000),
    jwtSecret: process.env.JWT_SECRET ?? '',
    redisUrl: process.env.REDIS_URL ?? 'redis://localhost:6379',
  },
};

Note the Symbol used as the token instead of a class. When there's no class to provide, a plain string or symbol works as the identifier; a symbol avoids accidental collisions with another module's string token of the same name. Inject it with @Inject(APP_CONFIG) since there's no class for Nest to infer the token from automatically.

useFactory runs a function to produce the value, and that function can itself declare dependencies via the inject array, resolved from the container before the factory runs. This is what you reach for when a provider's construction depends on something async, on another provider's value, or on branching logic.

// sms/sms-gateway.provider.ts
import { Provider } from '@nestjs/common';
import { TwilioGateway } from './twilio.gateway';
import { MockSmsGateway } from './mock-sms.gateway';
import { APP_CONFIG, AppConfig } from '../config/app-config.provider';

export const SMS_GATEWAY = Symbol('SMS_GATEWAY');

export const smsGatewayProvider: Provider = {
  provide: SMS_GATEWAY,
  useFactory: (config: AppConfig) => {
    return config.port === 3000 && process.env.NODE_ENV !== 'production'
      ? new MockSmsGateway()
      : new TwilioGateway(process.env.TWILIO_SID, process.env.TWILIO_TOKEN);
  },
  inject: [APP_CONFIG],
};

useExisting creates an alias for a token that already exists, rather than a second instance behind a second token. It's the least commonly needed of the four. But it earns its place when you're renaming a provider across a large codebase and need both the old and new token to resolve to the same instance during the migration, or when two modules expect different injection tokens for what is genuinely the same underlying service.

Custom providers as an abstraction boundary

The pattern that gets the most mileage out of custom providers is coding against an interface (or, since TypeScript interfaces don't exist at runtime, an abstract class or an injection token) instead of a concrete implementation.

// payments/payment-processor.interface.ts
export interface PaymentProcessor {
  charge(amountCents: number, customerId: string): Promise<{ transactionId: string }>;
}

export const PAYMENT_PROCESSOR = Symbol('PAYMENT_PROCESSOR');
// payments/payments.module.ts
import { Module } from '@nestjs/common';
import { PAYMENT_PROCESSOR } from './payment-processor.interface';
import { StripeProcessor } from './stripe-processor.service';
import { CheckoutService } from './checkout.service';

@Module({
  providers: [
    StripeProcessor,
    {
      provide: PAYMENT_PROCESSOR,
      useClass: StripeProcessor,
    },
    CheckoutService,
  ],
})
export class PaymentsModule {}

CheckoutService depends on PAYMENT_PROCESSOR, never on StripeProcessor directly:

// payments/checkout.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { PAYMENT_PROCESSOR, PaymentProcessor } from './payment-processor.interface';

@Injectable()
export class CheckoutService {
  constructor(
    @Inject(PAYMENT_PROCESSOR) private readonly processor: PaymentProcessor,
  ) {}

  async completeCheckout(amountCents: number, customerId: string) {
    return this.processor.charge(amountCents, customerId);
  }
}

Switching payment providers, or adding a second one behind a factory that picks based on tenant configuration, means changing the one binding in PaymentsModule. CheckoutService never changes, and neither does any test that mocks PAYMENT_PROCESSOR with a useValue stub.

But be honest about the cost here: it's an abstraction, and abstractions cost something even when they're the right call. If StripeProcessor is the only processor you have, will ever have, and there's no realistic second implementation on the roadmap, the interface and the token are ceremony without payoff. Introduce the abstraction when you have a real second implementation, a testing need that a useClass binding can't satisfy cleanly, or a strong signal (a second payment provider is genuinely coming) that the second implementation is close. Adding it speculatively, on the theory that you might swap Stripe out someday, is exactly the kind of complexity this series tries to defer until it's earned.

Provider scope and its cost

By default, every provider in Nest is a singleton: one instance per application, shared across every request and every module that injects it. That default is almost always right. It still helps to understand what changes it.

Scope.REQUEST creates a new instance of the provider for every incoming request, which matters when a provider needs to carry request-specific state, a tenant ID resolved from a JWT, for example, without threading it through every method signature. The cost is real: a request-scoped provider forces every provider that depends on it, directly or transitively, to also become request-scoped, since a singleton can't hold a reference to something that gets recreated per request. In a service with a few layers of dependencies, that can mean a much larger portion of the graph re-instantiates on every request than the one provider that actually needed it to.

// tenant/tenant-context.service.ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class TenantContextService {
  constructor(@Inject(REQUEST) private readonly request: Request) {}

  getTenantId(): string {
    return this.request.headers['x-tenant-id'] as string;
  }
}

Scope.TRANSIENT gives every injecting class its own instance, without tying that instance to the request lifecycle. This is the right scope for something like a per-context logger (the pattern used for AppLogger in the custom logger article in this series), where each consumer needs its own state but that state has nothing to do with an HTTP request.

Before reaching for Scope.REQUEST, check whether AsyncLocalStorage (via nestjs-cls or a similar library) solves the same problem without the instantiation cost. It gives you request-scoped data readable from anywhere in the call chain, backed by a singleton provider that reads from storage rather than one instance created fresh per request. For most tenant-context and correlation-ID use cases, that's the cheaper path to the same result.

Production pitfalls

Overusing tokens for things that don't need them. Not every provider needs a custom token and an interface. If a service has one implementation, will keep having one implementation, and nothing about testing it requires swapping the concrete class, useClass shorthand (or no explicit provider entry at all) is the right amount of structure. Reach for a token when there's a real reason to swap the implementation, not by default.

Circular provider dependencies. Two providers that inject each other directly will fail to resolve, and the error message doesn't always point clearly at which pair is the problem in a large module graph. forwardRef() resolves this mechanically, but its presence in a codebase is usually a signal that two responsibilities that should be separated are instead entangled. Treat it as a prompt to reconsider the boundary rather than a fix to apply and move past.

Forgetting to export a custom provider's token. A provider registered in one module isn't visible to another module unless its module both provides it and lists the token in exports. This trips people up specifically with custom tokens, since it's easy to export the class (StripeProcessor) while forgetting to also export the symbol token (PAYMENT_PROCESSOR) that other modules actually inject.

Request-scoped providers spreading further than intended. Because request scope propagates up through the dependency graph, adding Scope.REQUEST to a provider that sits low in that graph, one many services depend on, can quietly make a large part of the app request-scoped. Before changing a provider's scope, check the full chain of what depends on it, not only its direct dependents.

Key takeaways

  • Every provider follows the same `provide` and production-strategy shape; `useClass` is shorthand for the most common case, not a separate mechanism from `useValue`, `useFactory`, and `useExisting`.
  • Use `useValue` for constants and pre-built values, `useFactory` when construction depends on other providers or runtime logic, and `useExisting` for aliasing an existing token, which is rare in practice.
  • Code against an interface and a custom injection token when you have a real second implementation or a genuine testing need; skip the abstraction when there's only ever going to be one implementation.
  • Provider scope defaults to a singleton for good reason. `Scope.REQUEST` is powerful but propagates through the whole dependency graph, so check what depends on a provider before changing its scope.
  • `AsyncLocalStorage`-based context often replaces the need for `Scope.REQUEST` in tenant-context and correlation-ID scenarios, at a lower instantiation cost.
  • Exporting a custom provider means exporting its token, not just the class behind it; forgetting the token is the most common reason a custom provider "isn't visible" in another module.

Frequently asked questions

What's the difference between a provider and a service in NestJS?

"Service" is a naming convention for a specific kind of class, usually one holding business logic. "Provider" is the framework concept: anything registered in the DI container under a token. Every service is typically a provider, but providers also include repositories, factories, configuration objects, and anything else registered through `providers` in a module.

When should I use a custom injection token instead of just injecting the class?

When you need to swap the implementation behind an interface (multiple payment processors, a mock during tests, an environment-dependent choice) without changing the code that consumes it. If there's only one implementation and no swapping scenario in sight, injecting the class directly is simpler and just as correct.

Why does Nest need `@Inject()` for some providers but not others?

Nest can infer the token automatically when you inject a class by its own type, since the class itself is the token. Custom tokens (strings or symbols, as with `useValue` and most `useFactory` bindings) have no type to infer from, so `@Inject(TOKEN)` tells the container explicitly which binding to resolve.

Does using Scope.REQUEST hurt performance?

It adds per-request instantiation cost for that provider and everything depending on it, since singletons can't hold references to request-scoped instances. For most APIs that cost stays small next to the rest of the request lifecycle, but it's still worth checking whether `AsyncLocalStorage`-based context achieves the same goal without changing scope at all.

Can a factory provider depend on another factory provider?

Yes. The `inject` array in `useFactory` can reference any resolvable token, including one produced by another factory provider, and Nest resolves the chain in dependency order before your factory runs.

Related articles

Logging with a Custom Logger — Aman Kumar Singh
Exception Filters and Error Handling — Aman Kumar Singh
Custom Pipes and Transformation — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.