L’injection de dépendances (DI, Dependency Injection) est l’un des piliers de NestJS. Si vous avez déjà vu des classes qui instancient elles-mêmes leurs dépendances (ex. new Service() un peu partout), vous avez probablement aussi rencontré les problèmes associés : code difficile à tester, couplage fort, et maintenance pénible.
Dans ce tutoriel, on va expliquer la DI dans NestJS pas à pas, avec des analogies, puis des exemples de code progressifs : du service injecté le plus simple jusqu’aux custom providers, aux scopes, et aux dépendances circulaires.
Prérequis : bases de TypeScript, notions de classes et décorateurs, et un projet NestJS (v10+ recommandé). Documentation officielle utile : Custom providers et Dependency injection.
Qu’est-ce que l’injection de dépendances (DI) et pourquoi c’est important ?
Une dépendance, c’est tout ce dont une classe a besoin pour fonctionner : un repository, un client HTTP, un logger, un service métier, etc.
Sans DI, on voit souvent ce genre de code :
export class OrdersService {
private readonly payments = new PaymentsService();
async createOrder() {
// ...
return this.payments.charge();
}
}
Ce style pose plusieurs problèmes :
- Couplage fort :
OrdersServicedépend d’une implémentation concrète (PaymentsService), impossible de la remplacer facilement. - Tests difficiles : pour tester
OrdersService, vous êtes obligé d’utiliser le vraiPaymentsService(ou de bricoler). - Configuration dispersée : les paramètres (API keys, options, etc.) finissent par se retrouver partout.
Avec la DI, une classe déclare ce dont elle a besoin, et un système externe (le conteneur) se charge de fournir les bonnes instances.
Analogie : au lieu d’aller “fabriquer” vos outils vous-même à chaque tâche (marteau, tournevis…), vous demandez à un atelier central de vous les fournir. Vous restez concentré sur votre travail, et l’atelier gère l’inventaire.
Le conteneur IoC de NestJS : le “cerveau” qui assemble l’application
NestJS embarque un conteneur IoC (Inversion of Control). Concrètement, c’est un registre qui :
- connaît les providers (services, repositories, clients, etc.) déclarés dans vos modules ;
- instancie ces providers au bon moment ;
- résout automatiquement les dépendances via le constructeur ;
- gère leur cycle de vie (scopes).
Dans NestJS, l’unité d’organisation principale est le module. Un module déclare :
- providers : ce que le module “fabrique” et met à disposition ;
- exports : ce que le module rend disponible aux autres modules ;
- imports : ce qu’il consomme depuis d’autres modules.
Le décorateur @Injectable() : rendre une classe injectable
Dans NestJS, une classe destinée à être gérée par le conteneur est généralement décorée avec @Injectable(). Ce décorateur indique à NestJS : “cette classe peut être instanciée et injectée”.
Exemple simple :
import { Injectable } from '@nestjs/common';
@Injectable()
export class LoggerService {
log(message: string) {
console.log(message);
}
}
Point important : @Injectable() ne suffit pas à lui seul. Il faut aussi que la classe soit déclarée comme provider dans un module.
Injection via le constructeur : comment NestJS résout les dépendances
Le pattern standard dans NestJS est l’injection par constructeur. Vous déclarez les dépendances comme paramètres du constructeur, et NestJS les injecte automatiquement.
Créons un service qui dépend de LoggerService :
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
constructor(private readonly logger: LoggerService) {}
findAll() {
this.logger.log('Fetching users');
return [];
}
}
Et déclarons tout ça dans un module :
import { Module } from '@nestjs/common';
@Module({
providers: [LoggerService, UsersService],
exports: [UsersService],
})
export class UsersModule {}
Comment NestJS “devine” quoi injecter ? Grâce aux métadonnées TypeScript (types des paramètres du constructeur). En résumé : NestJS lit “UsersService a besoin d’un LoggerService”, puis cherche un provider correspondant dans le module (ou dans les modules importés).
Injection dans un contrôleur
Les contrôleurs sont aussi gérés par NestJS, donc ils peuvent recevoir des dépendances :
import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
list() {
return this.usersService.findAll();
}
}
Les scopes de providers : DEFAULT, REQUEST, TRANSIENT
Le scope définit le cycle de vie d’un provider : quand il est instancié, et combien de fois.
- DEFAULT (par défaut) : singleton à l’échelle de l’application (une instance partagée).
- REQUEST : une instance par requête HTTP (utile pour du contexte utilisateur, tracing, etc.).
- TRANSIENT : une nouvelle instance à chaque injection (rare, mais utile pour des objets “jetables”).
Scope DEFAULT (singleton)
Si vous ne précisez rien, c’est le comportement par défaut :
@Injectable()
export class CacheService {
private store = new Map<string, unknown>();
set(key: string, value: unknown) {
this.store.set(key, value);
}
get(key: string) {
return this.store.get(key);
}
}
Piège courant : un singleton conserve son état. Si vous stockez des données de requête dedans (ex. utilisateur courant), vous risquez des fuites entre requêtes.
Scope REQUEST
Pour créer une instance par requête :
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
private correlationId: string | null = null;
setCorrelationId(id: string) {
this.correlationId = id;
}
getCorrelationId() {
return this.correlationId;
}
}
À utiliser avec parcimonie : le scope REQUEST augmente le coût d’instanciation et peut impacter les performances.
Scope TRANSIENT
Pour une nouvelle instance à chaque injection :
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.TRANSIENT })
export class UuidService {
create() {
return crypto.randomUUID();
}
}
Analogie : DEFAULT = une calculatrice partagée sur le bureau, REQUEST = une calculatrice par personne, TRANSIENT = une calculatrice neuve à chaque opération.
Dépendances circulaires : les reconnaître et les éviter (avec forwardRef)
Une dépendance circulaire survient quand :
Adépend deB- et
Bdépend deA
Dans certains cas, c’est un signal de conception : vos responsabilités sont trop entremêlées. La meilleure solution est souvent de refactor (extraire un troisième service, déplacer une logique, introduire un événement, etc.).
Solution NestJS : forwardRef
Quand la circularité est légitime (ou difficile à casser immédiatement), NestJS propose forwardRef pour différer la résolution.
Exemple avec deux modules qui s’importent mutuellement :
import { Module, forwardRef } from '@nestjs/common';
@Module({
imports: [forwardRef(() => PaymentsModule)],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
import { Module, forwardRef } from '@nestjs/common';
@Module({
imports: [forwardRef(() => OrdersModule)],
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}
Et côté services, vous pouvez aussi utiliser forwardRef avec @Inject si nécessaire :
import { Inject, Injectable, forwardRef } from '@nestjs/common';
@Injectable()
export class OrdersService {
constructor(
@Inject(forwardRef(() => PaymentsService))
private readonly paymentsService: PaymentsService,
) {}
}
Piège courant : abuser de forwardRef masque parfois une architecture fragile. Essayez d’abord de clarifier les responsabilités.
Custom providers : useClass, useValue, useFactory
Par défaut, un provider est souvent une classe : providers: [UsersService]. Mais NestJS permet de définir des custom providers pour :
- remplacer une implémentation (ex. mock, variante par environnement) ;
- injecter une valeur de configuration ;
- construire un objet via une factory (ex. client SDK).
useClass : choisir une implémentation
On définit un token (souvent une chaîne ou un symbole) et on mappe vers une classe :
export const PAYMENT_GATEWAY = 'PAYMENT_GATEWAY';
export interface PaymentGateway {
charge(amount: number): Promise<void>;
}
@Injectable()
export class StripeGateway implements PaymentGateway {
async charge(amount: number) {
// appel Stripe...
}
}
@Injectable()
export class FakeGateway implements PaymentGateway {
async charge(amount: number) {
// simulation
}
}
@Module({
providers: [
{
provide: PAYMENT_GATEWAY,
useClass: StripeGateway,
},
PaymentsService,
],
exports: [PaymentsService],
})
export class PaymentsModule {}
Injection via token :
import { Inject, Injectable } from '@nestjs/common';
@Injectable()
export class PaymentsService {
constructor(
@Inject(PAYMENT_GATEWAY)
private readonly gateway: PaymentGateway,
) {}
charge(amount: number) {
return this.gateway.charge(amount);
}
}
useValue : injecter une valeur (config, constante, mock)
Pratique pour injecter une configuration simple :
export const APP_CONFIG = 'APP_CONFIG';
@Module({
providers: [
{
provide: APP_CONFIG,
useValue: {
environment: 'production',
apiTimeoutMs: 2000,
},
},
],
exports: [APP_CONFIG],
})
export class ConfigModule {}
useFactory : construire dynamiquement (avec injection de dépendances)
Quand la création dépend d’autres providers :
export const HTTP_CLIENT = 'HTTP_CLIENT';
@Module({
providers: [
{
provide: HTTP_CLIENT,
inject: [APP_CONFIG],
useFactory: (config: { apiTimeoutMs: number }) => {
return {
timeout: config.apiTimeoutMs,
async get(url: string) {
// impl simplifiée
return fetch(url);
},
};
},
},
],
exports: [HTTP_CLIENT],
})
export class HttpClientModule {}
Conseil : gardez les factories petites. Si la logique grossit, créez une classe dédiée.
Pourquoi la DI rend les tests beaucoup plus simples (mocks et overrides)
Le grand gain de la DI, c’est la testabilité. Comme vos classes reçoivent leurs dépendances, vous pouvez les remplacer par des mocks.
Exemple : tester un service en mockant sa dépendance
Imaginons PaymentsService dépendant d’un PAYMENT_GATEWAY. En test, on injecte un fake :
import { Test } from '@nestjs/testing';
describe('PaymentsService', () => {
it('charges via gateway', async () => {
const gatewayMock = {
charge: jest.fn().mockResolvedValue(undefined),
};
const moduleRef = await Test.createTestingModule({
providers: [
PaymentsService,
{
provide: PAYMENT_GATEWAY,
useValue: gatewayMock,
},
],
}).compile();
const service = moduleRef.get(PaymentsService);
await service.charge(100);
expect(gatewayMock.charge).toHaveBeenCalledWith(100);
});
});
Résultat : test rapide, déterministe, sans appel réseau, sans configuration complexe.
Pièges courants en test
- Mocker trop bas niveau : préférez mocker à la frontière (ex. gateway, repository) plutôt que des méthodes internes.
- Oublier les tokens : si vous injectez via
@Inject(TOKEN), votre test doit fournir ce token. - Scopes REQUEST : ils compliquent les tests (contexte). Utilisez-les seulement si nécessaire.
Récapitulatif mental : comment NestJS “câble” votre application
- Vous déclarez des providers dans un module.
- Vous marquez les classes injectables avec
@Injectable(). - Vous déclarez les dépendances dans le constructeur.
- Le conteneur IoC instancie et injecte selon le scope.
- En cas de circularité, vous pouvez (temporairement) utiliser
forwardRef. - Avec les custom providers, vous remplacez facilement l’implémentation (prod/test/env).
Conclusion
Vous avez vu comment l’injection de dépendances structure une application NestJS : elle réduit le couplage, centralise l’assemblage via le conteneur IoC, et rend vos composants beaucoup plus faciles à tester. En pratique, c’est ce qui permet à NestJS d’être à la fois “opinionated” et flexible : la plupart du temps, l’injection par constructeur suffit, et quand vous avez besoin de plus de contrôle, les custom providers et les scopes prennent le relais.
Pistes pour aller plus loin :
- Approfondir les custom providers et les tokens (Symbol vs string).
- Explorer les guards, interceptors et pipes, qui reposent aussi sur la DI.
- Mettre en place une stratégie de tests (unitaires + e2e) avec overrides de providers.

Commentaires