Architecture modulaire : organiser un gros projet NestJS

Architecture modulaire : organiser un gros projet NestJS

Quand un projet NestJS grossit, l'organisation initiale en quelques modules à plat finit toujours par montrer ses limites : imports interminables, dépendances croisées, fichiers difficiles à retrouver, et ce sentiment désagréable qu'ajouter une fonctionnalité demande de toucher à dix endroits différents. Dans cet article, nous allons voir comment structurer un projet NestJS de grande envergure en nous appuyant sur une architecture modulaire éprouvée, inspirée d'un vrai backend en production (un site de voyance utilisant NestJS 10, TypeORM et PostgreSQL).

L'objectif : obtenir une base de code lisible, testable, où chaque domaine fonctionnel est isolé et où l'ajout d'une nouvelle fonctionnalité ne casse rien. Prérequis : connaître les bases de NestJS (modules, providers, controllers) et TypeORM.

Le principe directeur : séparation par domaine fonctionnel

La règle d'or d'une architecture NestJS scalable, c'est une découpe par domaine métier, pas par couche technique. On ne crée pas un dossier controllers/, un dossier services/ et un dossier entities/ à la racine. À la place, chaque domaine (booking, payment, oracle, tarot, user...) possède son propre module avec ses controllers, services, DTO et tests regroupés.

Cette approche, parfois appelée feature-based ou domain-driven, présente plusieurs avantages : un nouveau développeur trouve immédiatement tout ce qui concerne une fonctionnalité au même endroit, le couplage entre modules est explicite (via les imports et exports), et on peut extraire un module en microservice plus tard sans tout réécrire.

Structure recommandée

Voici la structure que nous utiliserons comme référence, directement issue d'un projet réel :

src/
├── app.module.ts
├── main.ts
├── config/
│   └── configuration.ts
├── common/
│   ├── guards/
│   ├── decorators/
│   ├── filters/
│   ├── pipes/
│   └── dto/
├── database/
│   ├── database.module.ts
│   ├── entities/
│   ├── migrations/
│   └── seeds/
└── modules/
    ├── auth/
    ├── user/
    ├── booking/
    ├── payment/
    ├── services/
    ├── email/
    └── pages/
        ├── home/
        ├── about/
        ├── contact/
        └── footer/

Quatre dossiers racine clairs : config/ pour la configuration, common/ pour le code transverse, database/ pour tout ce qui concerne la persistance, et modules/ pour les fonctionnalités métier.

Le dossier common/ : code transverse réutilisable

Le dossier common/ regroupe tout ce qui n'appartient à aucun domaine en particulier mais qui est utilisé partout : guards d'authentification, décorateurs personnalisés (comme @Roles() ou @Public()), filtres d'exception globaux, pipes de validation, et DTO génériques (par exemple un ApiResponseDto pour standardiser les réponses).

Un point intéressant : on peut grouper ce code transverse dans un module global. Voici comment le projet de référence expose ses guards via un GuardsModule marqué @Global() :

import { Module, Global } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthModule } from '../../modules/auth/auth.module';
import { JwtAuthGuard } from './jwt-auth.guard';
import { RolesGuard } from './roles.guard';

@Global()
@Module({
  imports: [
    AuthModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const secret = configService.get<string>('jwt.secret');
        if (!secret) {
          throw new Error('JWT_SECRET est obligatoire.');
        }
        return {
          secret,
          signOptions: { expiresIn: '30m', algorithm: 'HS256' as const },
        };
      },
    }),
  ],
  providers: [
    JwtAuthGuard,
    RolesGuard,
    { provide: APP_GUARD, useExisting: JwtAuthGuard },
    { provide: APP_GUARD, useExisting: RolesGuard },
  ],
  exports: [JwtAuthGuard, RolesGuard, AuthModule, JwtModule],
})
export class GuardsModule {}

Le décorateur @Global() rend les exports disponibles partout sans avoir à réimporter GuardsModule dans chaque module. À utiliser avec parcimonie : ce qui est global devient implicite, donc plus difficile à tracer.

Le dossier database/ : persistance centralisée

Toutes les entités TypeORM, les migrations et les seeds vivent ensemble dans database/. Pourquoi pas dans chaque module ? Parce que les entités sont souvent référencées de manière croisée (un Booking référence un Service et un User) et que les concentrer simplifie la configuration TypeORM ainsi que les commandes de migration.

@Module({
  imports: [
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const isProduction = process.env.NODE_ENV === 'production';
        return {
          type: 'postgres' as const,
          host: configService.get<string>('database.host'),
          port: configService.get<number>('database.port'),
          username: configService.get<string>('database.username'),
          password: configService.get<string>('database.password'),
          database: configService.get<string>('database.name'),
          entities: [__dirname + '/entities/*.entity{.ts,.js}'],
          migrations: [__dirname + '/migrations/*{.ts,.js}'],
          migrationsRun: isProduction,
          synchronize: !isProduction && process.env.DB_SYNCHRONIZE !== 'false',
        };
      },
    }),
  ],
})
export class DatabaseModule {}

Les chemins glob entities/*.entity{.ts,.js} et migrations/*{.ts,.js} évitent de devoir lister chaque fichier manuellement. Notez aussi que synchronize est désactivé en production : on s'appuie alors uniquement sur les migrations, ce qui est la pratique recommandée.

Modules de fonctionnalité : exporter pour partager

Chaque module métier suit le même pattern : il importe ses dépendances, déclare ses providers, et exporte les services qu'il souhaite rendre disponibles aux autres modules. C'est ce qui permet la réutilisation propre.

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingService } from './booking.service';
import { BookingController } from './booking.controller';
import { Booking } from '../../database/entities/booking.entity';
import { ServicesModule } from '../services/services.module';
import { EmailModule } from '../email/email.module';
import { TimeSlotModule } from '../time-slot/time-slot.module';

@Module({
  imports: [
    TypeOrmModule.forFeature([Booking]),
    ServicesModule,
    EmailModule,
    TimeSlotModule,
  ],
  controllers: [BookingController],
  providers: [BookingService],
  exports: [BookingService], // ← réutilisable ailleurs
})
export class BookingModule {}

Ici, BookingModule consomme ServicesModule, EmailModule et TimeSlotModule, et expose son propre BookingService. C'est la clé d'une architecture lisible : les dépendances sont déclaratives.

Sous-modules : organiser les pages

Quand un domaine devient lui-même volumineux, on peut le diviser en sous-modules. Le projet de référence en donne un excellent exemple avec modules/pages/, qui regroupe tous les modules liés aux pages publiques du site :

modules/pages/
├── home/home-page.module.ts
├── about/about-page.module.ts
├── contact/contact-page.module.ts
├── reservation/reservation-page.module.ts
├── oracles/oracles-page.module.ts
├── footer/footer-page.module.ts
└── legal/legal-page.module.ts

Chaque page a son propre module, son controller et son service. C'est plus verbeux qu'un seul gros PagesModule, mais c'est aussi infiniment plus maintenable : modifier la page d'accueil ne touche pas le code des autres pages.

Éviter les dépendances circulaires

Le talon d'Achille de toute architecture modulaire : les dépendances circulaires. Si BookingModule a besoin de PaymentModule et inversement, NestJS lèvera une erreur au démarrage. Deux solutions :

  1. Repenser le découpage : extraire la logique partagée dans un troisième module ou dans un service commun.
  2. Utiliser forwardRef() en dernier recours, comme dans le module Payment du projet :
@Module({
  imports: [
    ConfigModule,
    TypeOrmModule.forFeature([StripeEvent]),
    ServicesModule,
    forwardRef(() => BookingModule),
  ],
  controllers: [PaymentController],
  providers: [PaymentService],
  exports: [PaymentService],
})
export class PaymentModule {}

forwardRef() indique à NestJS que la résolution du module peut être différée. C'est efficace mais signale souvent qu'un refactoring serait bénéfique : un cycle traduit généralement un couplage trop fort.

Index files et imports propres

Pour réduire le bruit visuel des imports, créez un fichier index.ts (ou barrel file) dans les dossiers qui exposent plusieurs éléments. Par exemple, dans common/decorators/index.ts :

export * from './roles.decorator';
export * from './public.decorator';
export * from './current-user.decorator';

Ce qui permet d'écrire import { Roles, Public, CurrentUser } from '@/common/decorators'; au lieu de trois imports séparés. Attention toutefois : les barrel files mal utilisés peuvent provoquer des dépendances circulaires subtiles. Évitez-en un à la racine de src/.

Documenter l'architecture

Un schéma vaut mille lignes de README. Maintenez à jour un fichier ARCHITECTURE.md à la racine du projet, contenant :

  • L'arborescence des dossiers avec le rôle de chacun
  • Un diagramme de dépendances entre modules (Mermaid fonctionne très bien dans GitHub)
  • Les conventions de nommage (entities en *.entity.ts, DTO en *.dto.ts, etc.)
  • Les règles d'or de l'équipe : pas de any, validation systématique, retours encapsulés dans ApiResponseDto...

Conclusion

Une architecture modulaire bien pensée n'est pas un luxe sur un projet NestJS de taille importante : c'est ce qui fait la différence entre un code base agréable à faire vivre et un legacy qu'on craint d'ouvrir. En séparant par domaine, en isolant le code transverse dans common/, en centralisant la persistance dans database/, et en exportant explicitement ce qui doit l'être, on obtient un projet où chaque morceau a sa place.

Pour aller plus loin, explorez les modules dynamiques, le pattern CQRS proposé par @nestjs/cqrs, ou encore l'extraction progressive de modules en microservices via le package @nestjs/microservices. La structure présentée ici est un excellent point de départ pour ces évolutions.

Continuer la lecture

Article suivant — NestJS Configurer CORS correctement dans NestJS Explorer tout : NestJS

Commentaires

Soyez le premier à laisser un commentaire — le robot attend.

Laisser un commentaire

Les champs obligatoires sont indiqués avec *