La relation many-to-many qui cache tout
Une relation ManyToMany semble évidente. Elle est souvent le symptôme d'un agrégat mal placé.
Les ORM adorent les relations many-to-many. C'est un raccourci séduisant : deux entités, une table de jointure, quelques lignes de mapping, et voilà — tu peux associer des Job à des Platform dans les deux sens.
Sauf qu'une relation ManyToMany cache rarement ce qu'elle a l'air de dire.
Une relation
many-to-many est souvent le symptôme qu'un agrégat n'est pas à sa place — qu'une responsabilité n'a pas trouvé son maître.
Prenons un exemple concret : tu dois associer des offres d'emploi aux plateformes de diffusion où elles doivent paraître. C'est une relation many-to-many ; une offre peut apparaître sur plusieurs sites, un site accueille plusieurs offres.
Doctrine te propose de bâtir ça en deux entités et une table de jonction. Ça compile. Les tests passent. Le code est lisible.
Mais… qui devrait être responsable de cette association ? L'offre d'emploi ? La plateforme ? Quelque chose d'autre ?
C'est là qu'intervient la pensée par agrégats du DDD. Un agrégat est une grappe d'entités qui partagent une raison de vivre — une invariante métier à préserver ensemble.
Dans ce cours, on va remodéliser cette relation step by step : d'abord voir où elle casse, puis construire une entité qui a un vrai sens métier et qui rompt la dépendance implicite.
À la fin, tu auras la réponse à cette question : ce many-to-many était-il vraiment un many-to-many ?
Le modèle naïf : deux entités, une table de jointure
Deux entités, une table de jointure, un mapping Doctrine en cinq lignes. Ça a l'air parfait.
Commençons par modéliser le problème tel qu'on le voit naturellement. Tu as deux entités : Job, qui représente une offre d'emploi, et JobBoard, qui représente une plateforme de diffusion. Une offre peut paraître sur plusieurs plateformes, une plateforme accueille plusieurs offres. C'est une relation many-to-many au sens strict.
Voici à quoi ressemblent ces deux entités avec leur mapping Doctrine :
// src/Domain/Job.php use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ArrayCollection; #[Entity] class Job { #[Id, GeneratedValue(strategy: 'AUTO'), Column] private int $id; #[Column(length: 255)] private string $title; #[ManyToMany(targetEntity: JobBoard::class, cascade: ['persist'])] #[JoinTable(name: 'job_job_board')] private Collection $jobBoards; public function __construct(string $title) { $this->title = $title; $this->jobBoards = new ArrayCollection(); } public function addJobBoard(JobBoard $board): void { if (!$this->jobBoards->contains($board)) { $this->jobBoards->add($board); } } }
Le cascade: ['persist'] permet de persister les JobBoard associés en même temps que le Job — pas besoin d'appeler persist() séparément sur chaque plateforme.
// src/Domain/JobBoard.php use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\Collection; #[Entity] class JobBoard { #[Id, GeneratedValue(strategy: 'AUTO'), Column] private int $id; #[Column(length: 255)] private string $name; #[ManyToMany(targetEntity: Job::class, mappedBy: 'jobBoards')] private Collection $jobs; public function __construct(string $name) { $this->name = $name; $this->jobs = new ArrayCollection(); } }
Du côté de Job, l'attribut #[ManyToMany] est le côté propriétaire (owning side) : c'est lui qui pilote la table de jointure. Du côté de JobBoard, mappedBy: 'jobBoards' indique que c'est un côté miroir — il lit l'état, il ne l'écrit pas.
La table intermédiaire job_job_board ne contient que deux colonnes : job_id et job_board_id. Doctrine la crée automatiquement à partir du #[JoinTable] et en gère le contenu de façon transparente.
La table de jointure ne correspond à aucune entité PHP : Doctrine la gère entièrement en coulisses. Pour associer un
Job à un JobBoard, il suffit d'appeler $job->addJobBoard($board). À la prochaine synchronisation (flush()), Doctrine insère automatiquement la ligne dans job_job_board. Côté PHP, tu manipules une collection ArrayCollection comme un tableau objet standard.
Voyons maintenant comment ce modèle s'utilise en pratique. Voici la méthode qui publie une offre sur une sélection de plateformes :
public function postJobToBoards( int $jobId, array $boardIds, EntityManagerInterface $em ): void { // Charge l'offre d'emploi depuis la base $job = $em->find(Job::class, $jobId); // Charge chaque plateforme cible et l'associe à l'offre foreach ($boardIds as $boardId) { $board = $em->find(JobBoard::class, $boardId); $job->addJobBoard($board); } // Un seul flush : toutes les associations sont écrites en une transaction $em->flush(); }
La méthode est courte, lisible, idiomatique. Elle charge, elle associe, elle persiste. Un seul flush() à la fin pour tout valider en une seule transaction.
Le code semble parfait. Pourtant, une hypothèse est enfouie dedans — une hypothèse si naturelle qu'on ne la voit pas au premier coup d'œil. Dans la section suivante, on va la faire remonter à la surface.
Les défaillances partielles sont normales
Dans la vraie vie, si un article manque au marché, tu ne rentres pas les mains vides.
Avant de remodéliser quoi que ce soit, il faut se poser une question que les développeurs omettent souvent : qu'est-ce que le métier considère comme acceptable ?
Imagine que tu fasses tes courses au marché. Sur ta liste, tu as du pain, des légumes, du fromage, de la viande et du poisson. Arrivé devant l'étal du poissonnier, mauvaise surprise : il est absent ce matin. Est-ce que tu fais demi-tour et tu rentres chez toi les mains vides ? Ou est-ce que tu achètes le pain, les légumes, le fromage et la viande — et tu notes le poisson pour demain ?
Pour la quasi-totalité des gens, la réponse est évidente : tu achètes ce qui est disponible. Rentrer les mains vides parce qu'un seul article manque serait absurde.
Une défaillance partielle, c'est quand une opération globale réussit en partie : certains sous-objectifs aboutissent, d'autres non. Le métier l'accepte explicitement, parce qu'un résultat partiel a de la valeur — il vaut mieux que rien.
Reporte cette logique sur notre problème. L'entreprise veut publier son offre d'emploi sur LinkedIn, Indeed, APEC, Welcome to the Jungle et une plateforme interne — cinq plateformes au total. Ce soir, LinkedIn est indisponible pour maintenance.
Est-ce que l'entreprise préfère ne rien publier du tout, et attendre que LinkedIn revienne ? Ou est-ce qu'elle préfère que les quatre autres publications partent immédiatement, et qu'on reprogramme LinkedIn dès qu'il est de nouveau accessible ?
Pour n'importe quel service RH, la réponse est limpide : quatre sur cinq, c'est bien mieux que zéro sur cinq. Les candidats qui cherchent sur Indeed ou l'APEC n'ont aucune raison d'attendre que LinkedIn soit en bonne santé. Chaque heure de délai, ce sont des candidats qui passent à l'offre suivante.
Cette distinction change tout. Dans le modèle actuel avec une seule transaction, le code dit implicitement au métier : "soit tes cinq plateformes fonctionnent toutes au même instant, soit tu n'as rien." C'est une contrainte que le métier n'a jamais demandée — et qu'il ne veut pas.
Ce que le métier dit réellement, c'est : "chaque publication sur une plateforme est un acte indépendant. Elle réussit ou elle échoue pour ses propres raisons."
Dès lors, une question s'impose : si chaque publication est indépendante — si elle a son propre cycle de vie, son propre statut, son propre résultat — pourquoi la modéliser comme une simple ligne dans une table de jointure sans identité propre ? Une ligne anonyme dans job_job_board ne peut pas avoir un statut failed, elle ne peut pas être replanifiée, elle ne peut pas stocker la date à laquelle la publication a effectivement abouti.
ManyToMany dans une transaction unique n'a plus de sens. On a besoin d'une entité — pas d'une jointure.
C'est exactement ce que l'on va construire dans les sections suivantes : une entité JobPosting qui représente le lien entre une offre et une plateforme, avec un statut, une date, et la possibilité d'échouer sans emporter les autres avec elle.
Repenser les agrégats : qui possède cette relation ?
Qui devrait être responsable d'associer un job à une plateforme ?
On vient de voir que le modèle naïf place chaque publication dans une seule transaction — et que le métier, lui, réclame des opérations indépendantes. Mais avant de coder quoi que ce soit, il faut se poser la vraie question : qui est responsable de cette relation ? Dans notre modèle, c'est Job qui "connaît" ses plateformes. Est-ce vraiment logique ?
Un agrégat est une grappe d'objets qui partagent une frontière de cohérence transactionnelle. Toutes les modifications de cette grappe passent par un unique point d'entrée : la racine d'agrégat. C'est elle qui applique les invariants métier, elle qui décide si une opération est valide. De l'extérieur, on ne touche jamais directement les objets internes — on passe toujours par la racine.
Dans notre modèle actuel, Job est le côté propriétaire de la relation : c'est lui qui porte le #[ManyToMany], c'est lui qui gère la collection de JobBoard, c'est lui qu'on appelle quand on veut associer une plateforme. Job "connaît" ses plateformes — et se comporte comme si c'était de sa responsabilité d'en gérer la liste.
Mais réfléchis une seconde à ce que signifient ces phrases dans la vraie vie :
- "Ce job est publié sur Indeed."
- "Indeed publie ce job."
La première met l'offre au centre. La seconde met la plateforme au centre. Laquelle décrit mieux ce qui se passe réellement ? C'est Indeed qui décide d'accepter et d'afficher une offre — pas l'offre qui se projette elle-même sur Indeed. La plateforme est l'acteur ; l'offre est le contenu.
En DDD, on cherche à faire en sorte que le code ressemble au langage du métier. Si les utilisateurs disent "Indeed publie ce job", alors c'est
$jobBoard->post($job) — et non $job->addJobBoard($jobBoard) — qui exprime correctement l'intention.
Ce changement de perspective n'est pas qu'une question de style. Il déplace la frontière de cohérence : au lieu que Job soit responsable de la liste de ses plateformes, c'est chaque JobBoard qui décide de publier ou non une offre. Et surtout, chaque publication devient une opération autonome — elle peut réussir ou échouer sans affecter les autres.
Modèle naïf — Job propriétaire
// Job porte la relation bidirectionnelle #[ManyToMany(targetEntity: JobBoard::class, cascade: ['persist'])] private Collection $jobBoards; public function addJobBoard(JobBoard $board): void { $this->jobBoards->add($board); }
Et à l'usage :
// L'offre "s'ajoute" une plateforme $job->addJobBoard($indeed); $job->addJobBoard($apec); $em->flush(); // une seule transaction
Modèle corrigé — JobBoard acteur
// JobBoard expose une intention métier class JobBoard { public function post(Job $job): void { // crée une publication indépendante } }
Et à l'usage :
// Chaque plateforme agit pour son compte $indeed->post($job); $apec->post($job); // deux opérations indépendantes possibles
La différence n'est pas que cosmétique. Dans le modèle naïf, $job->addJobBoard() dit juste : "insère une ligne dans la table de jointure". Dans le modèle corrigé, $jobBoard->post($job) dit : "Indeed prend la décision de publier cette offre." C'est une sémantique riche, qui fait écho à ce que les gens du métier décrivent.
post() est une opération indépendante sur JobBoard, il n'est plus nécessaire que la relation soit stockée dans une table de jointure anonyme. Cette opération peut créer une entité à part entière — avec son propre identifiant, son propre statut (pending, published, failed), sa propre date. Le ManyToMany disparaît naturellement : il devient une entité avec son propre cycle de vie.
C'est exactement ce qu'on va construire dans la section suivante : une entité JobPosting qui remplace la table de jointure, et qui permet à chaque publication d'exister, d'échouer ou d'être replanifiée indépendamment des autres.
Le refactoring : une entité à la place d'une jointure
Fini la table de jointure. Place à une entité qui a un sens métier.
On a posé le diagnostic. Le modèle naïf souffre d'une transaction unique qui engloutit des opérations indépendantes, et Job s'est vu attribuer une responsabilité qui ne lui appartient pas. Il est temps de construire la solution : remplacer la table de jointure anonyme par une entité qui a un nom, une identité, et un sens métier.
Cette entité, c'est JobPosting. Pense-y comme à un contrat signé entre une offre d'emploi et une plateforme de diffusion : il ne s'agit plus d'une simple ligne dans un tableau, mais d'un acte — daté, traçable, avec un statut.
Quand une relation
ManyToMany présente son propre cycle de vie — une date de création, un statut, des règles métier qui lui sont propres —, elle mérite d'exister en tant qu'entité à part entière. On l'appelle souvent une entité de relation ou entité d'association. Elle remplace la table de jointure par une table normale avec sa propre clé primaire, et on la manipule comme n'importe quelle autre entité du domaine.
L'entité JobPosting
Voici la nouvelle entité. Elle stocke le lien entre une offre et une plateforme, la date de publication, et laisse de la place pour un statut si ton métier en a besoin :
// src/Domain/JobPosting.php use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\ManyToOne; use Doctrine\ORM\Mapping\JoinColumn; #[Entity] class JobPosting { #[Id, GeneratedValue(strategy: 'AUTO'), Column] private int $id; #[ManyToOne(targetEntity: Job::class)] #[JoinColumn(nullable: false)] private Job $job; #[ManyToOne(targetEntity: JobBoard::class, inversedBy: 'postings')] #[JoinColumn(nullable: false)] private JobBoard $jobBoard; #[Column] private \DateTimeImmutable $postedAt; #[Column(length: 20, options: ['default' => 'pending'])] private string $status = 'pending'; public function __construct(Job $job, JobBoard $jobBoard) { $this->job = $job; $this->jobBoard = $jobBoard; $this->postedAt = new \DateTimeImmutable(); } public function markPublished(): void { $this->status = 'published'; } public function markFailed(): void { $this->status = 'failed'; } public function getId(): int { return $this->id; } public function getJob(): Job { return $this->job; } public function getJobBoard(): JobBoard { return $this->jobBoard; } public function getPostedAt(): \DateTimeImmutable { return $this->postedAt; } public function getStatus(): string { return $this->status; } }
Quelques points à remarquer. Le constructeur exige un Job et un JobBoard — une JobPosting ne peut pas exister sans eux. $postedAt est initialisé à la construction : c'est le moment où la décision de publier est prise, pas le moment où elle aboutit. Et le $status est à pending par défaut — on pourra le faire passer à published ou failed selon le résultat réel de la diffusion.
$status. Mais dès que tu as besoin de relancer les publications échouées, d'afficher un tableau de bord ou de faire de la réconciliation, ce champ devient indispensable. Mieux vaut le prévoir dès le départ.
JobBoard refactorisé : la méthode post()
JobBoard perd son ManyToMany et gagne une collection de JobPosting, ainsi qu'une méthode métier qui exprime clairement l'intention — "cette plateforme publie cette offre" :
// src/Domain/JobBoard.php use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\OneToMany; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ArrayCollection; #[Entity] class JobBoard { #[Id, GeneratedValue(strategy: 'AUTO'), Column] private int $id; #[Column(length: 255)] private string $name; #[OneToMany(targetEntity: JobPosting::class, mappedBy: 'jobBoard', cascade: ['persist'])] private Collection $postings; public function __construct(string $name) { $this->name = $name; $this->postings = new ArrayCollection(); } public function post(Job $job): JobPosting { $posting = new JobPosting($job, $this); $this->postings->add($posting); return $posting; } public function getId(): int { return $this->id; } public function getName(): string { return $this->name; } public function getPostings(): Collection { return $this->postings; } }
Le cascade: ['persist'] sur #[OneToMany] permet d'éviter un appel explicite à $em->persist($posting) : dès qu'on appelle $board->post($job), le JobPosting nouvellement créé est automatiquement géré par Doctrine lors du prochain flush().
Job simplifié : plus aucune référence à JobBoard
C'est ici que le refactoring révèle tout son intérêt. L'entité Job n'avait aucune raison de "connaître" les plateformes — c'était une dépendance artificielle imposée par le modèle ManyToMany. Elle disparaît complètement :
// src/Domain/Job.php — version simplifiée use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Column; #[Entity] class Job { #[Id, GeneratedValue(strategy: 'AUTO'), Column] private int $id; #[Column(length: 255)] private string $title; public function __construct(string $title) { $this->title = $title; } public function getId(): int { return $this->id; } public function getTitle(): string { return $this->title; } }
Plus de Collection, plus de #[ManyToMany], plus de addJobBoard(). La relation est désormais unidirectionnelle : JobPosting connaît son Job, mais Job ignore tout des plateformes qui le diffusent. C'est le modèle le plus simple possible pour représenter ce que Job est vraiment — une offre d'emploi, rien de plus.
La nouvelle méthode applicative : une transaction par plateforme
C'est ici que le modèle révèle toute sa valeur. On peut maintenant boucler sur les plateformes cibles et traiter chacune dans sa propre transaction. L'échec d'une ne pollue pas les autres. La méthode injecte ManagerRegistry $managerRegistry en plus de l'EntityManager, pour pouvoir le réinitialiser proprement en cas de fermeture :
public function postJobToBoards( int $jobId, array $boardIds, EntityManagerInterface $em, ManagerRegistry $managerRegistry ): void { $job = $em->find(Job::class, $jobId); $errors = []; foreach ($boardIds as $boardId) { $board = $em->find(JobBoard::class, $boardId); try { // La plateforme crée le JobPosting et l'ajoute à sa collection $posting = $board->post($job); // On marque la publication réussie avant le flush : // un seul commit atomique, pas d'état intermédiaire en base $posting->markPublished(); $em->flush(); } catch (\Exception $e) { // Cette plateforme a échoué : on le note, on continue $errors[] = $board->getName() . ' : ' . $e->getMessage(); // Réinitialiser l'EM fermé via ManagerRegistry (Symfony) if (!$em->isOpen()) { $em = $managerRegistry->resetManager(); } } } if (!empty($errors)) { // On logge les erreurs sans tout faire échouer $this->logger->warning('Certaines publications ont échoué', [ 'job_id' => $jobId, 'errors' => $errors, ]); } }
Chaque itération est désormais autonome. Si LinkedIn est indisponible à la troisième itération, les deux premières publications sont déjà commitées — elles ne seront pas annulées. L'erreur est collectée, loggée, et la boucle continue sur la plateforme suivante.
isOpen() === false). Dans ce cas, il faut le réinitialiser via ManagerRegistry::resetManager() — c'est l'API Symfony prévue pour ça. resetManager() recrée l'EntityManager et le réenregistre dans le conteneur, ce qui garantit que les services qui y font référence récupèrent bien la nouvelle instance.
JobPosting avec sa propre identité. On a déplacé la responsabilité de créer cette association vers JobBoard, qui est l'acteur naturel. Et on a rendu Job agnostique des plateformes qui le diffusent — une entité plus cohérente avec ce qu'elle représente.
Cette restructuration rend possible tout ce que le modèle naïf interdisait : replanifier les publications échouées, afficher l'état de chaque diffusion, comparer les plateformes par performance, ou simplement comprendre pourquoi une offre n'est pas parue quelque part. La table de jointure ne pouvait rien de tout ça — l'entité JobPosting, si.
Synthèse : quand suspecter un faux many-to-many ?
Un pattern, des compromis, et une question à se poser à chaque ManyToMany.
Tu as suivi l'évolution complète : un ManyToMany qui paraissait évident, un problème de transaction qui n'était pas visible au premier regard, et un refactoring qui a transformé une table de jointure anonyme en une entité JobPosting avec une identité, des attributs et une sémantique propres. Avant de conclure, faisons le bilan honnête — ce qu'on a gagné, et ce qu'on a perdu.
Ce qu'on a gagné
Le premier bénéfice est opérationnel : chaque publication est maintenant dans sa propre transaction. Si LinkedIn est indisponible, Indeed et Welcome to the Jungle ont déjà commité leur enregistrement. La résilience partielle n'est plus un cas à gérer spécialement — c'est le comportement naturel du modèle.
Le deuxième bénéfice est sémantique. $board->post($job) dit ce qu'il fait. Comparer avec l'ancien $job->addJobBoard($board) : quelle entité est l'acteur ? JobBoard — c'est elle qui publie, c'est elle qui est responsable. La méthode métier exprime l'intention.
Le troisième bénéfice est structurel. JobPosting peut maintenant porter toutes les métadonnées qui apparaîtront tôt ou tard dans ton backlog : une date de publication, un statut (pending, published, failed), un nombre de clics, une date d'expiration, un prix facturé. Ces données appartiennent naturellement à l'acte de publier, pas à l'offre ni à la plateforme. Une table de jointure ne peut pas les accueillir — une entité, si.
Ce qu'on a perdu
Soyons directs : $job->getBoards() n'existe plus. Si tu affiches dans une vue admin les plateformes sur lesquelles une offre a été publiée, tu ne peux plus naviguer directement depuis l'entité Job. Tu as besoin d'une requête dédiée.
C'est le compromis explicite du modèle : la relation est unidirectionnelle (JobPosting connaît son Job, Job ignore tout des plateformes), donc tu requêtes JobPosting pour reconstituer la vue. Voici à quoi ressemble un repository prévu pour ça :
// src/Infrastructure/Repository/JobPostingRepository.php use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class JobPostingRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, JobPosting::class); } /** * Retourne toutes les publications d'une offre donnée, * triées par date décroissante. * * @return JobPosting[] */ public function findByJob(Job $job): array { return $this->createQueryBuilder('p') ->andWhere('p.job = :job') ->setParameter('job', $job) ->addOrderBy('p.postedAt', 'DESC') ->getQuery() ->getResult(); } }
Une méthode, une intention. Dans ta vue admin, tu injectes JobPostingRepository et tu appelles findByJob($job) — tu récupères toutes les publications avec leur statut, leur date et leur plateforme. C'est plus explicite qu'une collection chargée à la volée depuis l'entité, et c'est entièrement contrôlable (pagination, filtre sur le statut, eager loading de jobBoard).
Avant de mapper la prochaine relation ManyToMany, pose-toi ces quatre questions :
- La relation mérite-t-elle une identité propre ? Si tu dois retrouver, modifier ou supprimer un lien spécifique entre deux entités (par exemple "la publication de cette offre sur cette plateforme"), c'est qu'il s'agit d'une entité à part entière, pas d'une entrée dans une table de jointure.
- Elle a des attributs qui lui appartiennent ? Une date de création, un statut, un prix, un compteur de vues — dès qu'un attribut décrit la relation plutôt que l'une ou l'autre des entités liées, la relation est une entité.
- Les deux côtés peuvent évoluer indépendamment ? Si
Jobpeut changer de titre sans affecter lesJobPostingexistants, et si uneJobBoardpeut changer de nom sans annuler les publications passées, les deux agrégats ont des cycles de vie distincts — unManyToManyintroduit un couplage artificiel. - Une défaillance partielle est acceptable métier ? Si publier sur trois plateformes sur cinq est préférable à ne publier nulle part, alors les cinq opérations doivent pouvoir échouer indépendamment. Une seule transaction pour toutes les associations rend ça impossible.
Si tu réponds oui à au moins deux de ces questions, le ManyToMany cache très probablement une entité de relation que ton modèle n'a pas encore nommée.
La vraie question à se poser
Les ManyToMany sont séduisants parce qu'ils fonctionnent. Doctrine les supporte nativement, la migration s'écrit en une ligne, et ton premier écran d'affichage marche du premier coup. Le problème apparaît plus tard, quand le métier demande "pourquoi cette offre n'est pas parue sur Indeed ?" — et que tu n'as aucune trace de l'échec, aucun statut, aucun moyen de relancer.
La question à se poser devant chaque relation bidirectionnelle n'est pas "est-ce que Doctrine gère ça ?", mais :
Si la réponse implique plusieurs acteurs distincts et une possibilité d'échec partiel, tu as besoin d'une entité, pas d'une jointure.
C'est la question qui révèle la modélisation juste. Pas "est-ce que c'est un ManyToMany ?", mais "qui signe ce contrat, et qu'est-ce qu'on fait quand il n'aboutit pas ?"
Ce raisonnement est inspiré d'un article de Udi Dahan — DDD & Many to Many Object Relational Mapping (2009) — court, direct, et toujours d'actualité si tu veux creuser les implications sur les agrégats et les limites de cohérence.