À propos de ce cours
Objectifs de la leçon
- Expliquer le rôle d'un service et ce qu'il doit contenir.
- Créer un service avec @Injectable et le déclarer dans un module.
- Faire collaborer plusieurs services.
- Constater qu'un service est partagé (instance unique).
- Éviter le piège de l'@Injectable oublié.
Provider et service
Dans NestJS, un provider est tout ce que le conteneur peut fournir et injecter : une classe, une valeur, le résultat d'une fonction (leçon 7.4). Le service est le provider le plus courant : une classe décorée par @Injectable() qui contient la logique d'une fonctionnalité.
| Un service contient | Un service ne contient pas |
|---|---|
| les règles métier (une note est entre 0 et 20, un groupe a une capacité maximale) | la lecture de la requête HTTP (@Param, @Body) |
| les calculs (moyennes, statistiques) | le choix des codes de statut de succès |
| l'accès aux données (directement ou via un repository) | de la mise en forme pour l'affichage |
| les appels à d'autres services | des données propres à une requête (sauf portée particulière, leçon 7.5) |
Il peut en revanche lever des exceptions HTTP (NotFoundException…) : c'est une pratique courante et acceptée dans NestJS.
Créer un service
nest g s stagiaires --no-spec # crée stagiaires.service.ts et l'ajoute aux providers du module
📄 src/stagiaires.service.ts
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
export interface Stagiaire {
id: number;
nom: string;
groupe: string;
}
@Injectable()
export class StagiairesService {
private readonly logger = new Logger(StagiairesService.name);
private readonly stagiaires: Stagiaire[] = [
{ id: 1, nom: 'Ben Ali', groupe: 'DEV101' },
{ id: 2, nom: 'Trabelsi', groupe: 'DEV101' },
{ id: 3, nom: 'Gharbi', groupe: 'DEV102' },
];
readonly creeLe = new Date().toISOString().slice(11, 23);
constructor() {
this.logger.log('instance de StagiairesService créée');
}
findAll(): Stagiaire[] {
return this.stagiaires;
}
findOne(id: number): Stagiaire {
const s = this.stagiaires.find((x) => x.id === id);
if (!s) throw new NotFoundException(`Stagiaire ${id} introuvable`);
return s;
}
}
@Injectable()indique à NestJS que la classe peut être gérée par le conteneur, et surtout déclenche l'enregistrement des types du constructeur (design:paramtypes, leçon 4.7).private readonly logger = new Logger(StagiairesService.name): unLoggeravec le nom de la classe comme contexte. C'est l'un des raresnewlégitimes dans un service, car ce n'est pas un provider.readonlysur le tableau : la référence ne change pas (son contenu, si).
Des services qui collaborent
Le service des notes a besoin de vérifier que le stagiaire existe et de connaître son nom : il injecte le service des stagiaires.
📄 src/notes.service.ts
import { BadRequestException, Injectable } from '@nestjs/common';
import { StagiairesService } from './stagiaires.service';
interface Note {
stagiaireId: number;
matiere: string;
valeur: number;
}
@Injectable()
export class NotesService {
private readonly notes: Note[] = [];
constructor(private readonly stagiairesService: StagiairesService) {} // un service en utilise un autre
ajouter(stagiaireId: number, matiere: string, valeur: number): Note {
this.stagiairesService.findOne(stagiaireId); // 404 si le stagiaire n'existe pas
if (valeur < 0 || valeur > 20) throw new BadRequestException('La note doit être comprise entre 0 et 20');
const note = { stagiaireId, matiere, valeur };
this.notes.push(note);
return note;
}
bulletin(stagiaireId: number) {
const stagiaire = this.stagiairesService.findOne(stagiaireId);
const notes = this.notes.filter((n) => n.stagiaireId === stagiaireId);
const moyenne = notes.length ? notes.reduce((s, n) => s + n.valeur, 0) / notes.length : null;
return { stagiaire: stagiaire.nom, notes: notes.map((n) => `${n.matiere} : ${n.valeur}`), moyenne };
}
}
Deux contrôleurs utilisent ces services ; NotesController injecte même les deux :
📄 src/controllers.ts
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { NotesService } from './notes.service';
import { StagiairesService } from './stagiaires.service';
@Controller('stagiaires')
export class StagiairesController {
constructor(private readonly stagiairesService: StagiairesService) {}
@Get()
findAll() {
return { instance: this.stagiairesService.creeLe, liste: this.stagiairesService.findAll().map((s) => s.nom) };
}
}
@Controller('notes')
export class NotesController {
constructor(
private readonly notesService: NotesService,
private readonly stagiairesService: StagiairesService, // même instance que dans StagiairesController
) {}
@Post()
ajouter(@Body() body: { stagiaireId: number; matiere: string; valeur: number }) {
return this.notesService.ajouter(body.stagiaireId, body.matiere, body.valeur);
}
@Get('bulletin/:id')
bulletin(@Param('id', ParseIntPipe) id: number) {
return { instance: this.stagiairesService.creeLe, ...this.notesService.bulletin(id) };
}
}
📄 src/app.module.ts
import { Module } from '@nestjs/common';
import { NotesController, StagiairesController } from './controllers';
import { NotesService } from './notes.service';
import { StagiairesService } from './stagiaires.service';
@Module({
controllers: [StagiairesController, NotesController],
providers: [StagiairesService, NotesService],
})
export class AppModule {}
Journal de démarrage et réponses obtenues :
[StagiairesService] instance de StagiairesService créée
GET /api/stagiaires → 200 {"instance":"22:28:54.615","liste":["Ben Ali","Trabelsi","Gharbi"]}
POST /api/notes → 201 {"stagiaireId":1,"matiere":"NestJS","valeur":15}
POST /api/notes → 201 {"stagiaireId":1,"matiere":"SQL","valeur":12}
POST /api/notes → 404 {"message":"Stagiaire 9 introuvable","error":"Not Found","statusCode":404}
POST /api/notes → 400 {"message":"La note doit être comprise entre 0 et 20","error":"Bad Request","statusCode":400}
GET /api/notes/bulletin/1 → 200 {"instance":"22:28:54.615","stagiaire":"Ben Ali","notes":["NestJS : 15","SQL : 12"],"moyenne":13.5}
GET /api/notes/bulletin/2 → 200 {"instance":"22:28:54.615","stagiaire":"Trabelsi","notes":[],"moyenne":null}
Une seule instance, partagée
Trois classes, une seule instance de StagiairesService.
La trace le prouve : le message « instance de StagiairesService créée » n'apparaît qu'une fois, au démarrage, et l'heure de création (instance) est identique dans les réponses des deux contrôleurs. StagiairesController, NotesController et NotesService partagent le même objet, donc les mêmes données.
Conséquence importante : un service ne doit pas stocker de données propres à une requête (l'utilisateur courant, par exemple) dans une propriété, car elles seraient partagées entre tous les utilisateurs. Les données d'une requête passent en paramètre des méthodes (ou par une portée particulière, leçon 7.5).
Le piège de l'@Injectable oublié
Que se passe-t-il si on oublie @Injectable() sur un service qui a une dépendance ?
import { Module } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
export class JournalService { // ✗ @Injectable() oublié
ecrire(m: string) { return `[journal] ${m}`; }
}
export class StagiairesService { // ✗ @Injectable() oublié, alors qu'il a une dépendance
constructor(private readonly journal: JournalService) {}
inscrire(nom: string) {
return this.journal.ecrire(`${nom} inscrit`);
}
}
@Module({ providers: [JournalService, StagiairesService] })
class AppModule {}
async function main() {
const app = await NestFactory.create(AppModule, { logger: false });
await app.init();
console.log('démarrage réussi');
try {
console.log(app.get(StagiairesService).inscrire('Ben Ali'));
} catch (e) {
console.log(`plantage à l'utilisation : ${(e as Error).message}`);
}
await app.close();
}
main();
Résultat réellement obtenu :
démarrage réussi
plantage à l'utilisation : Cannot read properties of undefined (reading 'ecrire')
L'application démarre sans erreur… puis plante à la première utilisation. Sans décorateur sur la classe, TypeScript ne produit pas les métadonnées des paramètres du constructeur : NestJS ne sait pas qu'il faut un JournalService et crée StagiairesService sans argument. this.journal vaut alors undefined. Ce bug est sournois car il n'apparaît qu'à l'exécution de la route concernée.
Règle: mettez toujours @Injectable() sur une classe déclarée dans providers, même si elle n'a pas (encore) de dépendance. La CLI le fait pour vous.
Activités
Activité 1 Un service de groupes
Créez GroupesService (liste de groupes { code, capacite }, findOne(code) avec 404) et modifiez StagiairesService pour qu'il ait une méthode inscrire(nom, code) qui refuse l'inscription (409) si le groupe est complet. Qui injecte qui ?
Voir le corrigé
@Injectable()
export class GroupesService {
private readonly groupes = [{ code: 'DEV101', capacite: 2 }, { code: 'DEV102', capacite: 20 }];
findOne(code: string) {
const g = this.groupes.find((x) => x.code === code);
if (!g) throw new NotFoundException(`Groupe ${code} introuvable`);
return g;
}
}
@Injectable()
export class StagiairesService {
constructor(private readonly groupesService: GroupesService) {}
inscrire(nom: string, code: string) {
const groupe = this.groupesService.findOne(code);
const effectif = this.stagiaires.filter((s) => s.groupe === code).length;
if (effectif >= groupe.capacite) throw new ConflictException(`Le groupe ${code} est complet`);
const s = { id: this.stagiaires.length + 1, nom, groupe: code };
this.stagiaires.push(s);
return s;
}
}
StagiairesService injecte GroupesService (et non l'inverse) : la règle « un groupe a une capacité » appartient aux groupes, la règle « on n'inscrit pas dans un groupe complet » à l'inscription. Les deux doivent figurer dans providers.
Activité 2 Le danger d'un état partagé
Un développeur ajoute dans NotesService une propriété private utilisateurCourant = '', remplie au début de chaque requête par le contrôleur, puis utilisée dans ajouter() pour enregistrer l'auteur de la note. Expliquez le bug qui apparaîtra en production. Proposez une correction.
Voir le corrigé
Le service est unique et partagé. Si deux formateurs envoient une note presque en même temps, le second écrase utilisateurCourant avant que la méthode du premier ne l'utilise (surtout si elle contient un await) : la note du premier sera attribuée au second. Correction : passer l'auteur en paramètre, ajouter(stagiaireId, matiere, valeur, auteur), ou utiliser un provider de portée requête (leçon 7.5).
Activité 3 Logger
Ajoutez à NotesService un Logger qui écrit « note ajoutée : <matière> <valeur> pour le stagiaire <id> » à chaque ajout, et un avertissement (logger.warn) quand une note est inférieure à 8. Lancez l'application et observez le format des messages.
Voir le corrigé
private readonly logger = new Logger(NotesService.name);
ajouter(stagiaireId: number, matiere: string, valeur: number) {
// … vérifications …
this.logger.log(`note ajoutée : ${matiere} ${valeur} pour le stagiaire ${stagiaireId}`);
if (valeur < 8) this.logger.warn(`note faible (${valeur}) pour le stagiaire ${stagiaireId}`);
}
Les messages apparaissent avec la date, le niveau (LOG en vert, WARN en jaune) et le contexte [NotesService]. Utiliser le Logger plutôt que console.log permet de filtrer les niveaux en production (leçon 3.3).
Activité 4 Reproduire le piège
Dans votre projet, retirez @Injectable() de NotesService (qui dépend de StagiairesService). L'application démarre-t-elle ? Appelez POST /api/notes: quel statut et quel message dans le terminal ? Remettez le décorateur.
Voir le corrigé
L'application démarre. L'appel renvoie 500 « Internal server error » et le terminal affiche TypeError: Cannot read properties of undefined (reading 'findOne'): this.stagiairesService est undefined. C'est exactement le comportement démontré dans la leçon.
À retenir
- Un provider est ce que le conteneur fournit ; le service, une classe @Injectable, en est la forme courante.
- Le service contient la logique métier et l'accès aux données, pas la lecture de la requête.
- Un service peut injecter d'autres services ; tous doivent être déclarés dans providers.
- Par défaut, une seule instance est partagée : pas de données propres à une requête dans ses propriétés.
- Toujours @Injectable() : sans lui, l'application démarre mais les dépendances valent undefined.