Decoupling NestJS Logic with Custom Providers
Stop hard-coding your service dependencies. Learn how to use NestJS Custom Providers (useClass, useValue, and useFactory) to build a decoupled architecture that is easy to test and configure.
01 Oct 2026, 03:57 UTC

The Tight Coupling Trap
In many NestJS projects, it is common to inject services directly by their class name: constructor(private readonly userService: UserService) {}. While this is fast for prototyping, it creates a hard dependency. Your controller is no longer just asking for 'something that can handle users'; it is asking specifically for the UserService class. If you ever need to swap that implementation for a different database, a third-party API, or a mock for testing, you have to touch every file where that service is injected.
The solution is to move from class-based injection to Custom Providers. By using an abstraction token, you decouple the requirement (the interface) from the implementation (the class).
Choosing the Right Provider Strategy
NestJS provides several ways to define how a dependency is instantiated. Choosing the right one depends on whether your dependency is static, dynamic, or external.
useClass: The Implementation Swap
Use useClass when you have multiple implementations of the same interface. You define a unique token (usually a string or symbol) and tell Nest which class to instantiate for that token. This is ideal for switching between a LocalFileStorageService and an S3FileStorageService based on the environment.
useValue: Constant and External Dependencies
useValue is best for static configurations, external library instances, or mock objects. Since it doesn't instantiate a class, it is highly efficient for values that never change during the application lifecycle.
useFactory: Dynamic Logic
When a provider needs logic to be created—such as reading an environment variable or waiting for a database connection—useFactory is the correct choice. It allows you to inject other providers into the factory function to determine the final returned value.
Worked Example: Swappable Notification Systems
Imagine an application that sends notifications. In development, you want to log notifications to the console; in production, you want to use an external API like SendGrid.
First, define a token to represent the notification service:
// constants.ts
export const NOTIFICATION_SERVICE_TOKEN = 'NOTIFICATION_SERVICE';
Next, create the implementations. Note that in TypeScript, interfaces are erased at runtime, so we use the token for injection:
// logger-notification.service.ts
export class LoggerNotificationService {
send(msg: string) { console.log(`[Dev Log]: ${msg}`); }
}
// email-notification.service.ts
export class EmailNotificationService {
send(msg: string) { /* API call to SendGrid */ }
}
Now, configure the provider in your module using a factory to decide which class to use based on the environment:
// app.module.ts
import { Module } from '@nestjs/core';
import { NOTIFICATION_SERVICE_TOKEN } from './constants';
import { LoggerNotificationService } from './logger-notification.service';
import { EmailNotificationService } from './email-notification.service';
@Module({
providers: [
{
provide: NOTIFICATION_SERVICE_TOKEN,
useFactory: () => {
return process.env.NODE_ENV === 'production'
? new EmailNotificationService()
: new LoggerNotificationService();
},
},
],
})
export class AppModule {}
Finally, inject the service using the @Inject() decorator in your controller or service:
// app.controller.ts
import { Controller, Get, Inject } from '@nestjs/common';
import { NOTIFICATION_SERVICE_TOKEN } from './constants';
@Controller()
export class AppController {
constructor(
@Inject(NOTIFICATION_SERVICE_TOKEN) private readonly notificationService: any
) {}
@Get('notify')
sendNotify() {
this.notificationService.send('Hello World!');
}
}
Trade-offs and Limitations
While custom providers increase flexibility, they introduce a few challenges:
- Loss of Type Safety: Because you are injecting a token rather than a class, TypeScript doesn't automatically know the type of the injected object. You should manually type the injected property (e.g.,
private readonly notificationService: INotificationService) to maintain IDE support. - Boilerplate: You must manage tokens and
@Inject()decorators, which is more verbose than standard class injection. - Circular Dependencies: When using factories that depend on other providers, you may encounter circular dependencies. If Provider A needs B, and B needs A, you must use
forwardRef()to resolve the loop.
Verifying the Implementation
To verify that your decoupling is working, you can perform a simple runtime check or a unit test:
- Runtime Check: Change your
NODE_ENVvariable and restart the server. Observe whether the console log (Dev) or the API call (Prod) is triggered. - Unit Test: In your test suite, use
useValueto provide a mock object for theNOTIFICATION_SERVICE_TOKEN. If the controller executes without requiring the actualEmailNotificationServiceclass, your decoupling is successful.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.