Chapitre 618 minIntermédiaire
Modélisation MongoDB : imbriquer ou référencer ?
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- choisir entre imbriquer et référencer pour chaque relation ;
- passer d'un MCD à des collections MongoDB ;
- décider quand une duplication est acceptable ;
- faire valider le schéma par la base avec $jsonSchema.
3 exercices corrigés1 schéma
Modéliser des données dans MongoDB, c'est décider, pour chaque relation entre deux entités, s'il faut imbriquer l'une dans l'autre (embedding : un sous-document ou un tableau dans le document parent) ou les référencer (referencing : deux collections, l'une gardant l'_id de l'autre). La règle de fond tient en une phrase : ce qui est lu ensemble est rangé ensemble. Ce chapitre donne la méthode, part du MCD d'AppliFrais pour arriver à ses collections, puis montre comment faire vérifier le schéma par la base elle-même.
Une autre façon de penser le modèle
En relationnel, tu modélises d'abord les données : le MCD, puis le passage au modèle relationnel et la normalisation, qui suppriment toute redondance (cours Merise). Les requêtes viennent ensuite, et les jointures recollent les morceaux.
Avec MongoDB, tu pars des données et des requêtes. La même information peut être rangée de plusieurs façons valides ; la bonne est celle qui sert les opérations les plus fréquentes avec le moins de lectures, sans faire grossir un document sans limite. La démarche :
- Lister les entités et leurs relations, avec leurs cardinalités : un MCD reste le meilleur point de départ.
- Lister les opérations de l'application, avec leur fréquence : « afficher la fiche du mois d'un visiteur » (des dizaines de fois par jour), « lister les fiches clôturées du mois pour le comptable » (quelques fois par mois), « changer le nom d'un visiteur » (rarement).
- Pour chaque relation, choisir entre imbriquer et référencer, avec les critères ci-dessous.
- Poser les index qui servent les requêtes fréquentes et les règles d'unicité (chapitre 7).
- Écrire le schéma de validation (en fin de chapitre) et tester avec des données réalistes.
Imbriquer : quand et pourquoi
Imbriquer, c'est ranger l'entité « enfant » dans le document parent : un sous-document pour une relation 1-1, un tableau pour une relation 1-n.
// Les lignes de frais sont imbriquées dans la fiche
{ mois: '2026-09', etat: 'VA',
lignesForfait: [ { code: 'ETP', quantite: 3, montantUnitaire: 110 }, … ] }
Avantages : une seule lecture renvoie l'objet complet ; une écriture sur le document est atomique, donc la fiche et ses lignes sont toujours cohérentes, sans transaction ; le document ressemble à l'objet du code.
Imbrique quand :
- l'enfant n'existe pas sans son parent (une ligne de frais sans fiche n'a pas de sens) ;
- l'enfant est lu avec le parent dans la plupart des cas ;
- le nombre d'enfants est borné et raisonnable (une fiche a au plus 4 lignes forfaitisées et quelques frais hors forfait).
Référencer : quand et pourquoi
Référencer, c'est garder deux collections et stocker dans l'une l'_id de l'autre, comme une clé étrangère :
// La fiche référence son visiteur
{ mois: '2026-09', visiteur: ObjectId('6a0000000000000000000001'), … }
Pour lire la fiche avec le nom du visiteur, il faut une deuxième lecture : un $lookup dans une agrégation (chapitre 8) ou un populate avec Mongoose (chapitre 10).
Référence quand :
- l'entité a une vie propre et est partagée entre plusieurs parents (un visiteur a des dizaines de fiches ; recopier ses informations dans chacune multiplierait les mises à jour) ;
- l'entité est souvent lue seule, sans son parent ;
- le nombre d'enfants est non borné (l'historique de toutes les connexions d'un utilisateur, les événements d'un journal d'audit) : imbriqués, ils feraient grossir le document vers la limite de 16 Mio et ralentiraient chaque écriture.
Les relations type par type
| Relation | Exemple AppliFrais | Choix habituel |
|---|---|---|
| 1-1 | Un visiteur et son adresse | Imbriquer un sous-document adresse |
| 1-quelques (borné, petit) | Une fiche et ses lignes de frais | Imbriquer un tableau |
| 1-n (des dizaines à des milliers) | Un visiteur et ses fiches | Référencer depuis l'enfant : chaque fiche porte visiteur |
| 1-énormément (non borné) | Une fiche et les consultations de son historique | Référencer depuis l'enfant, dans une collection à part, jamais un tableau dans le parent |
| n-n | Les visiteurs et les médecins qu'ils visitent | Tableau de références du côté le plus lu, ou collection d'association si l'association porte des données |
Le cas 1-n mérite une précision : on pourrait aussi ranger dans le visiteur un tableau fiches: [ObjectId(…), …]. C'est une mauvaise idée ici : le tableau grossit chaque mois, il faut le mettre à jour à chaque création de fiche (deux écritures à garder cohérentes), et on ne cherche presque jamais « les fiches d'un visiteur » autrement qu'avec un filtre sur visiteur. La référence portée par l'enfant (comme une clé étrangère) est la forme la plus sûre.
Pour une relation n-n qui porte des données (la date et le compte rendu de chaque visite d'un médecin par un visiteur), crée une collection visites avec deux références et les données de la visite : c'est l'équivalent exact d'une table d'association, et on ne la dissout pas dans les documents.
Dupliquer, quand c'est voulu
La normalisation interdit la redondance. En MongoDB, une duplication maîtrisée est un outil : on recopie dans un document les quelques champs dont on a besoin à chaque lecture, pour éviter une jointure. C'est le modèle de la référence étendue (extended reference).
// La fiche garde la référence ET une copie du nom, pour l'affichage des listes
{ mois: '2026-09',
visiteur: { _id: ObjectId('6a0000000000000000000001'), nom: 'Martin', prenom: 'Lucas' },
… }
La liste des fiches du comptable s'affiche alors sans $lookup. Le prix : si Lucas Martin change de nom, il faut mettre à jour toutes ses fiches (updateMany). Le calcul est simple : la copie vaut le coup si le champ copié change rarement et est lu souvent. Un nom de famille, oui ; un solde de congés, non.
Le jeu de données contient une duplication d'un autre genre, qui n'en est pas une : chaque ligne de frais recopie le tarif du forfait (montantUnitaire: 110) au moment de la saisie. Si le tarif de l'étape passe à 115 € en 2027, les fiches de 2026 doivent garder 110 € : c'est une historisation, exigée par la règle de gestion. En relationnel, on ferait de même (historisation en Merise).
Du MCD d'AppliFrais aux collections
Le MCD simplifié d'AppliFrais compte cinq entités : VISITEUR (0,n) — établit — (1,1) FICHE_FRAIS ; FICHE_FRAIS (0,n) — comporte — (0,n) FRAIS_FORFAIT, avec la quantité comme propriété de l'association ; FICHE_FRAIS (0,n) — contient — (1,1) LIGNE_HORS_FORFAIT ; et ETAT, qui qualifie chaque fiche.
| Élément du MCD | En relationnel | En MongoDB | Pourquoi |
|---|---|---|---|
| VISITEUR | Table visiteur | Collection visiteurs | Entité partagée, lue seule (connexion, profil) |
| FICHE_FRAIS | Table fiche_frais, clé étrangère vers visiteur | Collection fiches, champ visiteur (référence) | Relation 1-n non bornée côté visiteur |
| comporte (quantité) | Table ligne_frais_forfait | Tableau lignesForfait imbriqué dans la fiche, avec le tarif recopié | Au plus 4 lignes, toujours lues avec la fiche |
| LIGNE_HORS_FORFAIT | Table ligne_frais_hors_forfait | Tableau fraisHorsForfait imbriqué | Quelques lignes par mois, lues avec la fiche |
| FRAIS_FORFAIT | Table frais_forfait (code, libellé, montant) | Collection forfaits, ou constante dans le code | Référentiel de 4 lignes qui change une fois par an |
| ETAT | Table etat (code, libellé) | Champ etat avec une liste de valeurs permises | Liste fixe de 5 valeurs |
Les règles de gestion deviennent des index et des contrôles :
- « une seule fiche par visiteur et par mois » : un index unique sur
{ visiteur: 1, mois: 1 }; - « une fiche clôturée ne se modifie plus » : une condition
etat: 'CR'dans le filtre de chaque mise à jour ; - « l'état fait partie d'une liste » : un
enumdans le schéma de validation.
Quatre modèles de conception à connaître
MongoDB documente une série de design patterns, des solutions types à des problèmes récurrents. Quatre servent souvent :
| Modèle | Problème | Solution | Exemple |
|---|---|---|---|
| Valeur calculée (computed) | Un total recalculé à chaque lecture | Le stocker au moment de l'écriture | montantValide enregistré à la validation, plutôt que recalculé à chaque liste |
| Sous-ensemble (subset) | Un tableau long dont on n'affiche que le début | Garder les N derniers éléments dans le document, le reste dans une autre collection | Les 5 dernières fiches dans le profil du visiteur, l'historique complet dans fiches |
| Seau (bucket) | Des milliers de petites mesures | Les regrouper par période dans un document | Un document par visiteur et par jour avec ses positions GPS de l'heure |
| Version du schéma (schema versioning) | Le modèle évolue, les anciens documents restent | Un champ versionSchema et un code qui sait lire chaque version | Passer de visiteur: ObjectId à visiteur: { _id, nom, prenom } sans migrer d'un coup |
Faire valider le schéma par la base
Mongoose vérifie le schéma dans ton API. Mais d'autres programmes peuvent écrire dans la base : un script d'import, mongosh, une deuxième application. Pour que la base refuse elle-même un document mal formé, on lui donne un validateur $jsonSchema, à la création de la collection (db.createCollection('fiches', { validator: … })) ou après coup avec la commande collMod :
db.runCommand({
collMod: 'fiches',
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['visiteur', 'mois', 'etat', 'lignesForfait'],
properties: {
visiteur: { bsonType: 'objectId' },
mois: { bsonType: 'string', pattern: '^[0-9]{4}-(0[1-9]|1[0-2])$' },
etat: { enum: ['CR', 'CL', 'VA', 'MP', 'RB'] },
lignesForfait: {
bsonType: 'array',
maxItems: 4,
items: {
bsonType: 'object',
required: ['code', 'quantite', 'montantUnitaire'],
properties: {
code: { enum: ['ETP', 'KM', 'NUI', 'REP'] },
quantite: { bsonType: 'int', minimum: 0 },
montantUnitaire: { bsonType: ['int', 'double'], minimum: 0 }
}
}
},
montantValide: { bsonType: ['double', 'int', 'null'], minimum: 0 }
}
}
},
validationLevel: 'strict',
validationAction: 'error'
})
Une insertion invalide est alors refusée, avec le détail de la règle violée :
db.fiches.insertOne({ visiteur: ObjectId('6a0000000000000000000004'), mois: '2026-13', etat: 'CR', lignesForfait: [] })
MongoServerError: Document failed validation
propertyName: 'mois', operatorName: 'pattern',
reason: 'regular expression did not match', consideredValue: '2026-13'
(Extrait : le détail complet est dans err.errInfo.details.) Le code d'erreur est 121. Une mise à jour qui produirait un document invalide ($set: { etat: 'XX' }) est refusée de la même façon.
| Option | Valeurs | Effet |
|---|---|---|
validationLevel | strict (défaut), moderate, off | strict vérifie toute insertion et toute mise à jour ; moderate ne vérifie pas les mises à jour des documents déjà invalides, pratique pour introduire un validateur sur une collection ancienne |
validationAction | error (défaut), warn | warn accepte le document et écrit un avertissement dans le journal du serveur : utile pour mesurer avant d'interdire |
Exercices
Exercice 1 sur 3 Imbriquer ou référencer Réussi À revoir
Pour chaque relation, choisis entre imbriquer et référencer, et justifie.
- Un visiteur et ses numéros de téléphone (professionnel, portable).
- Un médecin et les visites qu'il reçoit des visiteurs (environ 30 par an, pendant des années).
- Une commande et ses lignes (produit, quantité, prix au moment de la commande).
- Un produit et la catégorie à laquelle il appartient (une vingtaine de catégories, renommées de temps en temps).
- Un utilisateur et le journal de ses connexions.
Voir la réponseMasquer la réponse de l'exercice 1
- Imbriquer : un petit tableau
telephones: [ { type: 'pro', numero: '…' } ]. Ils n'existent pas sans le visiteur et sont lus avec lui. - Référencer depuis la visite : une collection
visitesavecmedecin,visiteur, la date et le compte rendu. Le nombre de visites n'est pas borné dans le temps, et une visite relie deux entités. - Imbriquer les lignes dans la commande, en recopiant le prix : la commande doit garder le prix payé, même si le catalogue change. Le produit, lui, est référencé (
produit: ObjectId(…)), éventuellement avec une copie de son libellé. - Référencer la catégorie, avec éventuellement une copie de son nom dans le produit (référence étendue) pour afficher les listes sans
$lookup. Comme les catégories sont rarement renommées, mettre à jour les produits concernés avec unupdateManyest acceptable. - Référencer dans une collection
connexions: le journal grossit sans fin. Dans le document de l'utilisateur, tu peux garder la date de dernière connexion (modèle de la valeur calculée), voire les 5 dernières (sous-ensemble).
Tu avais trouvé ?
Exercice 2 sur 3 Modéliser un helpdesk Réussi À revoir
Une PME veut une application de tickets d'assistance. Un utilisateur (nom, service, courriel) ouvre un ticket (titre, description, priorité, statut, date d'ouverture). Un ou plusieurs techniciens (nom, spécialités) interviennent. Chaque ticket reçoit des messages (auteur, date, texte), une dizaine en moyenne, rarement plus de 50. Les écrans les plus utilisés sont : « le ticket complet avec ses messages » et « les tickets ouverts d'un technicien, avec le nom du demandeur ».
- Propose les collections et un document d'exemple pour un ticket.
- Quels index poses-tu ?
- Quelle règle de cohérence l'application doit-elle vérifier elle-même ?
Voir la réponseMasquer la réponse de l'exercice 2
- Trois collections :
utilisateurs,techniciensettickets. Les messages sont imbriqués dans le ticket (lus avec lui, bornés en pratique) ; le demandeur et les techniciens sont référencés, avec une copie de leur nom pour l'écran de liste :
{
_id: ObjectId('…'),
titre: 'Imprimante du 2e étage hors service',
description: 'Bourrage papier permanent depuis ce matin.',
priorite: 'haute',
statut: 'ouvert',
ouvertLe: ISODate('2026-10-28T08:12:00Z'),
demandeur: { _id: ObjectId('…'), nom: 'Faure', service: 'Logistique' },
techniciens: [ { _id: ObjectId('…'), nom: 'Mercier' } ],
messages: [
{ auteur: 'Faure', date: ISODate('2026-10-28T08:12:00Z'), texte: 'Bourrage papier permanent.' },
{ auteur: 'Mercier', date: ISODate('2026-10-28T09:40:00Z'), texte: 'Rouleau d\'entraînement changé.' }
]
}
- Un index
{ 'techniciens._id': 1, statut: 1 }pour « les tickets ouverts d'un technicien » (c'est un index multiclé, sur un tableau) ; un index{ 'demandeur._id': 1, ouvertLe: -1 }pour l'historique d'un utilisateur. - Les références : un ticket ne doit désigner que des utilisateurs et des techniciens qui existent. Et la duplication : si un utilisateur change de service, ses tickets ouverts doivent être mis à jour (les tickets clos peuvent garder l'ancien service, c'est même plus juste pour l'historique). Si un ticket pouvait recevoir des centaines de messages (un fil de discussion public), on sortirait les messages dans une collection
messages, en gardant éventuellement les derniers dans le ticket.
Tu avais trouvé ?
Exercice 3 sur 3 Ce modèle tient-il ? Réussi À revoir
Un étudiant propose ce modèle pour AppliFrais : un seul document par visiteur, qui contient ses informations et un tableau fiches avec toutes ses fiches et leurs lignes, depuis son embauche. Une fiche pèse environ 1 Kio. Cite trois problèmes de ce modèle.
Voir la réponseMasquer la réponse de l'exercice 3
- Croissance sans limite : le document grossit chaque mois. À 1 Kio par fiche, la limite de 16 Mio est loin (plus de 16 000 mois), mais chaque modification réécrit et retransmet un document de plus en plus lourd, et chaque lecture du profil charge tout l'historique.
- La règle « une fiche par mois » ne peut plus être garantie par un index unique entre documents : il faut la vérifier dans le tableau à chaque ajout.
- Les requêtes du comptable (« toutes les fiches clôturées d'octobre ») doivent fouiller les tableaux de tous les visiteurs, avec
$unwind; les fiches ne sont plus des objets qu'on désigne par un_iddans une API (/v1/fiches/:id).
La fiche est une entité à part entière, consultée seule et en grand nombre : elle mérite sa propre collection, avec une référence vers le visiteur.
Tu avais trouvé ?