Qu’est-ce que NestJS ?
NestJS est un framework Node.js progressif qui apporte une structure au JavaScript côté serveur. Là où Express vous fournit des primitives et vous laisse le choix, NestJS vous impose une architecture : modules, controllers, providers, decorators et un conteneur d’injection de dépendances. Il s’inspire fortement d’Angular et des frameworks Java d’entreprise, tout en s’exécutant au-dessus d’Express ou de Fastify.
Cette approche directive (« opinionated ») constitue l’essentiel de sa proposition de valeur. Au sein d’une équipe de cinq personnes développant une API volumineuse, les problèmes les plus complexes sont rarement liés au protocole HTTP. Ils concernent plutôt le nommage, les frontières de modules, les tests et le maintien de patterns cohérents à mesure que la base de code s’étend. NestJS répond à ces enjeux via des conventions que vous pouvez soit accepter, soit remplacer, et il fournit toute l’infrastructure nécessaire — validation, guards, interceptors, configuration, utilitaires de test — pour vous éviter d’avoir à tout assembler vous-même.
Les modules définissent les frontières
Tout dans NestJS est organisé en modules. Un module est une classe décorée avec @Module qui déclare ses controllers, ses providers et ce qu’il exporte vers le reste de l’application.
@Module({
imports: [DatabaseModule],
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService],
})
export class CatsModule {}
imports permet d’importer d’autres modules dont vous avez besoin des providers exportés. providers sont les classes dont ce module est propriétaire. exports constitue l’API publique : seul ce qu’un module exporte peut être injecté ailleurs. Cela rend les dépendances explicites — si OrdersModule a besoin de CatsService, il doit importer CatsModule, et le conteneur (similaire à un compilateur) vous signalera lorsqu’il ne peut pas résoudre une dépendance.
Le AppModule racine importe chaque module de fonctionnalité. Les modules de fonctionnalité peuvent être regroupés en modules partagés ou core, et @Global() marque un module qui doit être disponible partout sans imports répétés. Utilisez les modules globaux avec parcimonie pour les infrastructures véritablement transversales, comme la configuration ou le logging.
Contrôleurs : le routage via les décorateurs
Un contrôleur mappe les requêtes HTTP vers des méthodes. Le décorateur de classe définit le chemin de base, tandis que les décorateurs de méthode définissent le verbe HTTP et le sous-chemin.
@Controller("cats")
export class CatsController {
constructor(private readonly catsService: CatsService) {}
@Get()
findAll() {
return this.catsService.findAll();
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.catsService.findOne(id);
}
@Post()
create(@Body() dto: CreateCatDto) {
return this.catsService.create(dto);
}
}
Les décorateurs de paramètres s’occupent de l’extraction : @Param, @Query, @Body, @Headers, @Req et @Res. Le gestionnaire (handler) retourne une valeur simple ou une promesse, et NestJS la sérialise en JSON avec le statut approprié. Si vous lancez une HttpException telle que NotFoundException, le framework la transformera en une réponse d’erreur appropriée.
Les contrôleurs doivent rester “fins” (thin). Ils traduisent une requête HTTP en un appel de méthode, et rien de plus. Toute la logique doit résider dans les providers, ce qui permet de garder le contrôleur testable et d’isoler les problématiques liées au protocole HTTP.
Providers et injection de dépendances
Un provider est toute classe pouvant être injectée. Marquez-la @Injectable() et déclarez-la dans un module, et le container s’occupe du reste.
@Injectable()
export class CatsService {
constructor(private readonly db: DatabaseService) {}
findAll() {
return this.db.query("SELECT * FROM cats");
}
}
L’injection s’effectue par constructeur et par type. Le container lit les métadonnées émises, résout chaque paramètre et injecte l’instance. Vous pouvez également injecter par token lorsque la valeur n’est pas une classe, en utilisant @Inject("CONFIG") avec un provider correspondant.
Les providers peuvent être personnalisés via des factories, des valeurs et des alias. Un provider de type factory est le moyen habituel d’injecter un élément nécessitant une configuration asynchrone, comme une connexion à une base de données :
@Module({
providers: [
{
provide: "DATABASE",
useFactory: async (config: ConfigService) => {
return createPool(config.getOrThrow("DATABASE_URL"));
},
inject: [ConfigService],
},
],
exports: ["DATABASE"],
})
export class DatabaseModule {}
La portée (scope) est également importante. Le scope singleton par défaut partage une seule instance dans toute l’application, ce qui est idéal pour les services sans état. Le scope REQUEST crée une nouvelle instance par requête et est utile pour le contexte spécifique à une requête, mais cela impacte les performances car l’effet remonte tout le graphe de dépendances.
DTO, pipes et validation
La validation des entrées est une priorité absolue. Un DTO (data transfer object) est une classe qui décrit la structure d’une requête, décorée avec des règles class-validator.
export class CreateCatDto {
@IsString()
@MinLength(1)
name: string;
@IsInt()
@Min(0)
age: number;
}
Un pipe transforme ou valide la valeur avant qu’elle n’atteigne le handler. Activez le ValidationPipe intégré globalement et chaque DTO décoré sera vérifié automatiquement :
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
whitelist supprime les propriétés inconnues, forbidNonWhitelisted les rejette purement et simplement, et transform convertit les payloads en véritables instances de DTO afin que les types soient réels au runtime. Une entrée invalide produit une 400 structurée sans qu’une seule ligne de code de validation ne soit nécessaire dans le handler. Les pipes peuvent également être appliqués par paramètre ou par contrôleur avec @UsePipes.
Guards, interceptors et filtres
Ces trois briques fondamentales couvrent la plupart des préoccupations transversales et s’exécutent dans un ordre fixe.
Les Guards s’exécutent avant le handler et décident si la requête peut continuer.
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>("roles", context.getHandler());
if (!roles) return true;
const { user } = context.switchToHttp().getRequest();
return roles.includes(user?.role);
}
}
Les Interceptors enveloppent l’appel au handler et peuvent transformer le résultat, mesurer le temps d’exécution ou ajouter du caching.
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const start = Date.now();
return next.handle().pipe(
tap(() => console.log(`took ${Date.now() - start}ms`)),
);
}
}
Les Exception filters capturent les erreurs levées et formatent la réponse. Sans filtre, NestJS utilise un comportement par défaut raisonnable ; un filtre personnalisé permet de garder une structure d’erreur cohérente sur l’ensemble de l’API.
@Catch(HttpException)
export class HttpErrorFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
const status = exception.getStatus();
response.status(status).json({
statusCode: status,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}
Enregistrez-les globalement, par contrôleur ou par route. Les guards, interceptors et filtres globaux sont le moyen pour une application NestJS mature de centraliser l’authentification, l’observabilité et la gestion des erreurs.
Configuration et environnement
Le package @nestjs/config encapsule les variables d’environnement et les rend injectables. Importez-le une seule fois en tant que module global.
@Module({
imports: [ConfigModule.forRoot({ isGlobal: true })],
})
export class AppModule {}
Ensuite, injectez ConfigService partout où c’est nécessaire :
@Injectable()
export class DatabaseService {
constructor(private readonly config: ConfigService) {}
connect() {
const url = this.config.getOrThrow<string>("DATABASE_URL");
return createPool(url);
}
}
Privilégiez getOrThrow pour les valeurs obligatoires afin qu’une variable manquante provoque une erreur au démarrage plutôt qu’à la première requête. Combinez cela avec un schéma de validation pour que la configuration soit vérifiée une seule fois, et gardez vos secrets totalement en dehors du dépôt.
Tests avec TestingModule
Les utilitaires de test de NestJS construisent un véritable conteneur en isolation. Vous ne déclarez que les providers à tester, puis vous remplacez leurs dépendances par des mocks.
describe("CatsController", () => {
let controller: CatsController;
let service: CatsService;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();
controller = module.get(CatsController);
service = module.get(CatsService);
});
it("returns all cats", () => {
jest.spyOn(service, "findAll").mockReturnValue([]);
expect(controller.findAll()).toEqual([]);
});
});
Pour les tests de bout en bout (E2E), construisez l’application complète et utilisez supertest contre app.getHttpServer(). Comme le conteneur DI est le même que celui utilisé en production, ces tests sollicitent les guards, pipes et filters exactement comme ils le sont lors du déploiement.
Bonnes pratiques
- Gardez vos contrôleurs légers et placez toute la logique dans des services injectables.
- Définissez les limites de vos modules par fonctionnalité et n’exportez que ce dont les autres modules ont besoin.
- Validez chaque requête à l’aide d’un DTO et d’un
ValidationPipeglobal. - Utilisez des guards pour l’authentification et l’autorisation, et des interceptors pour formater les réponses.
- Enregistrez des filtres d’exception globaux afin que toutes les réponses d’erreur partagent la même structure.
- Injectez la configuration au lieu de lire directement
process.env. - Privilégiez le scope singleton ; n’utilisez le scope request que lorsque c’est réellement nécessaire.
- Testez avec
TestingModuleet remplacez les dépendances (override) plutôt que de solliciter les services réels.
Erreurs courantes
- Construire des providers avec
newau lieu de les injecter, ce qui casse le container. - Oublier d’ajouter un provider à un module, puis passer du temps à traquer une erreur “Nest can’t resolve dependencies”.
- Importer un module mais oublier d’
exportle provider qu’il est censé partager. - Placer la logique métier dans les controllers, les rendant impossibles à tester unitairement.
- Utiliser le request scope partout et dégrader silencieusement les performances.
- Faire confiance à
@Body()sans utiliser de DTO et de validation pipe. - Éparpiller le formatage des erreurs au lieu de le centraliser dans un filter.
- Traiter les modules comme de simples dossiers plutôt que comme des frontières de dépendances.
Et après ?
NestJS est la méthode la plus structurée pour construire un backend Node.js, et il s’appuie directement sur Express ou Fastify. Consultez ces guides pour comprendre la couche HTTP que vous abstrayez. Une bonne maîtrise de TypeScript est essentielle car les décorateurs et l’injection de dépendances reposent sur les types, et les bases de Node.js vous permettront de comprendre l’environnement d’exécution sur lequel tout repose. Ensuite, développez un module de fonctionnalité de bout en bout : contrôleur, service, DTO et tests.