Cours NestJS · Module 3: TypeORM et bases de données
PROJET - un système d'inventaire des tributs
Dans cette leçon7
Les huit leçons de ce module ont mené de la première connexion à une base jusqu'à la validation au niveau du schéma. Le projet est l'endroit où tout cela doit fonctionner ensemble - et où tu verras pour la première fois que les décisions prises sur le schéma te reviennent une semaine plus tard, au moment d'écrire les requêtes.
Tu vas construire un système d'inventaire des tributs pour une légion romaine : qui possède quoi, d'où cela provient et comment cela passe de main en main.
Le domaine
Cinq domaines que le système doit couvrir :
- Les légionnaires - inscription avec validation, rangs et droits, historique des opérations de chacun.
- Le catalogue des tributs - types (or, argent, gemmes, artefacts), évaluation, appréciation de l'authenticité, historique de la découverte.
- Les cartes - coordonnées géographiques avec validation de l'intervalle, indications de recherche, statut de découverte, lien vers un tribut précis.
- Transactions et transferts - transfert sûr de la propriété entre légionnaires, échange, historique complet des opérations.
- Légions et cohortes - affectation des légionnaires, limites d'effectif, centurions.
Ne commence pas par le code. Commence par une feuille et note ce qui se rattache à quoi - la seule décision de ce projet dont la révision ultérieure coûte vraiment cher.
Étape 1 - le schéma et les relations
L'entité du légionnaire montre trois choses à la fois : les types de colonnes, les index et les contraintes au niveau de la base :
1@Entity('legionaries')
2@Check('tribute_count_non_negative', '"tributeCount" >= 0')
3@Index(['name', 'isActive'])
4export class Legionary {
5 @PrimaryGeneratedColumn()
6 id: number;
7
8 @Column({ length: 100, unique: true })
9 name: string;
10
11 @Column({ length: 50 })
12 rank: string;
13
14 @Column({ type: 'int', default: 0 })
15 tributeCount: number;
16
17 @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 })
18 totalTributeValue: number;
19
20 @Column({ default: true })
21 isActive: boolean;
22
23 @CreateDateColumn()
24 joinedAt: Date;
25
26 @OneToMany(() => Tribute, (tribute) => tribute.owner)
27 tributes: Tribute[];
28
29 @ManyToOne(() => Cohort, (cohort) => cohort.legionaries)
30 cohort: Cohort;
31}Trois décisions de ce code méritent d'être comprises, car tu les répéteras dans chaque projet.
La valeur d'un tribut est un decimal, non un float. Un nombre à virgule flottante ne sait pas représenter exactement même 0,1 ; après mille transferts, les totaux divergeront de quelques centimes, et s'agissant d'argent, c'en est fini de la confiance dans le système. precision: 15, scale: 2 signifie quinze chiffres significatifs, dont deux après la virgule.
@Index(['name', 'isActive']) crée un index composite, utile quand les requêtes filtrent sur les deux colonnes à la fois. Ajoute les index une fois que tu sais ce que tu demanderas - non « au cas où », car chacun ralentit les écritures.
@Check est la dernière ligne de défense, inscrite dans la base elle-même. La validation dans un DTO te protège d'une mauvaise requête HTTP, mais non d'un script de migration, d'un UPDATE manuel ou d'un bogue dans ton propre service. Une contrainte dans la base tient quel que soit celui qui écrit.
Choisis les relations selon la cardinalité : @OneToMany et @ManyToOne sont les deux faces d'une même relation (un légionnaire a plusieurs tributs, un tribut a un propriétaire), @OneToOne convient à une carte liée à un seul tribut, et @ManyToMany aux échanges à plusieurs participants.
Étape 2 - la couche des dépôts
Les requêtes complexes ont leur place dans un dépôt à toi :
1@Injectable()
2export class TributeRepository extends Repository<Tribute> {
3 async findValuableByLegionary(legionaryId: number, minValue: number) {
4 return this.createQueryBuilder('tribute')
5 .innerJoinAndSelect('tribute.owner', 'owner')
6 .where('owner.id = :legionaryId', { legionaryId })
7 .andWhere('tribute.value >= :minValue', { minValue })
8 .orderBy('tribute.value', 'DESC')
9 .getMany();
10 }
11}Le partage des responsabilités est simple : le dépôt sait comment demander quelque chose à la base ; le service sait quand et pourquoi. Lorsqu'un createQueryBuilder apparaît dans un service, cela signifie d'ordinaire que la méthode s'est mise à faire deux choses à la fois.
Remarque les paramètres :legionaryId et :minValue. Ce n'est pas affaire de style - assembler une requête à partir de chaînes ouvre la voie à l'injection SQL. Le Query Builder transmet toujours les valeurs séparément.
Étape 3 - le transfert, c'est-à-dire une transaction
Déplacer un tribut est le cœur de ce projet, car l'opération se compose d'actions qui doivent toutes aboutir, ou aucune :
1async transferTribute(tributeId: number, fromId: number, toId: number) {
2 return this.dataSource.transaction(async (manager) => {
3 const tribute = await manager.findOne(Tribute, {
4 where: { id: tributeId, owner: { id: fromId } },
5 relations: ['owner'],
6 });
7
8 if (!tribute) {
9 throw new BadRequestException("Tribut introuvable ou n'appartenant pas a l'expediteur");
10 }
11
12 tribute.owner = await manager.findOneOrFail(Legionary, { where: { id: toId } });
13 await manager.save(tribute);
14
15 await manager.decrement(Legionary, { id: fromId }, 'tributeCount', 1);
16 await manager.increment(Legionary, { id: toId }, 'tributeCount', 1);
17
18 return manager.save(Transaction, {
19 tribute,
20 fromLegionaryId: fromId,
21 toLegionaryId: toId,
22 });
23 });
24}Sans transaction, une panne à mi-parcours laisse le système dans un état impossible : le tribut a un nouveau propriétaire, mais le compteur de l'ancien n'a pas été décrémenté - ou pire, le compteur a baissé puis augmenté tandis que le propriétaire restait le même. De tels écarts se révèlent des mois plus tard, et l'on ne peut alors plus établir quels enregistrements disent vrai.
Et voici maintenant le plus grand piège de tout le projet. À l'intérieur d'une transaction, n'utilise que manager, jamais le dépôt injecté. Un appel à this.tributeRepository.save(...) dans ce bloc empruntera une autre connexion - une qui ignore tout de la transaction. Le code compilera, les tests passeront très probablement, et au moment du rollback cette opération-là sera écrite malgré tout. La règle est simple : si tu es dans transaction(async (manager) => ...), alors manager est ton unique chemin vers la base.
Étape 4 - les seeders
Les données initiales remplissent la base pour le développement et les tests :
1export class LegionarySeeder {
2 constructor(private dataSource: DataSource) {}
3
4 async run() {
5 const repo = this.dataSource.getRepository(Legionary);
6
7 const existing = await repo.count();
8 if (existing > 0) {
9 return;
10 }
11
12 await repo.save([
13 { name: 'Marcus Aurelius', rank: 'Centurion' },
14 { name: 'Gaius Julius', rank: 'Optio' },
15 ]);
16 }
17}Une exigence distingue le bon seeder du seeder pénible : il doit pouvoir s'exécuter deux fois. Un contrôle par count() avant l'écriture, ou un upsert, fait qu'un second appel ne duplique rien. Sans cela, chaque redémarrage de l'environnement multiplie les légionnaires, et les tests se mettent à dépendre du nombre de fois où quelqu'un a lancé le seeder auparavant.
L'ordre compte aussi : d'abord les entités indépendantes (légions, cohortes), puis celles qui s'y réfèrent (légionnaires), et en dernier les tributs et les transactions. L'ordre inverse s'achève sur une erreur de clé étrangère.
La validation à trois niveaux
Le système comporte trois endroits où des données peuvent être rejetées, et chacun attrape autre chose :
- Les DTO avec
class-validator- la forme de la requête HTTP. Tu y rejettes un nom manquant ou une valeur négative avant que quoi que ce soit n'atteigne la base. - L'entité -
unique,nullable: false,@Check. Cela tient quelle que soit la voie par laquelle les données sont arrivées. - Le service - les règles du domaine que la base ne sait pas exprimer : « un tribut ne peut être transféré à soi-même », « une cohorte compte cent hommes ».
Doubler une règle sur deux niveaux n'est pas une faute - c'est une assurance peu coûteuse.
Critères d'évaluation
Le projet est achevé lorsqu'il satisfait six conditions :
- Des entités avec relations - les quatre sortes là où elles conviennent, avec des index sur les colonnes que tu filtres réellement.
- Des migrations - le schéma vient des migrations, non de
synchronize: true. - Des dépôts comportant au moins deux requêtes écrites au Query Builder.
- Un transfert dans une transaction, avec rollback prouvé par un test.
- Des seeders idempotents et exécutés dans le bon ordre.
- La validation aux trois niveaux décrits plus haut.
Avant d'envoyer, fais un essai : interromps un transfert à mi-course - indique un destinataire inexistant - et vérifie dans la base les compteurs des deux légionnaires. Si l'un d'eux a changé, quelque part dans ton code un dépôt injecté se tient là où devrait être manager.
Envoie le lien de ton dépôt une fois terminé.
Code de cette leçon : src/tribute-inventory-project.ts
1// PROJET : Système d'inventaire des tributs romains
2import {
3 Module, Controller, Injectable, Get, Post, Put, Delete,
4 Body, Param, Query,
5} from '@nestjs/common';
6
7console.log("=== PROJET : Système d'inventaire des tributs ===");
8
9// ===========================================
10// 1. Entités du projet
11// ===========================================
12
13// Entity: Tribute
14// id, name, province, amount, type, collectedBy, verified, createdAt
15
16// Entity: Province
17// id, name, governor, population, totalTribute
18
19// Entity: Collector (ManyToOne -> Province)
20// id, name, rank, assignedProvince
21
22// Relation : Province OneToMany -> Tribute
23// Relation : Collector ManyToOne -> Province
24
25// ===========================================
26// 2. Service avec repository et QueryBuilder
27// ===========================================
28
29@Injectable()
30export class TributeInventoryService {
31 private tributes = [
32 { id: 1, name: 'Aurum Galliae', province: 'Gallia', amount: 50000, type: 'gold', verified: true },
33 { id: 2, name: 'Argentum Hispaniae', province: 'Hispania', amount: 30000, type: 'silver', verified: false },
34 { id: 3, name: 'Merces Britanniae', province: 'Britannia', amount: 15000, type: 'goods', verified: true },
35 { id: 4, name: 'Aurum Aegypti', province: 'Aegyptus', amount: 80000, type: 'gold', verified: true },
36 ];
37
38 // CRUD
39 findAll(filters?: { province?: string; type?: string; verified?: boolean }) {
40 let result = [...this.tributes];
41 if (filters?.province) result = result.filter(t => t.province === filters.province);
42 if (filters?.type) result = result.filter(t => t.type === filters.type);
43 if (filters?.verified !== undefined) result = result.filter(t => t.verified === filters.verified);
44 return result;
45 }
46
47 findById(id: number) {
48 return this.tributes.find(t => t.id === id);
49 }
50
51 create(data: any) {
52 const tribute = { id: this.tributes.length + 1, verified: false, ...data };
53 this.tributes.push(tribute);
54 return tribute;
55 }
56
57 // Transaction - transfert entre provinces
58 async transfer(fromId: number, toProvince: string) {
59 const tribute = this.findById(fromId);
60 if (tribute) {
61 const oldProvince = tribute.province;
62 tribute.province = toProvince;
63 console.log('Transfert : ' + tribute.name + ' de ' + oldProvince + ' vers ' + toProvince);
64 }
65 return tribute;
66 }
67
68 // Statistiques (agrégation)
69 getStatistics() {
70 const total = this.tributes.reduce((sum, t) => sum + t.amount, 0);
71 const byProvince = this.tributes.reduce((acc, t) => {
72 acc[t.province] = (acc[t.province] || 0) + t.amount;
73 return acc;
74 }, {} as Record<string, number>);
75
76 return { total, byProvince, count: this.tributes.length };
77 }
78}
79
80// ===========================================
81// 3. Contrôleur avec API complète
82// ===========================================
83
84@Controller('inventory')
85export class InventoryController {
86 constructor(private service: TributeInventoryService) {}
87
88 @Get()
89 findAll(@Query('province') province?: string, @Query('type') type?: string) {
90 return this.service.findAll({ province, type });
91 }
92
93 @Get('stats')
94 getStats() { return this.service.getStatistics(); }
95
96 @Get(':id')
97 findOne(@Param('id') id: string) { return this.service.findById(+id); }
98
99 @Post()
100 create(@Body() data: any) { return this.service.create(data); }
101}
102
103@Module({
104 controllers: [InventoryController],
105 providers: [TributeInventoryService],
106})
107export class InventoryModule {}
108
109console.log("\n=== STRUCTURE DU PROJET ===");
110console.log("Entités avec relations (Province, Tribute, Collector)");
111console.log("Repository Pattern avec CRUD");
112console.log("QueryBuilder pour des requêtes avancées");
113console.log("Transactions pour des transferts sûrs");
114console.log("Seeders avec les données initiales");
115console.log("Validation à plusieurs niveaux (DTO + contraintes DB)");
116Tu as repéré une erreur dans cette leçon ?