NestJS 11 Enterprise API Patterns

Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.

E
ECOSIRE Research and Development Team
|19 mars 202613 min de lecture3.0k Mots|

Modèles d'API d'entreprise NestJS 11

NestJS 11 est devenu le framework incontournable pour créer des API de production en TypeScript, combinant une architecture d'inspiration angulaire avec la puissance brute de Node.js. Lorsque vous travaillez à l'échelle de l'entreprise (en traitant des millions de requêtes, en gérant des données multi-locataires et en coordonnant des dizaines de modules), les modèles que vous choisissez dès le premier jour déterminent si votre base de code évolue correctement ou s'effondre sous son propre poids.

Ce guide distille les leçons durement gagnées de la création d'un backend NestJS 11 de 56 modules avec plus de 310 fichiers TypeScript, couvrant tout, de l'organisation des modules et de la composition des gardes aux modèles multi-locataires qui résistent réellement à la charge.

Points clés à retenir

  • Utilisez forRoutes('*path') et non forRoutes('*') — NestJS 11 a modifié la correspondance d'itinéraire générique
  • Les filtres d'exceptions globales doivent être enregistrés dans main.ts, et non via APP_FILTER
  • La multi-location nécessite un filtrage organizationId à chaque couche de requête, pas seulement un middleware
  • Le modèle de décorateur @Public() est plus sûr que la désactivation complète des gardes pour les itinéraires ouverts
  • N'utilisez jamais sql.raw() dans les requêtes Drizzle - toujours paramétré les littéraux de modèle sql
  • EmailModule doit être @Global() pour éviter la réimportation dans chaque module de fonctionnalités
  • Dotenv doit être préchargé dans main.ts avant le démarrage de NestJS pour éviter les problèmes de résolution d'environnement
  • La limitation du débit est obligatoire sur tous les points de terminaison publics — utilisez @nestjs/throttler

Structure du projet à grande échelle

La décision architecturale la plus importante dans NestJS est la façon dont vous organisez les modules. À l'échelle de l'entreprise, une liste de modules plate dans app.module.ts devient ingérable. Le modèle qui fonctionne est un regroupement de modules piloté par domaine avec des déclarations de dépendance explicites.

apps/api/src/
  modules/
    auth/
      auth.module.ts
      auth.controller.ts
      auth.service.ts
      guards/
        jwt.guard.ts
        roles.guard.ts
      decorators/
        public.decorator.ts
        roles.decorator.ts
    contacts/
      contacts.module.ts
      contacts.controller.ts
      contacts.service.ts
      contacts.spec.ts
      dto/
        create-contact.dto.ts
        update-contact.dto.ts
    billing/
      billing.module.ts
      billing.service.ts
      webhook.controller.ts
  shared/
    filters/
      global-exception.filter.ts
    interceptors/
      transform.interceptor.ts
    pipes/
      validation.pipe.ts
  health/
    health.controller.ts
    indicators/
  main.ts
  app.module.ts

Chaque module de domaine est autonome. Le répertoire shared/ recèle des préoccupations transversales. Cette séparation vous permet d'ajouter de nouveaux domaines sans toucher au code existant.


Le modèle de décorateur @Public()

NestJS 11 applique les gardes JWT à l'échelle mondiale, mais vous devez sélectionner des points de terminaison ouverts : vérifications de l'état, rappels d'authentification, récepteurs webhook. Le modèle de décorateur @Public() est de loin supérieur à la désactivation des gardes par itinéraire.

// decorators/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// guards/jwt-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) return true;
    return super.canActivate(context);
  }
}
// app.module.ts
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: RolesGuard },
  ],
})
export class AppModule {}

Désormais, toute méthode de contrôleur décorée avec @Public() contourne entièrement la validation JWT. Les contrôleurs Webhook, les points de terminaison d’intégrité et les routes d’authentification utilisent tous ce modèle.


Multilocation avec OrganizationId

La règle cardinale des API multi-locataires : chaque requête de base de données doit être filtrée par organizationId. Un middleware qui définit req.organizationId ne suffit pas : un développeur oubliant d'appliquer le filtre expose les données entre locataires.

Le modèle consiste à extraire organizationId de la charge utile JWT et à l'attacher à une interface de requête typée :

// types/authenticated-request.interface.ts
import { Request } from 'express';

export interface AuthenticatedRequest extends Request {
  user: {
    sub: string;
    email: string;
    name: string;
    role: 'admin' | 'support' | 'user';
    organizationId: string;
  };
}
// contacts/contacts.controller.ts
import { Controller, Get, Post, Body, Req } from '@nestjs/common';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';

@Controller('contacts')
export class ContactsController {
  constructor(private contactsService: ContactsService) {}

  @Get()
  findAll(@Req() req: AuthenticatedRequest) {
    return this.contactsService.findAll(req.user.organizationId);
  }

  @Post()
  create(@Body() dto: CreateContactDto, @Req() req: AuthenticatedRequest) {
    return this.contactsService.create(dto, req.user.organizationId);
  }
}
// contacts/contacts.service.ts
import { db } from '@ecosire/db';
import { contacts } from '@ecosire/db/schema';
import { eq, and } from 'drizzle-orm';

@Injectable()
export class ContactsService {
  async findAll(organizationId: string) {
    return db
      .select()
      .from(contacts)
      .where(eq(contacts.organizationId, organizationId))
      .limit(100);
  }
}

La couche de service applique organizationId au niveau de la requête. Aucune défaillance de middleware ou décorateur oublié ne peut divulguer des données entre locataires, car le SQL lui-même applique l'isolation.


Filtre d'exception global

Des réponses d'erreur cohérentes sur tous les points de terminaison nécessitent un filtre d'exception global. La hiérarchie HttpException de NestJS 11 gère la plupart des cas, mais vous devez également détecter les erreurs inattendues et ne jamais exposer les traces de pile en production.

// filters/global-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(GlobalExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';
    let errors: Record<string, string[]> | undefined;

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();

      if (typeof exceptionResponse === 'string') {
        message = exceptionResponse;
      } else if (typeof exceptionResponse === 'object') {
        const resp = exceptionResponse as Record<string, unknown>;
        message = (resp.message as string) || message;
        if (Array.isArray(resp.message)) {
          // Validation errors from class-validator
          errors = this.formatValidationErrors(resp.message as string[]);
          message = 'Validation failed';
        }
      }
    } else if (exception instanceof Error) {
      this.logger.error(exception.message, exception.stack);
      // Never expose stack traces in production
      if (process.env.NODE_ENV !== 'production') {
        message = exception.message;
      }
    }

    response.status(status).json({
      statusCode: status,
      message,
      errors,
      path: request.url,
      timestamp: new Date().toISOString(),
    });
  }

  private formatValidationErrors(messages: string[]): Record<string, string[]> {
    const errors: Record<string, string[]> = {};
    for (const msg of messages) {
      const [field, ...rest] = msg.split(' ');
      if (!errors[field]) errors[field] = [];
      errors[field].push(rest.join(' '));
    }
    return errors;
  }
}

Enregistrez-le dans main.ts :

// main.ts
import { NestFactory } from '@nestjs/core';
import { GlobalExceptionFilter } from './shared/filters/global-exception.filter';

async function bootstrap() {
  // CRITICAL: Load env vars before NestJS bootstraps
  require('dotenv').config({ path: join(__dirname, '..', '..', '..', '.env.local') });

  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new GlobalExceptionFilter());
  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
  await app.listen(3001);
}

Le changement de joker de route NestJS 11

L’un des changements les plus marquants de NestJS 11 est la correspondance d’itinéraire générique. Si vous migrez depuis NestJS 10, cela brisera silencieusement votre middleware :

// NestJS 10 — works
consumer.apply(LoggerMiddleware).forRoutes('*');

// NestJS 11 — use '*path' instead
consumer.apply(LoggerMiddleware).forRoutes('*path');

La même chose s'applique à la configuration de Swagger et à tous les modèles d'itinéraire basés sur des chaînes. Cette modification affecte l'enregistrement du middleware, les exclusions de routes et tout endroit où vous utilisez la correspondance de routes de style global.


Contrôle d'accès basé sur les rôles

Au-delà de l'authentification JWT, les API d'entreprise nécessitent une autorisation basée sur les rôles. Le modèle décorateur plus garde garde les contrôleurs propres :

// decorators/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

export type Role = 'admin' | 'support' | 'user';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
// guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY, Role } from '../decorators/roles.decorator';
import { AuthenticatedRequest } from '../../types/authenticated-request.interface';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (!requiredRoles) return true;

    const { user } = context.switchToHttp().getRequest<AuthenticatedRequest>();
    const hasRole = requiredRoles.some((role) => user.role === role);

    if (!hasRole) {
      throw new ForbiddenException('Insufficient permissions');
    }

    return true;
  }
}

Utilisation dans les contrôleurs :

// For top-level module controllers — one `..`
import { Roles } from '../auth/guards/roles.guard';

// For nested sub-module controllers — two `../..`
import { Roles } from '../../auth/guards/roles.guard';

@Controller('admin/contacts')
@Roles('admin', 'support')
export class AdminContactsController {}

La profondeur du chemin d'importation est une source courante de bugs. Les contrôleurs de module de niveau supérieur utilisent un .. ; les contrôleurs de sous-modules imbriqués utilisent deux ../...


Modèle global de module de messagerie

Le décorateur @Global() résout le problème d’importation répétitive pour les services utilisés partout. Le courrier électronique est le cas d'utilisation canonique : vous ne souhaitez pas importer EmailModule dans chaque module de fonctionnalité qui envoie des notifications.

// email/email.module.ts
import { Global, Module } from '@nestjs/common';
import { EmailService } from './email.service';

@Global()
@Module({
  providers: [EmailService],
  exports: [EmailService],
})
export class EmailModule {}

Enregistrez-le une fois dans AppModule :

@Module({
  imports: [
    EmailModule, // Global — available everywhere
    ContactsModule,
    BillingModule,
    // ...
  ],
})
export class AppModule {}

Désormais, n'importe quel service peut injecter EmailService sans toucher aux importations de modules. Le même modèle fonctionne pour Redis, EventBus et tout service d'infrastructure transversal.


Points de terminaison publics limitant le débit

Les API d'entreprise sont quotidiennement confrontées à des tentatives d'abus. Le @nestjs/throttler de NestJS s'intègre parfaitement au système de garde :

// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      { name: 'short', ttl: 1000, limit: 10 },
      { name: 'medium', ttl: 60000, limit: 100 },
    ]),
  ],
  providers: [
    { provide: APP_GUARD, useClass: ThrottlerGuard },
  ],
})

Remplacez chaque point de terminaison par @Throttle() pour des limites plus strictes sur les routes sensibles :

import { Throttle } from '@nestjs/throttler';

@Controller('auth')
export class AuthController {
  @Post('exchange')
  @Public()
  @Throttle({ short: { ttl: 60000, limit: 5 } }) // 5 per minute
  async exchangeCode(@Body() dto: ExchangeCodeDto) {
    return this.authService.exchangeCode(dto.code);
  }
}

Bilans de santé avec @nestjs/terminus

Les API de production ont besoin de points de terminaison d’intégrité pour les vérifications et la surveillance de l’équilibreur de charge. @nestjs/terminus fournit un système déclaratif avec des indicateurs intégrés :

// health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
import { Public } from '../auth/decorators/public.decorator';
import { DatabaseHealthIndicator } from './indicators/database.indicator';
import { RedisHealthIndicator } from './indicators/redis.indicator';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: DatabaseHealthIndicator,
    private redis: RedisHealthIndicator,
  ) {}

  @Get()
  @Public()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.isHealthy('database'),
      () => this.redis.isHealthy('redis'),
    ]);
  }
}

Indicateur personnalisé pour Drizzle ORM (puisque le terminus n'en a pas intégré) :

// health/indicators/database.indicator.ts
import { Injectable } from '@nestjs/common';
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
import { db } from '@ecosire/db';
import { sql } from 'drizzle-orm';

@Injectable()
export class DatabaseHealthIndicator extends HealthIndicator {
  async isHealthy(key: string): Promise<HealthIndicatorResult> {
    try {
      await db.execute(sql`SELECT 1`);
      return this.getStatus(key, true);
    } catch (error) {
      throw new HealthCheckError('Database check failed', this.getStatus(key, false));
    }
  }
}

Modèles de validation DTO

Le ValidationPipe avec whitelist: true supprime les propriétés inconnues avant qu'elles n'atteignent votre couche de service. Combinés avec class-transformer, les DTO deviennent votre première ligne de défense :

// contacts/dto/create-contact.dto.ts
import { IsString, IsEmail, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';

export enum ContactType {
  INDIVIDUAL = 'individual',
  COMPANY = 'company',
}

export class CreateContactDto {
  @ApiProperty({ example: 'Acme Corp' })
  @IsString()
  @MinLength(2)
  @MaxLength(255)
  @Transform(({ value }) => value?.trim())
  name: string;

  @ApiProperty({ example: '[email protected]' })
  @IsEmail()
  @Transform(({ value }) => value?.toLowerCase().trim())
  email: string;

  @ApiPropertyOptional({ enum: ContactType })
  @IsOptional()
  @IsEnum(ContactType)
  type?: ContactType;

  @ApiPropertyOptional()
  @IsOptional()
  @IsString()
  @MaxLength(500)
  notes?: string;
}

Les décorateurs @Transform normalisent les données au niveau de la couche de validation. La suppression des espaces et la mise en minuscule des e-mails empêchent les enregistrements en double dus aux différences de casse.


Intégration Swagger

Les API d'entreprise ont besoin de documentation. Le module Swagger de NestJS génère des spécifications OpenAPI à partir des décorateurs, mais la configuration doit être délibérée :

// main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('ECOSIRE API')
    .setDescription('Enterprise API for ECOSIRE platform')
    .setVersion('1.0')
    .addBearerAuth()
    .addTag('auth', 'Authentication endpoints')
    .addTag('contacts', 'Contact management')
    .build();

  const document = SwaggerModule.createDocument(app, config);

  // Only expose in non-production environments
  if (process.env.NODE_ENV !== 'production') {
    SwaggerModule.setup('api/docs', app, document);
  }

  await app.listen(3001);
}

Chaque méthode de contrôleur a besoin de @ApiOperation et @ApiResponse :

@Get(':id')
@ApiOperation({ summary: 'Get contact by ID' })
@ApiResponse({ status: 200, description: 'Contact found', type: ContactResponseDto })
@ApiResponse({ status: 404, description: 'Contact not found' })
async findOne(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
  return this.contactsService.findOne(id, req.user.organizationId);
}

Pièges courants et solutions

Piège 1 : Dépendances circulaires entre modules

Les importations circulaires plantent NestJS au démarrage avec une erreur cryptique. Le correctif est forwardRef() :

@Module({
  imports: [forwardRef(() => BillingModule)],
})
export class LicenseModule {}

Meilleure solution : extrayez la logique partagée vers un troisième module qui importe tous deux sans dépendance circulaire.

Piège 2 : les variables d'environnement ne sont pas disponibles lors de l'initialisation du module

Si vous accédez à process.env.DATABASE_URL dans un constructeur de classe ou une usine de fournisseur avant le chargement de dotenv, vous obtenez undefined. Solution : chargez dotenv tout en haut de main.ts, avant toute importation NestJS.

// main.ts — must be FIRST
import * as path from 'path';
require('dotenv').config({ path: path.join(__dirname, '..', '..', '..', '.env.local') });

// Then NestJS imports
import { NestFactory } from '@nestjs/core';

Piège 3 : attente manquante pour les opérations asynchrones dans les hooks de cycle de vie

// Wrong — database connection might not be ready
@Injectable()
export class AppService implements OnModuleInit {
  onModuleInit() {
    this.seedDatabase(); // Not awaited!
  }
}

// Correct
async onModuleInit() {
  await this.seedDatabase();
}

Piège 4 : Utilisation de sql.raw() dans les requêtes Drizzle

sql.raw() contourne le paramétrage et ouvre les vecteurs d'injection SQL. Utilisez toujours le modèle littéral sql :

// Dangerous — never do this
const result = await db.execute(sql.raw(`SELECT * FROM contacts WHERE id = '${id}'`));

// Safe — parameterized
const result = await db.execute(sql`SELECT * FROM contacts WHERE id = ${id}`);

Questions fréquemment posées

Comment gérer la gestion des versions dans les API NestJS 11 ?

NestJS 11 prend en charge la gestion des versions d'URI, la gestion des versions d'en-tête et la gestion des versions de type de média prêtes à l'emploi. Activez-le dans main.ts avec app.enableVersioning({ type: VersioningType.URI }), puis décorez les contrôleurs avec @Version('1'). Pour les API d'entreprise, la gestion des versions d'URI (/v1/contacts) est l'approche la plus explicite et la plus conviviale pour le cache.

Quelle est la meilleure façon de gérer les téléchargements de fichiers dans NestJS ?

Utilisez @nestjs/platform-express avec multer pour les téléchargements de fichiers. Pour les téléchargements S3, configurez un moteur de stockage personnalisé via multer-s3. Validez toujours les types et les tailles de fichiers au niveau du canal avant l'exécution du gestionnaire, et ne faites jamais confiance aux types MIME fournis par le client : validez plutôt les octets magiques.

Comment dois-je structurer les transactions de base de données dans NestJS ?

Drizzle ORM prend en charge les transactions avec db.transaction(async (tx) => { ... }). Transmettez l’objet de transaction aux méthodes de service plutôt qu’à l’instance globale db. Pour une logique métier multi-opérations (créer une commande + déduire l'inventaire + envoyer un e-mail), enveloppez le tout dans une transaction et rendez l'envoi d'e-mails non bloquant avec .catch() après la validation.

Quand dois-je utiliser des gardes, des middlewares ou des intercepteurs ?

Les gardes gèrent l'autorisation (cet utilisateur peut-il accéder à cette ressource ?). Le middleware gère la transformation transversale des requêtes (journalisation, ID de corrélation, analyse). Les intercepteurs encapsulent le cycle requête-réponse pour la transformation, la mise en cache et les métriques. L'ordre d'exécution est le suivant : Middleware → Guards → Interceptors (avant) → Pipes → Handler → Interceptors (after) → Filtres d'exception.

Comment tester les modules NestJS de manière isolée ?

Utilisez Test.createTestingModule() pour créer un bac à sable de test avec des dépendances simulées. Moquez vos méthodes de service avec jest.fn() ou vi.fn() et testez le comportement du contrôleur indépendamment de votre base de données. Pour les tests d'intégration, utilisez @nestjs/testing avec une connexion à la base de données réelle (base de données de test séparée) et une restauration des transactions après chaque test.

Quel est l'impact des gardes globales sur les performances ?

Des gardes mondiaux interviennent à chaque demande, alors gardez-les rapides. La vérification JWT dure généralement de 1 à 5 ms. Évitez les recherches de base de données dans les gardes : chargez les autorisations lors de la création du jeton et incluez-les dans la charge utile JWT. Si vous avez besoin de nouvelles autorisations à chaque requête, utilisez Redis avec une durée de vie courte plutôt que d'accéder à la base de données.


Prochaines étapes

La création d'API d'entreprise à grande échelle nécessite la bonne architecture dès le premier jour. L'équipe d'ingénierie d'ECOSIRE a construit et exploité un backend NestJS 11 de 56 modules gérant des flux de travail multi-locataires complexes, la facturation Stripe, la gestion des licences et des analyses basées sur l'IA.

Que vous ayez besoin d'une API NestJS personnalisée, d'une intégration Odoo ERP ou d'une plate-forme d'entreprise complète, notre équipe apporte à votre projet des modèles éprouvés en production. Découvrez nos services de développement pour voir comment nous pouvons accélérer votre prochaine version.

E

Rédigé par

ECOSIRE Research and Development Team

Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.

Discutez sur WhatsApp