Dans une application NestJS connectée à une base de données relationnelle, vos entités TypeORM ne vivent jamais seules : elles s’assemblent pour représenter votre domaine (utilisateurs, réservations, services, etc.). Comprendre les relations est donc indispensable pour modéliser correctement vos tables, charger les données efficacement et éviter des pièges classiques (relations circulaires, requêtes N+1…).
Dans ce tutoriel, on va voir pas à pas comment fonctionnent les cardinalités (1-N, N-1, N-N), comment utiliser @OneToMany et @ManyToOne, quand et pourquoi ajouter @JoinColumn, et comment récupérer les relations avec find({ relations: ... }). Les exemples s’appuient sur les entités user.entity.ts et booking.entity.ts du projet (NestJS + TypeORM).
Prérequis : connaître les bases de NestJS (modules/services/controllers) et de TypeORM (entités, repositories). Pour la référence officielle : TypeORM Documentation.
Comprendre les cardinalités : 1-N, N-1, N-N
Une relation décrit combien d’enregistrements d’une table peuvent être associés à une autre. On parle de cardinalité :
- 1-N (One-to-Many) : un utilisateur peut avoir plusieurs réservations. Exemple : User → Bookings.
- N-1 (Many-to-One) : plusieurs réservations appartiennent à un utilisateur. C’est la relation inverse : Booking → User.
- N-N (Many-to-Many) : un utilisateur peut avoir plusieurs rôles et un rôle peut appartenir à plusieurs utilisateurs. Cela implique une table de jointure.
Point clé : en base relationnelle, la plupart du temps, la relation se matérialise via une clé étrangère. Dans un 1-N, la clé étrangère se trouve du côté N (le “many”).
Règle pratique : le décorateur @ManyToOne est généralement le côté “propriétaire” de la relation, car c’est lui qui porte la clé étrangère.
@OneToMany : le côté “un” de la relation
@OneToMany se place sur l’entité du côté “un”. Dans notre cas, un User possède plusieurs Booking.
Conceptuellement, User expose une collection de bookings. Mais attention : @OneToMany ne crée pas la clé étrangère en base à lui seul. Il sert à définir l’inverse de la relation et à naviguer dans le graphe d’objets.
Exemple : User → Bookings
Dans user.entity.ts, on retrouve typiquement un champ de collection :
import { Entity, OneToMany } from 'typeorm';
import { Booking } from '../booking/booking.entity';
@Entity()
export class User {
// ... autres colonnes
// Un utilisateur peut avoir plusieurs réservations
@OneToMany(() => Booking, (booking) => booking.user)
bookings: Booking[];
}
Décomposition :
() => Booking: TypeORM a besoin d’une fonction pour éviter des problèmes d’import circulaire au runtime.(booking) => booking.user: indique la propriété inverse sur l’entitéBooking.bookings: Booking[]: une collection (côté 1-N).
@ManyToOne : le côté “plusieurs” de la relation
@ManyToOne se place sur l’entité du côté “plusieurs”. Dans notre cas, chaque Booking appartient à un seul User. C’est généralement le côté propriétaire : c’est lui qui porte la colonne de clé étrangère (ex. userId).
Exemple : Booking → User
Dans booking.entity.ts, on retrouve une relation vers User :
import { Entity, ManyToOne } from 'typeorm';
import { User } from '../user/user.entity';
@Entity()
export class Booking {
// ... autres colonnes
// Plusieurs réservations appartiennent à un utilisateur
@ManyToOne(() => User, (user) => user.bookings)
user: User;
}
Ce mapping explique à TypeORM que la table booking doit contenir une colonne de type clé étrangère pointant vers user.
L’importance de JoinColumn et quand l’utiliser
@JoinColumn permet de préciser quelle colonne est utilisée comme clé étrangère et sur quel champ elle pointe. Elle s’utilise :
- sur le côté propriétaire d’une relation OneToOne ;
- sur le côté propriétaire d’une relation ManyToOne si vous voulez personnaliser le nom de la colonne (ex.
user_idau lieu deuserId), ou si vous gérez plusieurs relations vers la même table.
Sans @JoinColumn, TypeORM choisit un nom par défaut (souvent basé sur le nom de propriété). Avec @JoinColumn, vous reprenez la main, ce qui est utile pour respecter une convention SQL existante.
Exemple : personnaliser la colonne de clé étrangère
import { Entity, ManyToOne, JoinColumn, Column } from 'typeorm';
import { User } from '../user/user.entity';
@Entity()
export class Booking {
// Optionnel : exposer explicitement l'id
@Column({ nullable: true })
userId: string;
@ManyToOne(() => User, (user) => user.bookings)
@JoinColumn({ name: 'userId' })
user: User;
}
À retenir : @JoinColumn n’est pas obligatoire dans un ManyToOne “simple”, mais il devient précieux dès que vous voulez maîtriser le schéma (noms de colonnes, multiples relations, compatibilité avec une base existante).
Options de relation : eager, lazy, cascade
Les relations ne concernent pas que le schéma : elles influencent aussi comment les données sont chargées et persistées.
eager : chargement automatique
Avec eager: true, TypeORM charge automatiquement la relation à chaque fois que l’entité est chargée.
@ManyToOne(() => User, (user) => user.bookings, { eager: true })
user: User;
Avantage : pratique pour des relations indispensables (ex. un booking doit toujours afficher son user).
Inconvénient : risque de surcharger les requêtes, surtout si la relation est volumineuse ou si elle entraîne d’autres relations.
lazy : chargement à la demande
Le lazy loading charge la relation uniquement quand vous y accédez. En TypeORM, cela se fait via un type Promise<T> (et une config adaptée). Exemple conceptuel :
@ManyToOne(() => User, (user) => user.bookings)
user: Promise<User>;
Attention : le lazy loading peut rendre le flux moins explicite et favoriser des requêtes en cascade (N+1) si mal utilisé. Dans beaucoup de projets NestJS, on préfère un chargement explicite via relations ou via un QueryBuilder.
cascade : persistance en chaîne
cascade permet de propager certaines opérations (insert/update/remove) sur l’entité liée.
@OneToMany(() => Booking, (booking) => booking.user, { cascade: ['insert', 'update'] })
bookings: Booking[];
Bon usage : quand l’entité “enfant” n’a pas de sens sans le parent (agrégat).
Piège : un cascade: true trop large peut créer des insertions/suppressions non désirées.
Exemple concret 1 : User → Bookings (un utilisateur a plusieurs réservations)
On récapitule le duo classique :
User.bookings:@OneToMany(inverse)Booking.user:@ManyToOne(propriétaire)
Ce modèle correspond à une réalité SQL simple : la table booking possède une colonne de clé étrangère vers user.
Créer une réservation pour un utilisateur
Côté service NestJS, le plus robuste est de charger l’utilisateur, puis de l’assigner à la réservation :
// Exemple de logique de service (pattern courant) :
// 1) récupérer le User
// 2) créer la Booking
// 3) sauvegarder
const user = await this.userRepository.findOneOrFail({ where: { id: userId } });
const booking = this.bookingRepository.create({
// ... autres champs
user,
});
await this.bookingRepository.save(booking);
Variante : si vous exposez un champ userId, vous pouvez parfois éviter de charger l’utilisateur, mais vous perdez la validation “l’utilisateur existe” si vous ne vérifiez pas explicitement.
Exemple concret 2 : Booking → GuidanceService (une réservation concerne un service)
Un autre cas fréquent : une réservation concerne un service (ici GuidanceService). On est encore sur un N-1 : plusieurs bookings peuvent référencer le même service.
Dans booking.entity.ts, cela ressemble à :
import { Entity, ManyToOne } from 'typeorm';
import { GuidanceService } from '../guidance-service/guidance-service.entity';
@Entity()
export class Booking {
// ... autres colonnes
@ManyToOne(() => GuidanceService)
guidanceService: GuidanceService;
}
Remarquez que si vous n’avez pas besoin de naviguer dans l’autre sens (ex. GuidanceService.bookings), vous pouvez garder une relation unidirectionnelle. C’est souvent un bon compromis pour limiter la complexité.
Récupérer les relations : find avec l’option relations
Par défaut, TypeORM ne charge pas forcément les relations (sauf si eager est activé). La manière la plus claire en NestJS est de charger explicitement les relations nécessaires via l’option relations.
Charger un utilisateur avec ses réservations
const user = await this.userRepository.findOne({
where: { id: userId },
relations: {
bookings: true,
},
});
Vous obtenez un User avec user.bookings rempli.
Charger une réservation avec son utilisateur et son service
const booking = await this.bookingRepository.findOne({
where: { id: bookingId },
relations: {
user: true,
guidanceService: true,
},
});
Ce chargement explicite rend le comportement prévisible et limite les surprises côté performance.
Pièges courants et comment les éviter
1) Relations circulaires (et sérialisation JSON)
Quand vous définissez les deux côtés (User.bookings et Booking.user), vous créez un graphe potentiellement circulaire. Si vous retournez directement des entités TypeORM depuis vos controllers, la sérialisation peut :
- gonfler énormément la réponse (user → bookings → user → bookings…)
- provoquer des erreurs de type “Converting circular structure to JSON” selon la façon dont c’est sérialisé
Bonne pratique NestJS : utiliser des DTOs (objets de transfert) et ne retourner que les champs nécessaires. Vous pouvez aussi contrôler ce qui est exposé via des mécanismes de transformation (ex. class-transformer), mais l’idée centrale reste : ne pas exposer le graphe complet par défaut.
2) N+1 queries
Le problème N+1 survient quand :
- vous chargez une liste d’entités (1 requête)
- puis, pour chaque entité, vous chargez une relation (N requêtes supplémentaires)
Exemple typique : charger 50 bookings, puis accéder à booking.user en lazy loading. Résultat : 51 requêtes.
Solutions :
- charger les relations en une fois via
relations(comme montré plus haut) - utiliser un QueryBuilder avec des
leftJoinAndSelectsi vous avez besoin de filtrer/ordonner sur des champs liés - éviter
eagerpartout “par confort” : vous perdez le contrôle
3) Cascade mal maîtrisé
Un cascade: true peut insérer ou mettre à jour des entités liées sans que vous vous en rendiez compte. Préférez des cascades ciblées (['insert'], ['update']) et gardez en tête qui “possède” le cycle de vie de quoi.
4) Nullabilité et contraintes
Une réservation doit-elle toujours avoir un utilisateur ? Un service est-il obligatoire ? Ces règles métiers doivent se refléter :
- dans la base (nullable / NOT NULL)
- dans vos DTOs (validation)
- dans vos relations TypeORM (option
{ nullable: false }si pertinent)
Ne laissez pas ces décisions implicites : elles finissent toujours par se transformer en bugs de données.
Conclusion
Vous savez maintenant :
- différencier 1-N, N-1 et N-N et comprendre où se place la clé étrangère ;
- utiliser @OneToMany pour le côté “un” (inverse) et @ManyToOne pour le côté “plusieurs” (propriétaire) ;
- quand ajouter @JoinColumn pour contrôler le schéma ;
- choisir entre eager, lazy et un chargement explicite via relations ;
- éviter les pièges classiques : relations circulaires et N+1 queries.
Pour aller plus loin, vous pouvez explorer : les relations ManyToMany (et les tables de jointure), les QueryBuilder pour des requêtes complexes, et la mise en place de DTOs + validation pour sécuriser vos entrées et contrôler vos sorties.

Commentaires