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 :
- Le module déclare un controller et un service.
- Le controller reçoit une requête HTTP.
- Le controller appelle une méthode du service.
- Le service exécute la logique (validation métier, accès données, etc.).
- 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.

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