12k
All articles

Structurer une application Node.js d'envergure avec NestJS

Structurez une grande application NestJS par fonctionnalité avec modules, contrôleurs, services, validation DTO, guards, configuration et tests. Clarifiez les dépendances grâce aux imports et exports explicites.

OpenReplay Team
OpenReplay Team
Structurer une application Node.js d'envergure avec NestJS

Une application NestJS d’envergure doit être organisée par fonctionnalité. Chaque domaine (utilisateurs, commandes, facturation) dispose de son propre dossier de module, qui regroupe son contrôleur, son service, ses DTO et ses tests. Les modules ne partagent du code qu’au moyen d’exports et d’imports explicites.

Si vous héritez d’une API Express en pleine croissance, les symptômes vous sont familiers. Un seul fichier de routes gère l’authentification, la validation et les écritures en base de données, et process.env est lu à une douzaine d’endroits. Personne ne sait vraiment quel helper peut être modifié sans risque.

Cet article construit une petite API de gestion d’utilisateurs et de commandes, et place chaque élément là où il doit se trouver : modules, contrôleurs, providers, validation, guards, configuration, module partagé et test unitaire. Si vous en êtes encore au choix d’un framework, l’article Existe-t-il un Rails pour JavaScript ? compare les alternatives.

Points clés

  • Par défaut, un module NestJS garde ses providers privés. Un autre module ne peut en injecter un que si le module propriétaire l’exporte et que le module consommateur importe ce module.
  • L’organisation par fonctionnalité regroupe le module, le contrôleur, le service, les DTO et les tests de chaque domaine dans un même dossier. Une modification des commandes reste ainsi circonscrite à src/orders/.
  • Lorsque whitelist est activé, ValidationPipe supprime les propriétés dépourvues de décorateur de validation. L’ajout de forbidNonWhitelisted fait plutôt échouer ces requêtes.
  • Lorsqu’un guard renvoie false, Nest lève une ForbiddenException. Les vérifications d’authentification résident ainsi dans une seule classe plutôt que dans chaque handler.
  • Un import circulaire non résolu entre modules bloque Nest au démarrage (bootstrap). Extraire le concept partagé est une meilleure solution que forwardRef().

Le problème : une application Express sans règles

Express n’impose aucune règle quant à l’emplacement du code. Une application qui grandit finit donc organisée au gré de la façon dont ses premiers handlers ont été écrits. La plupart des projets démarrent comme le décrit un guide sur la configuration de Node.js avec TypeScript et Express : un app.ts, un dossier de routes et quelques middlewares. En quelques mois, un handler ressemble à ceci :

app.post('/orders', (req, res) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) return res.sendStatus(403);
  const { userId, items } = req.body;
  if (typeof userId !== 'number' || !Array.isArray(items)) return res.sendStatus(400);
  const user = users.find((u) => u.id === userId);
  if (!user) return res.sendStatus(404);
  orders.push({ id: orders.length + 1, userId, items });
  res.status(201).json(orders.at(-1));
});

Ce handler mélange cinq préoccupations. NestJS attribue à chacune un emplacement fixe :

Habitude ExpressConstruction NestJS
Fichier de routesContrôleur (controller)
Module helper contenant de la logique métierProvider (service)
Vérification d’authentification dans un middleware ou un handlerGuard
Lectures de process.envConfigService
Vérifications manuelles de req.bodyDTO + pipe de validation

Tout cela a un coût. Pour une API qui ne compte qu’une poignée de routes, les décorateurs, les modules et le conteneur d’injection de dépendances (DI) de NestJS relèvent d’un formalisme superflu. Ils deviennent rentables dès lors que plusieurs personnes modifient chacune une fonctionnalité sans avoir à lire le reste de la base de code.

Comment structurer un projet NestJS d’envergure ?

Le cœur d’une structure de projet NestJS maintenable, c’est un dossier par fonctionnalité, chacun doté de son propre module. Avec cette organisation, modifier le fonctionnement des commandes revient à ouvrir un seul répertoire, au lieu de fouiller dans des arborescences controllers/, services/ et dto/ distinctes.

src/
├── users/
│   ├── dto/create-user.dto.ts
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.service.spec.ts
│   └── users.module.ts
├── orders/
│   ├── dto/create-order.dto.ts
│   ├── orders.controller.ts
│   ├── orders.service.ts
│   ├── orders.service.spec.ts
│   └── orders.module.ts
├── shared/
│   ├── api-key.guard.ts
│   ├── audit-log.service.ts
│   └── shared.module.ts
├── app.module.ts
└── main.ts

Les modules NestJS encapsulent leurs providers par défaut. OrdersService a besoin de UsersService, donc UsersModule doit l’exporter :

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller.js';
import { UsersService } from './users.service.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [SharedModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
// orders/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersController } from './orders.controller.js';
import { OrdersService } from './orders.service.js';
import { UsersModule } from '../users/users.module.js';
import { SharedModule } from '../shared/shared.module.js';

@Module({
  imports: [UsersModule, SharedModule],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

Le graphe de dépendances est à sens unique : OrdersModule → UsersModule → SharedModule. Tout ce que UsersModule n’exporte pas reste interne. C’est précisément ce qui permet de refactoriser une fonctionnalité en toute sécurité.

Que sont les contrôleurs, les providers et l’injection de dépendances dans NestJS ?

Un contrôleur associe des routes HTTP à des méthodes. Un provider désigne toute classe marquée @Injectable() que Nest peut instancier et fournir à d’autres classes. Avec l’injection de dépendances, une classe déclare ses besoins sous forme de paramètres de constructeur, et le framework fournit les instances. Ce même mécanisme permet à un test de passer une fausse implémentation à la place de la dépendance réelle.

// users/users.service.ts
import { Injectable } from '@nestjs/common';

export interface User { id: number; email: string }

@Injectable()
export class UsersService {
  private readonly users: User[] = [{ id: 1, email: 'ada@example.com' }];

  findOne(id: number): User | undefined {
    return this.users.find((u) => u.id === id);
  }
}
// orders/orders.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';
import { CreateOrderDto } from './dto/create-order.dto.js';

@Injectable()
export class OrdersService {
  private readonly orders: Array<CreateOrderDto & { id: number }> = [];

  constructor(
    private readonly users: UsersService,
    private readonly audit: AuditLogService,
  ) {}

  create(dto: CreateOrderDto) {
    if (!this.users.findOne(dto.userId)) {
      throw new NotFoundException(`User ${dto.userId} not found`);
    }
    const order = { id: this.orders.length + 1, ...dto };
    this.orders.push(order);
    this.audit.record('order.created', order.id);
    return order;
  }
}
// orders/orders.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiKeyGuard } from '../shared/api-key.guard.js';
import { CreateOrderDto } from './dto/create-order.dto.js';
import { OrdersService } from './orders.service.js';

@Controller('orders')
@UseGuards(ApiKeyGuard)
export class OrdersController {
  constructor(private readonly orders: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.orders.create(dto);
  }
}

Le contrôleur transforme une requête en un unique appel de service. La règle « l’utilisateur doit exister » réside dans le service : tout autre appelant peut la réutiliser et un test peut la vérifier directement. Les exceptions HTTP intégrées, comme NotFoundException, sont converties en la réponse d’erreur correspondante.

Comment fonctionnent les DTO et les pipes de validation dans NestJS ?

Un DTO (data transfer object, ou objet de transfert de données) est une classe qui décrit la forme du corps d’une requête. Associé à un pipe de validation, il rejette les entrées invalides avant l’exécution du contrôleur. Si vous disposez d’un exemple de payload, le convertisseur JSON vers TypeScript génère le type de départ, auquel vous ajoutez ensuite les décorateurs class-validator :

// orders/dto/create-order.dto.ts
import { ArrayNotEmpty, IsArray, IsInt, IsString } from 'class-validator';

export class CreateOrderDto {
  @IsInt()
  userId!: number;

  @IsArray()
  @ArrayNotEmpty()
  @IsString({ each: true })
  items!: string[];
}

Enregistrez le pipe une seule fois dans main.ts :

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  );
  await app.listen(3000);
}
bootstrap();

La documentation sur la validation précise le rôle de chaque option. whitelist supprime les propriétés dépourvues de décorateur. forbidNonWhitelisted transforme cette suppression silencieuse en réponse d’erreur. transform convertit le payload en instance de la classe DTO. ValidationPipe dépend de class-validator et de class-transformer.

NestJS 12, sorti en août 2026, a introduit une seconde approche : StandardSchemaValidationPipe, qui valide des schémas Zod, Valibot ou ArkType passés via @Body({ schema }). À elle seule, l’option schema se contente d’associer le schéma au paramètre. La validation n’a lieu qu’une fois le pipe enregistré, par exemple avec app.useGlobalPipes(new StandardSchemaValidationPipe()).

Si aucun pipe n’est enregistré, le frontend vous le fera savoir. Les replays de session d’interfaces qui s’appuient sur une API montrent côte à côte la saisie de l’utilisateur, la requête malformée et le toast d’erreur générique. On voit alors clairement que la correction doit intervenir au niveau du DTO.

Comment les guards et la configuration gèrent-ils l’authentification et les variables d’environnement ?

Un guard est une classe @Injectable() qui implémente CanActivate. Il s’exécute avant le handler de la route et décide si la requête peut se poursuivre. S’il renvoie false, Nest lève une ForbiddenException (403). Combiné à @nestjs/config, le guard lit son secret depuis ConfigService plutôt que depuis process.env :

// shared/api-key.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  constructor(private readonly config: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    return request.headers['x-api-key'] === this.config.get<string>('API_KEY');
  }
}

La lecture de request.headers fonctionne aussi bien avec l’adaptateur Express qu’avec l’adaptateur Fastify. Dans AppModule, validez l’environnement au démarrage : une clé manquante provoquera ainsi une erreur au boot, et non lors de la première requête. Depuis NestJS 12, validationSchema accepte n’importe quel objet Standard Schema, comme un schéma Zod. Les versions antérieures attendaient un schéma Joi :

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { z } from 'zod';
import { UsersModule } from './users/users.module.js';
import { OrdersModule } from './orders/orders.module.js';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: z.object({ API_KEY: z.string().min(1) }),
    }),
    UsersModule,
    OrdersModule,
  ],
})
export class AppModule {}

La configuration est le seul cas où un flag global se justifie vraiment, car presque tous les modules la lisent.

Un module partagé et des tests avec substitution de dépendances

Le code utilisé par plusieurs fonctionnalités va dans un module partagé qui l’exporte. Chaque fonctionnalité importe ensuite ce module, si bien que chaque dépendance apparaît dans le tableau imports. Marquer les modules avec @Global() masquerait ce câblage :

// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { AuditLogService } from './audit-log.service.js';
import { ApiKeyGuard } from './api-key.guard.js';

@Module({
  providers: [AuditLogService, ApiKeyGuard],
  exports: [AuditLogService, ApiKeyGuard],
})
export class SharedModule {}

C’est dans les tests que l’injection de dépendances montre tout son intérêt. Test.createTestingModule construit un petit module dans lequel vous remplacez les providers réels par de simples objets :

// orders/orders.service.spec.ts
import { Test } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { OrdersService } from './orders.service.js';
import { UsersService } from '../users/users.service.js';
import { AuditLogService } from '../shared/audit-log.service.js';

describe('OrdersService', () => {
  it('throws when the user does not exist', async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        OrdersService,
        { provide: UsersService, useValue: { findOne: () => undefined } },
        { provide: AuditLogService, useValue: { record: () => {} } },
      ],
    }).compile();

    const service = moduleRef.get(OrdersService);
    expect(() => service.create({ userId: 99, items: ['book'] })).toThrow(NotFoundException);
  });
});

Depuis NestJS 12, nest new configure Vitest pour les projets ESM et Jest pour les projets CommonJS. Avec Vitest, importez describe, it et expect depuis vitest, sauf si les globals sont activés. Pour les tests d’intégration sur une véritable base de données, l’article exécuter NestJS avec Docker Compose détaille la mise en place.

Quelles sont les erreurs courantes dans les bases de code NestJS d’envergure ?

La plupart des problèmes structurels des grandes applications NestJS proviennent de trois schémas :

  • Dépendances circulaires entre modules. Si UsersModule et OrdersModule s’importent mutuellement, Nest échoue au démarrage, car il ne parvient pas à créer l’instance du module. forwardRef() contourne le cycle, alors que déplacer le concept partagé dans son propre module le fait disparaître.
  • Un AppModule hypertrophié. Le module racine doit importer les modules de fonctionnalités et la configuration globale, et non déclarer lui-même des dizaines de contrôleurs et de providers.
  • De la logique dans les contrôleurs. Dès qu’un contrôleur prend des décisions, ces règles ne peuvent plus être réutilisées ni testées unitairement sans passer par HTTP. Limitez chaque méthode de contrôleur à un unique appel de service.

Conclusion

Une application NestJS d’envergure reste maîtrisable à trois conditions : chaque fonctionnalité vit dans son propre module, les seuls liens entre modules sont des exports explicites et les contrôleurs restent légers. Choisissez une route de votre API actuelle, par exemple POST /orders. Déplacez ses règles dans un service, ses vérifications du corps de requête dans un DTO et son authentification dans un guard. Répétez ensuite ce schéma, une fonctionnalité à la fois.

FAQ

La logique d'authentification NestJS doit-elle se trouver dans un middleware ou dans un guard ?

Placez l'authentification propre à une route dans un guard. Le middleware s'exécute avant que Nest ne sélectionne le handler de la route : il ne voit donc que la requête et la réponse brutes. Il n'a accès ni au contrôleur, ni au handler, ni aux métadonnées des décorateurs. Les guards s'exécutent une fois tous les middlewares terminés, avant tout intercepteur ou pipe, et reçoivent un ExecutionContext qui expose le handler et sa classe. Le middleware reste adapté aux traitements qui s'appliquent à chaque requête, comme la journalisation.

Quelle est la cause de l'erreur Nest can't resolve dependencies ?

Cette erreur signifie que Nest ne trouve pas de provider pour l'un des paramètres d'un constructeur dans le module indiqué par le message. Le plus souvent, ce provider est absent du tableau providers du module et n'est exporté par aucun des modules de son tableau imports. Pour corriger le problème, exportez le provider depuis le module qui le possède et importez ce module. Vérifiez également qu'aucune classe marquée @Injectable() ne figure dans un tableau imports.

Pourquoi une chaîne numérique dans le corps d'une requête échoue-t-elle à la validation @IsInt, même lorsque transform est activé ?

Lorsque transform vaut true, ValidationPipe convertit le corps JSON en instance de la classe DTO, mais ne modifie pas le type des valeurs primitives. Un userId envoyé sous la forme '30' reste une chaîne et échoue à @IsInt. Vous pouvez envoyer un véritable nombre depuis le client, ou définir enableImplicitConversion à true dans transformOptions. La conversion implicite comporte toutefois un piège : toute chaîne non vide, y compris 'false', devient true pour un champ booléen.

Comment valider des objets imbriqués ou des tableaux d'objets dans un DTO NestJS ?

Ajoutez à la propriété le décorateur @ValidateNested() de class-validator et le décorateur @Type(() => ItemDto) de class-transformer. Sans @Type, la valeur imbriquée reste un objet littéral, et les décorateurs de validation d'ItemDto ne s'exécutent jamais. Pour un tableau d'objets, utilisez @ValidateNested({ each: true }) avec le même décorateur @Type. La classe DTO imbriquée doit disposer de ses propres décorateurs class-validator, et whitelist s'applique alors également à ses propriétés.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.