Comprendre les Modules, Controllers et Services dans NestJS

Comprendre les Modules, Controllers et Services dans NestJS

NestJS est souvent présenté comme un framework Node.js « opiniâtre » : il impose une structure claire et des conventions inspirées d’Angular, tout en restant compatible avec l’écosystème Express/Fastify. Au cœur de cette structure, trois briques reviennent partout : les modules, les controllers et les services.

Dans ce tutoriel, on va clarifier le rôle de chacun, comprendre comment ils s’assemblent, et construire un exemple concret : un module users avec son controller et son service. L’objectif est aussi d’ancrer une bonne pratique essentielle : le Single Responsibility Principle (SRP), ou principe de responsabilité unique.

Prérequis : connaissances de base en TypeScript et HTTP/REST. Les exemples utilisent NestJS (TypeScript).

Le pattern « MVC » adapté à NestJS

On parle souvent de MVC (Model-View-Controller). Dans une API NestJS, on a rarement une « View » au sens rendu HTML. Le découpage ressemble plutôt à :

  • Controller : reçoit la requête HTTP, valide/transforme les entrées (via pipes), appelle la couche métier, et renvoie une réponse.
  • Service : contient la logique métier (règles, orchestration), et délègue l’accès aux données à des repositories/ORM si besoin.
  • Model : entités/DTO (Data Transfer Objects) et éventuellement schémas ORM (TypeORM/Prisma, etc.).

Une analogie utile : le controller est le « standard téléphonique » (il reçoit et redirige), le service est « l’équipe métier » (elle sait quoi faire), et le module est « le département » (il regroupe, configure et expose ce qui est nécessaire).

Les Modules NestJS (@Module) : regrouper et composer

Le module est l’unité d’organisation principale dans NestJS. Un module :

  • regroupe des controllers et des providers (services, repositories, etc.) ;
  • déclare ses dépendances via imports ;
  • expose des providers à d’autres modules via exports.

Structure typique d’un module

Un module se déclare avec le décorateur @Module() :

import { Module } from '@nestjs/common';

@Module({
  imports: [],
  controllers: [],
  providers: [],
  exports: [],
})
export class ExampleModule {}

Imports / Exports : pourquoi c’est important

Dans NestJS, un provider (service) déclaré dans un module n’est pas automatiquement accessible ailleurs. Pour le réutiliser :

  • le module qui le déclare doit l’ajouter à exports ;
  • le module consommateur doit l’ajouter à imports.

Ce mécanisme force une architecture modulaire et évite les dépendances implicites.

Les Controllers (@Controller) : routes HTTP et contrat d’API

Un controller est responsable de la couche HTTP : routes, paramètres, corps de requête, codes de retour, etc. Il ne devrait pas contenir de logique métier complexe.

Décorateurs de routing

NestJS fournit des décorateurs pour mapper les routes :

  • @Controller('users') : préfixe de route
  • @Get(), @Post(), @Put(), @Delete() : verbes HTTP
  • @Param(), @Query(), @Body() : extraction des données

Exemple minimal

import { Controller, Get } from '@nestjs/common';

@Controller('health')
export class HealthController {
  @Get()
  ping() {
    return { status: 'ok' };
  }
}

Les Services (@Injectable) : logique métier et réutilisabilité

Un service est un provider (un composant injectable) qui encapsule la logique métier. Il est décoré avec @Injectable() et instancié par le conteneur d’injection de dépendances (DI) de NestJS.

Pourquoi isoler la logique métier ?

  • Testabilité : on teste un service sans HTTP.
  • Réutilisabilité : plusieurs controllers (REST, GraphQL, WebSocket) peuvent appeler le même service.
  • Lisibilité : le controller reste fin et centré sur le transport.

Exemple minimal

import { Injectable } from '@nestjs/common';

@Injectable()
export class HealthService {
  getStatus() {
    return { status: 'ok' };
  }
}

Comment Modules, Controllers et Services interagissent

Le flux classique ressemble à ceci :

  1. Le module déclare un controller et un service.
  2. Le controller reçoit une requête HTTP.
  3. Le controller appelle une méthode du service.
  4. Le service exécute la logique (validation métier, accès données, etc.).
  5. Le controller renvoie la réponse.

Le point clé : le controller n’instancie pas le service. Il le reçoit via le constructeur grâce à l’injection de dépendances.

Exemple concret : créer un module “users” (module + controller + service)

On va créer une petite API REST pour les utilisateurs. Pour rester simple et testable, on utilisera un stockage en mémoire (un tableau). Dans un projet réel, cette couche serait remplacée par un repository (TypeORM/Prisma), mais la structure module/controller/service resterait identique.

Schéma d'interaction entre UsersModule, UsersController et UsersService

Arborescence recommandée

Une organisation simple, lisible et alignée avec les conventions NestJS :

src/
  users/
    dto/
      create-user.dto.ts
      update-user.dto.ts
    users.controller.ts
    users.service.ts
    users.module.ts
  app.module.ts
  main.ts

1) Les DTO : définir le contrat d’entrée

Les DTO (Data Transfer Objects) décrivent la forme des données attendues en entrée. Dans NestJS, ils servent souvent de base à la validation (avec class-validator). Même sans détailler la validation ici, c’est une bonne pratique de les créer.

// src/users/dto/create-user.dto.ts
export class CreateUserDto {
  // Dans un vrai projet, ajoutez des décorateurs class-validator
  // ex: @IsEmail(), @IsString(), etc.
  email: string;
  firstName: string;
  lastName: string;
}
// src/users/dto/update-user.dto.ts
export class UpdateUserDto {
  // Champs optionnels pour une mise à jour partielle
  email?: string;
  firstName?: string;
  lastName?: string;
}

2) Le service : logique métier UsersService

Le service encapsule les opérations CRUD. Il ne connaît pas HTTP : il expose juste des méthodes TypeScript.

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

type User = {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
};

@Injectable()
export class UsersService {
  // Stockage en mémoire pour l'exemple
  private users: User[] = [];

  findAll(): User[] {
    return this.users;
  }

  findOne(id: string): User {
    const user = this.users.find((u) => u.id === id);
    if (!user) {
      throw new NotFoundException(`User ${id} not found`);
    }
    return user;
  }

  create(dto: CreateUserDto): User {
    const user: User = {
      id: crypto.randomUUID(),
      email: dto.email,
      firstName: dto.firstName,
      lastName: dto.lastName,
    };

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

  update(id: string, dto: UpdateUserDto): User {
    const user = this.findOne(id);

    // Mise à jour partielle
    const updated: User = {
      ...user,
      ...dto,
    };

    this.users = this.users.map((u) => (u.id === id ? updated : u));
    return updated;
  }

  remove(id: string): void {
    // findOne pour déclencher un 404 si absent
    this.findOne(id);
    this.users = this.users.filter((u) => u.id !== id);
  }
}

Point SRP : ce service a une responsabilité claire : gérer la logique liée aux utilisateurs. Il ne gère ni le parsing HTTP, ni la configuration du serveur, ni la sérialisation avancée.

3) Le controller : routes REST UsersController

Le controller expose l’API HTTP et délègue au service. Il utilise les décorateurs NestJS pour récupérer les paramètres.

// src/users/users.controller.ts
import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.usersService.findOne(id);
  }

  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Put(':id')
  update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
    return this.usersService.update(id, dto);
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    this.usersService.remove(id);
    return { deleted: true };
  }
}

Piège courant : mettre des règles métier (ex: « un email doit être unique ») directement dans le controller. Gardez ces règles dans le service (ou mieux : dans une couche domaine dédiée si votre projet grossit).

4) Le module : assembler UsersModule

Le module déclare les éléments de la feature users.

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

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // utile si un autre module doit réutiliser UsersService
})
export class UsersModule {}

5) Brancher le module dans AppModule

Enfin, on importe le module dans le module racine.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';

@Module({
  imports: [UsersModule],
})
export class AppModule {}

6) Démarrer l’application

Un main.ts typique démarre l’application NestJS. Dans un projet standard, il ressemble à ceci :

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

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

  // Bonnes pratiques : préfixe global et CORS selon le contexte
  // app.setGlobalPrefix('api');
  // app.enableCors();

  await app.listen(3000);
}

bootstrap();

Tester rapidement les endpoints

# Créer un utilisateur
curl -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","firstName":"Ada","lastName":"Lovelace"}'

# Lister
curl http://localhost:3000/users

Single Responsibility Principle (SRP) : l’appliquer concrètement

Le SRP dit qu’un composant ne doit avoir qu’une seule raison de changer. Appliqué à NestJS :

  • Controller change si le contrat HTTP change (routes, payload, codes).
  • Service change si la logique métier change (règles, workflow).
  • Module change si la composition change (dépendances, providers exposés).

Si vous respectez ce découpage, votre code devient plus simple à maintenir et à tester.

Bonnes pratiques d’organisation et d’architecture

1) Un dossier par feature (vertical slicing)

Regrouper controller/service/dto dans src/users évite de disperser le code. C’est particulièrement efficace quand l’application grandit.

2) Garder les controllers fins

Un controller devrait idéalement :

  • extraire les inputs (@Param, @Body) ;
  • appeler le service ;
  • retourner le résultat.

Tout ce qui ressemble à une règle métier, une transaction, un calcul, une orchestration entre plusieurs dépendances : direction service.

3) Exports : n’exportez que ce qui est utile

Exporter un service dans un module est un contrat public. Si un service n’est utilisé que dans le module, ne l’exportez pas : cela limite le couplage.

4) Préparer la validation et la sécurité

Dans une API réelle, ajoutez :

  • Validation des DTO via Validation NestJS (pipes + class-validator).
  • Authentification/autorisation via Guards.
  • Gestion d’erreurs cohérente (exceptions NestJS, filtres si nécessaire).

5) Tester au bon niveau

  • Unit tests : testez le service sans HTTP (mocks de dépendances).
  • e2e tests : testez les routes du controller avec le module monté.

La séparation controller/service rend ces tests beaucoup plus simples.

Conclusion

Les modules, controllers et services sont la colonne vertébrale de NestJS :

  • le module compose et encapsule une fonctionnalité ;
  • le controller expose un contrat HTTP ;
  • le service porte la logique métier et reste réutilisable.

En appliquant le Single Responsibility Principle et une organisation par feature (comme avec UsersModule), vous obtenez une base saine, testable et évolutive.

Pour aller plus loin : ajoutez la validation des DTO, branchez une couche de persistance (TypeORM/Prisma) et introduisez des Guards/Interceptors pour standardiser sécurité et réponses, en vous appuyant sur la documentation officielle NestJS.

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *