Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

  1. Lister les entités et leurs relations, avec leurs cardinalités : un MCD reste le meilleur point de départ.
  2. 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).
  3. Pour chaque relation, choisir entre imbriquer et référencer, avec les critères ci-dessous.
  4. Poser les index qui servent les requêtes fréquentes et les règles d'unicité (chapitre 7).
  5. É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.

js
// 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 :

js
// 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.
Collection visiteurs_id: ObjectId('6a…01')matricule: 'a131'nom: 'Martin'prenom: 'Lucas'role: 'visiteur'region: 'Rhône'partagé par toutes ses fiches,lu seul : collection à partCollection fiches_id: ObjectId('6ae8…6f')visiteur: ObjectId('6a…01')mois: '2026-09', etat: 'VA'lignesForfait: [{ code: 'ETP', quantite: 3, … },{ code: 'KM', quantite: 420, … },{ code: 'NUI', … }, { code: 'REP', … }]fraisHorsForfait: [{ libelle: 'Repas client…', … } ]imbriqués : lus et écrits avec la ficheréférence
Les lignes sont imbriquées dans la fiche, le visiteur est référencé

Les relations type par type

RelationExemple AppliFraisChoix habituel
1-1Un visiteur et son adresseImbriquer un sous-document adresse
1-quelques (borné, petit)Une fiche et ses lignes de fraisImbriquer un tableau
1-n (des dizaines à des milliers)Un visiteur et ses fichesRéférencer depuis l'enfant : chaque fiche porte visiteur
1-énormément (non borné)Une fiche et les consultations de son historiqueRéférencer depuis l'enfant, dans une collection à part, jamais un tableau dans le parent
n-nLes visiteurs et les médecins qu'ils visitentTableau 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).

js
// 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 MCDEn relationnelEn MongoDBPourquoi
VISITEURTable visiteurCollection visiteursEntité partagée, lue seule (connexion, profil)
FICHE_FRAISTable fiche_frais, clé étrangère vers visiteurCollection fiches, champ visiteur (référence)Relation 1-n non bornée côté visiteur
comporte (quantité)Table ligne_frais_forfaitTableau lignesForfait imbriqué dans la fiche, avec le tarif recopiéAu plus 4 lignes, toujours lues avec la fiche
LIGNE_HORS_FORFAITTable ligne_frais_hors_forfaitTableau fraisHorsForfait imbriquéQuelques lignes par mois, lues avec la fiche
FRAIS_FORFAITTable frais_forfait (code, libellé, montant)Collection forfaits, ou constante dans le codeRéférentiel de 4 lignes qui change une fois par an
ETATTable etat (code, libellé)Champ etat avec une liste de valeurs permisesListe 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 enum dans 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èleProblèmeSolutionExemple
Valeur calculée (computed)Un total recalculé à chaque lectureLe stocker au moment de l'écrituremontantValide enregistré à la validation, plutôt que recalculé à chaque liste
Sous-ensemble (subset)Un tableau long dont on n'affiche que le débutGarder les N derniers éléments dans le document, le reste dans une autre collectionLes 5 dernières fiches dans le profil du visiteur, l'historique complet dans fiches
Seau (bucket)Des milliers de petites mesuresLes regrouper par période dans un documentUn 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 restentUn champ versionSchema et un code qui sait lire chaque versionPasser 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 :

js
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 :

js
db.fiches.insertOne({ visiteur: ObjectId('6a0000000000000000000004'), mois: '2026-13', etat: 'CR', lignesForfait: [] })
text
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.

OptionValeursEffet
validationLevelstrict (défaut), moderate, offstrict 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
validationActionerror (défaut), warnwarn 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.

  1. Un visiteur et ses numéros de téléphone (professionnel, portable).
  2. Un médecin et les visites qu'il reçoit des visiteurs (environ 30 par an, pendant des années).
  3. Une commande et ses lignes (produit, quantité, prix au moment de la commande).
  4. Un produit et la catégorie à laquelle il appartient (une vingtaine de catégories, renommées de temps en temps).
  5. Un utilisateur et le journal de ses connexions.
Voir la réponseMasquer la réponse de l'exercice 1
  1. Imbriquer : un petit tableau telephones: [ { type: 'pro', numero: '…' } ]. Ils n'existent pas sans le visiteur et sont lus avec lui.
  2. Référencer depuis la visite : une collection visites avec medecin, 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.
  3. 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é.
  4. 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 un updateMany est acceptable.
  5. 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 ».

  1. Propose les collections et un document d'exemple pour un ticket.
  2. Quels index poses-tu ?
  3. 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
  1. Trois collections : utilisateurs, techniciens et tickets. 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 :
js
{
  _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é.' }
  ]
}
  1. 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.
  2. 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
  1. 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.
  2. 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.
  3. 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 _id dans 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é ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Index MongoDB et explain() : accélérer les requêtesRevoir le sommaire du cours