NestJS : Créer une API REST complète

Guide complet pour créer une API REST professionnelle avec NestJS. Controllers, Services, Modules, validation avec class-validator et gestion des erreurs expliqués.

Guide NestJS pour créer une API REST complète

NestJS s'impose comme le framework Node.js de référence pour créer des applications serveur scalables et maintenables. Inspiré par Angular, il apporte une architecture modulaire, l'injection de dépendances et TypeScript natif. Construire une API REST professionnelle devient structuré et prévisible.

Pourquoi NestJS en 2026

NestJS 11 introduit des améliorations de performance significatives, un support natif des signaux pour l'arrêt gracieux, et une intégration simplifiée avec les ORMs modernes comme Prisma et Drizzle. Le framework reste backward-compatible avec les versions précédentes.

Installation et configuration du projet

La CLI NestJS génère un projet complet avec une structure prête pour la production. TypeScript, ESLint et les tests unitaires sont configurés automatiquement.

bash
# terminal
# Installation globale de la CLI NestJS
npm install -g @nestjs/cli

# Création d'un nouveau projet
nest new my-api

# Navigation dans le projet
cd my-api

# Démarrage en mode développement avec hot-reload
npm run start:dev

Cette commande crée un projet avec une structure de fichiers organisée et les dépendances essentielles pré-installées.

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

async function bootstrap() {
  // Création de l'application NestJS
  const app = await NestFactory.create(AppModule);

  // Activation de la validation globale
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,           // Supprime les propriétés non décorées
    forbidNonWhitelisted: true, // Rejette les requêtes avec propriétés inconnues
    transform: true,            // Transforme les payloads en instances de DTO
  }));

  // Configuration du préfixe global pour toutes les routes
  app.setGlobalPrefix('api');

  await app.listen(3000);
}
bootstrap();

Cette configuration active la validation automatique des requêtes et préfixe toutes les routes avec /api.

Comprendre l'architecture modulaire

NestJS organise le code en modules, chacun encapsulant un domaine fonctionnel. Les modules déclarent les controllers (entrées HTTP), providers (services métier) et imports (dépendances).

src/app.module.tstypescript
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { ProductsModule } from './products/products.module';
import { AuthModule } from './auth/auth.module';

// Le module racine importe tous les modules de l'application
@Module({
  imports: [
    UsersModule,    // Gestion des utilisateurs
    ProductsModule, // Catalogue produits
    AuthModule,     // Authentification
  ],
})
export class AppModule {}

Chaque module métier suit la même structure : un fichier module, un controller et un service.

src/users/users.module.tstypescript
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  // Controllers gèrent les requêtes HTTP
  controllers: [UsersController],
  // Providers sont injectables dans tout le module
  providers: [UsersService],
  // Exports rendent les providers disponibles pour d'autres modules
  exports: [UsersService],
})
export class UsersModule {}

L'export de UsersService permet aux autres modules d'importer UsersModule et d'utiliser ce service via injection de dépendances.

Création d'un Controller CRUD complet

Les controllers définissent les routes HTTP et délèguent la logique métier aux services. Les décorateurs de NestJS rendent le code expressif et auto-documenté.

src/users/users.controller.tstypescript
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

// Préfixe de route : /api/users
@Controller('users')
export class UsersController {
  // Injection du service via le constructeur
  constructor(private readonly usersService: UsersService) {}

  // POST /api/users - Création d'un utilisateur
  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() createUserDto: CreateUserDto): Promise<User> {
    // Le DTO est automatiquement validé avant d'arriver ici
    return this.usersService.create(createUserDto);
  }

  // GET /api/users - Liste avec pagination
  @Get()
  async findAll(
    @Query('page', new ParseIntPipe({ optional: true })) page: number = 1,
    @Query('limit', new ParseIntPipe({ optional: true })) limit: number = 10,
  ): Promise<{ data: User[]; total: number }> {
    return this.usersService.findAll(page, limit);
  }

  // GET /api/users/:id - Récupération par ID
  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number): Promise<User> {
    // ParseIntPipe convertit et valide automatiquement le paramètre
    return this.usersService.findOne(id);
  }

  // PUT /api/users/:id - Mise à jour complète
  @Put(':id')
  async update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ): Promise<User> {
    return this.usersService.update(id, updateUserDto);
  }

  // DELETE /api/users/:id - Suppression
  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
    await this.usersService.remove(id);
  }
}

Les pipes comme ParseIntPipe assurent la conversion et validation des paramètres. En cas d'échec, une erreur 400 est automatiquement renvoyée.

Pipes de validation natifs

NestJS fournit plusieurs pipes intégrés : ParseIntPipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe. Chacun valide et transforme les données entrantes avant l'exécution du handler.

Implémentation du Service métier

Les services encapsulent la logique métier et les accès aux données. Marqués avec @Injectable(), ils sont gérés par le conteneur d'injection de dépendances de NestJS.

src/users/users.service.tstypescript
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  // Simulation d'une base de données en mémoire
  private users: User[] = [];
  private idCounter = 1;

  async create(createUserDto: CreateUserDto): Promise<User> {
    // Création de l'entité utilisateur
    const user: User = {
      id: this.idCounter++,
      ...createUserDto,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    this.users.push(user);
    return user;
  }

  async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
    // Calcul de la pagination
    const start = (page - 1) * limit;
    const end = start + limit;

    return {
      data: this.users.slice(start, end),
      total: this.users.length,
    };
  }

  async findOne(id: number): Promise<User> {
    const user = this.users.find(u => u.id === id);

    // Levée d'exception si l'utilisateur n'existe pas
    if (!user) {
      throw new NotFoundException(`Utilisateur avec l'ID ${id} introuvable`);
    }

    return user;
  }

  async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
    const user = await this.findOne(id);

    // Fusion des données existantes avec les mises à jour
    Object.assign(user, updateUserDto, { updatedAt: new Date() });

    return user;
  }

  async remove(id: number): Promise<void> {
    const index = this.users.findIndex(u => u.id === id);

    if (index === -1) {
      throw new NotFoundException(`Utilisateur avec l'ID ${id} introuvable`);
    }

    this.users.splice(index, 1);
  }

  // Méthode utilitaire pour d'autres services
  async findByEmail(email: string): Promise<User | undefined> {
    return this.users.find(u => u.email === email);
  }
}

L'exception NotFoundException génère automatiquement une réponse HTTP 404 avec un message d'erreur formaté.

Prêt à réussir tes entretiens Node.js / NestJS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Validation des données avec class-validator

Les DTOs (Data Transfer Objects) définissent la structure des données entrantes. Les décorateurs de class-validator spécifient les règles de validation appliquées automatiquement.

src/users/dto/create-user.dto.tstypescript
import {
  IsEmail,
  IsNotEmpty,
  IsString,
  MinLength,
  MaxLength,
  IsOptional,
  Matches,
} from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty({ message: "L'email est obligatoire" })
  @IsEmail({}, { message: "Format d'email invalide" })
  email: string;

  @IsNotEmpty({ message: 'Le mot de passe est obligatoire' })
  @MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
  @MaxLength(50, { message: 'Le mot de passe ne peut pas dépasser 50 caractères' })
  @Matches(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
    { message: 'Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre' }
  )
  password: string;

  @IsNotEmpty({ message: 'Le prénom est obligatoire' })
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  firstName: string;

  @IsNotEmpty({ message: 'Le nom est obligatoire' })
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  lastName: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  phone?: string;
}

Pour les mises à jour partielles, PartialType rend tous les champs optionnels tout en conservant les validations.

src/users/dto/update-user.dto.tstypescript
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

// Tous les champs de CreateUserDto deviennent optionnels
// Le mot de passe est exclu des mises à jour standard
export class UpdateUserDto extends PartialType(
  OmitType(CreateUserDto, ['password'] as const)
) {}

L'entité représente la structure des données stockées.

src/users/entities/user.entity.tstypescript
export class User {
  id: number;
  email: string;
  password: string;
  firstName: string;
  lastName: string;
  phone?: string;
  createdAt: Date;
  updatedAt: Date;
}

Gestion centralisée des erreurs

NestJS fournit des exceptions HTTP intégrées. Un filtre d'exception global permet de personnaliser le format des réponses d'erreur.

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

// Capture toutes les HttpException
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    // Récupération du message d'erreur (peut être string ou objet)
    const exceptionResponse = exception.getResponse();
    const message = typeof exceptionResponse === 'string'
      ? exceptionResponse
      : (exceptionResponse as any).message;

    // Format de réponse standardisé
    response.status(status).json({
      success: false,
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
      method: request.method,
      message: message,
    });
  }
}

Pour capturer les erreurs non-HTTP (erreurs système, exceptions non gérées), un second filtre assure une gestion complète.

src/common/filters/all-exceptions.filter.tstypescript
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Request, Response } from 'express';

// Capture TOUTES les exceptions (y compris les erreurs système)
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    // Détermination du code HTTP et du message
    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;

    const message = exception instanceof HttpException
      ? exception.message
      : 'Erreur interne du serveur';

    // Log de l'erreur pour le debugging
    console.error('Exception caught:', exception);

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

Enregistrement global des filtres dans le module principal.

src/main.ts (mise à jour)typescript
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';

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

  // Validation globale
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }));

  // Filtres d'exceptions globaux
  app.useGlobalFilters(
    new AllExceptionsFilter(),
    new HttpExceptionFilter(),
  );

  app.setGlobalPrefix('api');
  await app.listen(3000);
}
bootstrap();
Ordre des filtres

L'ordre d'enregistrement des filtres est important. Le premier filtre enregistré est le dernier exécuté. AllExceptionsFilter doit être enregistré avant HttpExceptionFilter pour servir de fallback.

Intégration avec Prisma ORM

Prisma simplifie les interactions avec la base de données grâce à un client typé généré automatiquement. Voici l'intégration complète avec NestJS.

bash
# terminal
# Installation de Prisma
npm install prisma @prisma/client

# Initialisation de Prisma avec PostgreSQL
npx prisma init --datasource-provider postgresql

Définition du schéma de données.

prisma/schema.prismaprisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  firstName String   @map("first_name")
  lastName  String   @map("last_name")
  phone     String?
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // Relations
  posts     Post[]

  @@map("users")
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String
  published Boolean  @default(false)
  authorId  Int      @map("author_id")
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @updatedAt @map("updated_at")

  // Relation vers User
  author    User     @relation(fields: [authorId], references: [id])

  @@map("posts")
}

Création d'un module Prisma réutilisable.

src/prisma/prisma.service.tstypescript
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  // Connexion automatique au démarrage du module
  async onModuleInit() {
    await this.$connect();
  }

  // Déconnexion propre à l'arrêt de l'application
  async onModuleDestroy() {
    await this.$disconnect();
  }
}
src/prisma/prisma.module.tstypescript
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

// @Global rend le service disponible dans toute l'application
@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}

Mise à jour du service Users pour utiliser Prisma.

src/users/users.service.ts (version Prisma)typescript
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from '@prisma/client';
import * as bcrypt from 'bcrypt';

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  async create(createUserDto: CreateUserDto): Promise<Omit<User, 'password'>> {
    // Vérification d'unicité de l'email
    const existingUser = await this.prisma.user.findUnique({
      where: { email: createUserDto.email },
    });

    if (existingUser) {
      throw new ConflictException('Cet email est déjà utilisé');
    }

    // Hashage du mot de passe
    const hashedPassword = await bcrypt.hash(createUserDto.password, 10);

    // Création de l'utilisateur
    const user = await this.prisma.user.create({
      data: {
        ...createUserDto,
        password: hashedPassword,
      },
    });

    // Exclusion du mot de passe de la réponse
    const { password, ...result } = user;
    return result;
  }

  async findAll(page: number, limit: number): Promise<{ data: User[]; total: number }> {
    // Exécution parallèle du count et de la requête paginée
    const [data, total] = await Promise.all([
      this.prisma.user.findMany({
        skip: (page - 1) * limit,
        take: limit,
        orderBy: { createdAt: 'desc' },
        select: {
          id: true,
          email: true,
          firstName: true,
          lastName: true,
          phone: true,
          createdAt: true,
          updatedAt: true,
        },
      }),
      this.prisma.user.count(),
    ]);

    return { data: data as User[], total };
  }

  async findOne(id: number): Promise<Omit<User, 'password'>> {
    const user = await this.prisma.user.findUnique({
      where: { id },
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        phone: true,
        createdAt: true,
        updatedAt: true,
      },
    });

    if (!user) {
      throw new NotFoundException(`Utilisateur avec l'ID ${id} introuvable`);
    }

    return user as Omit<User, 'password'>;
  }

  async update(id: number, updateUserDto: UpdateUserDto): Promise<Omit<User, 'password'>> {
    // Vérification de l'existence
    await this.findOne(id);

    const user = await this.prisma.user.update({
      where: { id },
      data: updateUserDto,
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        phone: true,
        createdAt: true,
        updatedAt: true,
      },
    });

    return user as Omit<User, 'password'>;
  }

  async remove(id: number): Promise<void> {
    await this.findOne(id);
    await this.prisma.user.delete({ where: { id } });
  }
}

Intercepteurs pour la transformation des réponses

Les intercepteurs permettent de transformer les réponses de manière uniforme. Un intercepteur de transformation standardise le format de toutes les réponses API.

src/common/interceptors/transform.interceptor.tstypescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

// Interface pour le format de réponse standardisé
export interface ApiResponse<T> {
  success: boolean;
  data: T;
  timestamp: string;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map(data => ({
        success: true,
        data,
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

Un intercepteur de logging trace les requêtes et leurs temps d'exécution.

src/common/interceptors/logging.interceptor.tstypescript
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const now = Date.now();

    return next.handle().pipe(
      tap(() => {
        const response = context.switchToHttp().getResponse();
        const { statusCode } = response;
        const duration = Date.now() - now;

        // Log au format structuré
        this.logger.log(
          `${method} ${url} ${statusCode} - ${duration}ms`
        );
      }),
    );
  }
}

Conclusion

NestJS offre une architecture robuste et scalable pour créer des APIs REST professionnelles. La combinaison de TypeScript, l'injection de dépendances et les décorateurs expressifs permet de construire des applications maintenables et testables.

Checklist pour une API NestJS de qualité

  • ✅ Structure modulaire avec séparation des responsabilités
  • ✅ DTOs avec validation class-validator pour toutes les entrées
  • ✅ Services dédiés à la logique métier
  • ✅ Gestion centralisée des erreurs avec filtres d'exceptions
  • ✅ Intercepteurs pour la transformation et le logging
  • ✅ Intégration Prisma pour les accès base de données
  • ✅ ValidationPipe global avec whitelist activé
  • ✅ Préfixe API cohérent sur toutes les routes

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

La force de NestJS réside dans sa structure opinionnée qui guide naturellement vers les bonnes pratiques. Les patterns éprouvés comme l'injection de dépendances et la séparation en couches produisent du code testable et évolutif, prêt pour les applications d'entreprise.

Tags

#nestjs
#nodejs
#typescript
#rest api
#backend

Partager

Articles similaires