Modéliser une relation plusieurs à plusieurs (n:n) dans un agrégat

J'ai publié hier un article sur la notion d'agrégat, si le principe peut paraître relativement simple, il peut soulever quelques questions comme par exemple, comment modéliser une relation type n:n (plusieurs à plusieurs).

Prenons un exemple que je suis amené à manipuler régulièrement: le cas de la gestion d'un lead dans une application de type CRM. Prenons un cas où une affaire peut être liée à une ou plusieurs entreprises (dans le cas d'achat groupé par exemple). L'affaire étant l'objet de base, nous allons en faire un agrégat.

Si nous cherchons à modéliser notre agrégat, nous pouvons créer un objet Affaire qui sera donc lié à une collection d'objets Entreprise:

class Affaire
{
    // ...

    /** @var Entreprise[] */
    private array $entreprises;
}

Dans notre contexte métier (que l'on pourrait représenter par un Bounded Context), lorsqu'une entreprise est reliée à une affaire, nous souhaitons conserver la date à laquelle l'entreprise a été ajouté au lead en cours.

La solution la plus simple est alors d'ajouter la date directement à notre objet entreprise comme ceci:

class Entreprise
{
    private Siren $siren;
    private string $nom;
    private Adresse $adresse;
    private DateTimeImmutable $dateAjout;

    // ...
}

Dans notre métier, il est possible d'avoir plusieurs leads différents avec une même entreprise. Si la modélisation précédente était simple et intuitive, elle a un défaut majeur: elle va nous obliger à dupliquer les informations des entreprises.
Or, on souhaiterait que si une entrepise A, qui est liée à une affaire L1 et L2, est mise à jour, les informations soient mises à jour dans les deux affaires.

Ici, les développeurs vont en général modéliser une association de la manière suivante:

class Affaire
{
    /** @var AffaireEntreprise */
    private array $entreprises;
}

class Entreprise
{
    private Siren $siren;
    private string $nom;
    private Adresse $adresse;
}

class AffaireEntreprise
{
    private Affaire $affaire;
    private Entreprise $entreprise;
    private DateTimeImmutable $dateAjout;
}

Cette modélisation est très courante car elle reflète en réalité la manière dont les données seraient modélisées dans un système de base de données relationnelle (notamment lorsque l'on travaille sur une application CRUD).

De plus, les structures de données précédentes semblent indiquer qu'une même entité Entreprise peut être partagée entre plusieurs agrégats Affaire. On enfreint ici l'un des principes fondamentaux des agrégats, à savoir que les objets qui constituent un agrégat doivent lui être propre et ne peuvent être partagés (sinon ce dernier n'est plus indépendant et il ne peut assurer la cohérence de ces données puisque les objets partagés peuvent évoluer sans qu'il en ait connaissance).

A cela s'ajoute que la manipulation d'une affaire dans notre code va conduire à une utilisation qui n'est pas des plus intuitives:

$affaire = $repository->get($id);
foreach ($affaire->getEntreprises() as $entrepriseAffaire) {
    // ici la variable `entrepriseAffaire` représente en réalité une classe
    // d'association entre un objet `Affaire` et un objet `Entreprise`
    // il est alors nécessaire de passer par un accesseur supplémentaire pour
    // accéder à notre entreprise
    $entreprise = $entrepriseAffaire->getEntreprise();
}

// cela est d'autant plus visible sur on cherche à accéder à la première
// entreprise de l'affaire
$affaire->getEntreprises()[0]->getEntreprise();

Personnellement, je trouve cette écriture complètement contre-intuitive. Et surtout, il y a fort à parier que les développeurs ne comprennent pas réellement ce qui est manipuler lorsque l'on accède à $affaire->getEntreprises().
Si on en revient au DDD, cette notion de AffaireEntreprise n'est pas une notion métier, c'est une glu technique. Tout cela semble indiquer que nous faisons une erreur de modélisation de notre domaine.

Mais comment écrire proprement cette relation alors ? Avant de répondre à cette question, prenons le temps de réfléchir aux données que nous manipulons. Disons que nous travaillons dans un contexte métier (Bounded Context) type CRM. Ce que nous venons de mettre en évidence, c'est que dans ce contexte, nous travaillons avec des Entreprise qui peuvent vivre de manière indépendantes. Ces dernières ont un cycle de vie qui n'est pas lié à notre contexte de vente.

Dans notre contexte de gestion de vente, nous souhaitons simplement modéliser un lien entre une affaire et une entreprise. De ce fait, la seule information sur une société qui nous intéresse est son SIREN (son identifiant unique). Dans notre contexte de gestion des ventes, nous n'avons pas à nous préoccuper de comment sont gérées les informations attachées à une entreprise (soit via un système tiers, soit via un autre contexte de notre application).

Concrètement, la modélisation d'une entreprise dans le cadre de notre lead peut alors se modéliser ainsi:

class Affaire
{
    /** @var Entreprise[] */
    private array $entreprises;
}

class Entreprise
{
    private Siren $siren;
    private DateTimeImmutable $dateAjout;
}

Ce code nous permettra ainsi de manipuler nos objets avec une API intuitive:

$affaire = $repository->get($id);
foreach ($affaire->getEntreprises() as $entreprise) {
    // je peux accéder directement aux informations utiles à mon contexte
    $entreprise->getSiren();
}

$affaire->getEntreprises()[0]->getSiren();

Comme je l'ai rapidement évoqué précédemment, l'obtention des détails d'une entreprise (ainsi que leurs mises à jour) n'est pas de la responsabilité de notre contexte (ces informations ne sont pas utiles et doivent/peuvent évoluer de manière indépendante à notre système).

Au travers de cette nouvelle modélisation, notre agrégat ne travaille dorénavant plus avec de données partagées et dont il n'a pas la maitrise. Il n'est plus dépendant de modifications extérieures puisqu'il n'y a plus d'objets en commun.

Si maintenant notre code doit être amené à afficher des informations concernant des informations additionnelles aux données traitées par notre agrégat (comme par exemple le nom de la société que nous ne stockons pas) cela pourra se faire à la demande au moyen de sources données externes. Ces dernières ayant la responsabilité de gérer la donnée et de la mettre à jour, garantissant ainsi que notre contexte accédera toujours à une donnée à jour.