Pourquoi des agrégats ?
Ton modèle marche très bien — jusqu'au jour où il a du succès.
Imagine une petite boutique en ligne, les premiers mois. Une poignée de commandes par jour, un seul développeur, une base de données qui tient dans un mouchoir de poche. Tu ajoutes une ligne à une commande, tu recalcules le total, tu enregistres : tout se passe bien, parce qu'à ce moment-là, personne d'autre ne touche à cette commande en même temps que toi. Le modèle est simple, et il est simple parce que rien ne le met sous pression.
Puis la boutique marche. Vraiment. Les commandes affluent, l'équipe grandit, on ajoute des promotions, des cartes cadeaux, un programme de fidélité, un entrepôt qui réserve le stock. Et là, quelque chose se met à grincer. Deux requêtes modifient la même commande à un dixième de seconde d'intervalle. Un total affiché ne correspond plus à la somme de ses lignes. Une commande déjà expédiée se retrouve avec une ligne ajoutée après coup, va savoir comment. Rien de tout cela n'arrivait au début — non pas parce que le code était meilleur, mais parce qu'il n'était jamais poussé dans ses retranchements.
Eric Evans, qui a posé les fondations du Domain-Driven Design, décrit précisément ce point de rupture dans son livre de référence :
« It is difficult to guarantee the consistency of changes to objects in a model with complex associations. Invariants need to be maintained that apply to closely related groups of objects, not just discrete objects, yet cautious locking schemes cause multiple users to interfere pointlessly. »
Décortiquons cette phrase, parce qu'elle contient tout le programme du cours. Deux besoins s'y cachent. Le premier : maintenir des règles qui portent non pas sur un objet isolé, mais sur un groupe d'objets liés — « le total d'une commande égale la somme de ses lignes » ne parle ni de la commande seule, ni d'une ligne seule, mais des deux ensemble. Le second : gérer la concurrence sans que des verrous trop larges ne bloquent des utilisateurs qui n'avaient rien à se disputer.
Un agrégat répond aux deux d'un coup. C'est une manière de tracer, dans ton modèle, une frontière à l'intérieur de laquelle la cohérence est garantie à chaque instant, et qui devient du même mouvement l'unité que tu verrouilles, que tu charges et que tu sauvegardes. Ni plus, ni moins.
On file un seul exemple du début à la fin : une commande e-commerce —
Order, ses lignes OrderLine, et le client Customer. À partir de là, on va tracer la frontière, désigner la racine, protéger les invariants, puis descendre dans le mapping Doctrine réel (cascade, référence par identité, verrou optimiste). L'objectif n'est pas de réciter une définition, mais de savoir décider : où passe la frontière, et pourquoi là.
Un dernier mot avant de commencer. L'agrégat est un outil tactique : il structure le code au plus près des entités. Il a un cousin stratégique, le bounded context, qu'on croisera à la fin. Et il vit dans une famille de patterns qu'on a déjà explorés ailleurs — si les relations bidirectionnelles t'ont déjà mordu, le cours DDD : démêler les relations many-to-many est un bon compagnon de route. Ici, on se concentre sur une seule question, la plus structurante de toutes : qu'est-ce qui doit rester cohérent ensemble, tout le temps ?
Anatomie d'un agrégat : grappe, frontière, racine
Trois pièces, et une seule porte d'entrée.
Reprenons la commande. Quand tu y penses concrètement, tu ne vois jamais une commande « toute seule » : tu vois une commande et ses lignes, un en-tête et son contenu, un montant total et le détail qui le justifie. Ces objets ne vivent pas leur vie chacun dans leur coin — ils forment une grappe, un petit paquet qui a du sens comme un tout. C'est exactement l'intuition qu'Evans a mise en mots.
« A cluster of associated objects that we treat as a unit for the purpose of data changes. Each aggregate has a root and a boundary. »
Une grappe d'objets liés qu'on traite comme une seule unité quand on modifie des données. Chaque agrégat a une racine et une frontière.
Trois mots à retenir, et on va les prendre un par un : la grappe, la frontière, la racine.
La frontière : là où la cohérence est garantie
La frontière, c'est le trait que tu dessines autour de la grappe. Tout ce qui est dedans doit être cohérent à chaque changement — immédiatement, sans délai. Pas « cohérent à la fin de la journée », pas « cohérent après le prochain batch » : cohérent maintenant, à l'instant précis où la modification est validée. Si le total de la commande doit égaler la somme de ses lignes, alors il n'existe aucun moment observable où ce n'est pas vrai.
Un point qui surprend souvent : cette frontière est artificielle. Elle n'est pas dictée par la nature des choses ni gravée dans le domaine. C'est toi qui la traces, en fonction des règles que tu veux garantir. Le même ensemble d'objets peut donner des frontières différentes selon ce que le métier exige. La frontière est une décision de conception, pas une observation.
La racine : le gardien, seule porte d'entrée
Dans la grappe, un objet a un statut particulier : la racine d'agrégat (aggregate root). C'est le videur à l'entrée de la boîte de nuit. Personne n'entre par la fenêtre ; tout le monde passe devant lui. Concrètement, cela veut dire deux choses. D'abord, le monde extérieur ne connaît que la racine : rien, en dehors de l'agrégat, ne tient une référence directe vers une ligne de commande. Ensuite, toute modification de la grappe passe par une méthode de la racine — c'est $order->addLine(...) qu'on appelle, jamais $line->setQuantity(...) attrapé au vol depuis l'extérieur.
Pourquoi cette discipline ? Parce que c'est la racine, et elle seule, qui sait faire respecter les règles de la grappe. Si n'importe qui pouvait modifier une ligne dans son coin, le total deviendrait faux sans que personne ne soit responsable de le corriger. En centralisant les modifications sur la racine, tu as un seul endroit qui garantit qu'après chaque opération, la grappe est de nouveau dans un état valide.
Dans notre exemple,
Order est la racine, OrderLine vit à l'intérieur de la frontière — mais Customer, lui, est en dehors. Une commande référence un client, elle ne le contient pas. Le client est la racine de son propre agrégat, avec sa propre frontière. Retiens cette image : deux agrégats côte à côte, chacun gardé par sa racine, qui se parlent par-dessus la clôture sans jamais entrer l'un chez l'autre. On y reviendra en détail (section « référencer par l'identité »).
Grappe, frontière, racine : tu as le squelette. Mais on n'a pas encore répondu à la question qui décide de tout — pourquoi ces objets-là forment-ils une grappe, et pas d'autres ? La réponse n'a rien à voir avec les relations entre tables. Elle tient dans un seul mot, qu'on attaque tout de suite : les invariants.
Les invariants : les règles qui ne mentent jamais
Ce qui doit rester vrai, quoi qu'il arrive.
Un invariant, c'est une règle qui est vraie tout le temps. Pas « la plupart du temps », pas « une fois la validation passée » : tout le temps, à chaque instant où quelqu'un peut observer l'objet. Le mot vient du latin invariare, « ne pas changer » — et c'est exactement l'idée : une propriété que le système s'engage à ne jamais laisser fausse.
Une règle métier qui doit rester vraie en permanence sur la grappe d'objets, quelles que soient les opérations effectuées. Ce n'est pas une validation ponctuelle à l'entrée : c'est une garantie permanente que l'agrégat porte sur lui-même.
Prends une carte de crédit. Son invariant le plus évident : le solde utilisé ne dépasse jamais le plafond. Ce n'est pas « on vérifie au moment de payer » — c'est qu'il n'existe aucun état de la carte où le dépassement soit vrai. Chaque opération qui pourrait le violer est soit ajustée, soit refusée. La règle tient les rênes.
Les invariants de notre commande
Reviens à Order. Quelles sont les vérités qui ne doivent jamais être prises en défaut ? Deux sautent aux yeux, et elles vont nous accompagner tout le cours.
La première : le total de la commande égale la somme de ses lignes. À la seconde où tu ajoutes une ligne, où tu changes une quantité, où tu appliques une remise, le total doit suivre. Il ne doit jamais exister un instant où le total affiché raconte une histoire différente de celle du détail. C'est un invariant qui porte sur plusieurs objets à la fois — la commande et ses lignes — et c'est précisément pour ça qu'il justifie une frontière commune.
La seconde : on n'ajoute pas de ligne à une commande déjà expédiée. Une fois le colis parti, le contenu est figé. Tenter d'ajouter un article n'est pas une petite erreur qu'on corrige plus tard — c'est une opération qui n'a aucun sens dans le monde réel, et l'agrégat doit la refuser net.
« Le total égale la somme des lignes » ne parle pas d'une relation
one-to-many dans un schéma. Il parle d'une règle qui n'a de sens que si commande et lignes changent ensemble, sous une même garde. C'est cette règle qui fait la grappe — pas le fait qu'il y ait une clé étrangère entre deux tables.
Faire respecter l'invariant, c'est le métier de la racine
Voici le lien avec la section précédente. Si toute modification passe par la racine, alors la racine a un endroit unique où revérifier ses invariants après chaque opération. Ajouter une ligne ne se contente pas de pousser un objet dans une collection : la méthode recalcule le total (invariant 1) et vérifie d'abord que la commande n'est pas expédiée (invariant 2).
public function addLine(Product $product, int $quantity): void { // Invariant 2 : une commande expédiée est figée. if ($this->status === OrderStatus::Shipped) { throw new OrderAlreadyShippedException($this->id); } $this->lines[] = new OrderLine($product->id(), $product->price(), $quantity); // Invariant 1 : le total suit toujours le détail. $this->recalculateTotal(); }
addLine » — tu viens de créer une faille : il suffit d'un seul appelant qui oublie la vérification pour casser la règle. C'est le sujet exact de la prochaine section.
Retiens l'ordre des priorités : d'abord l'invariant, ensuite la frontière. On ne regroupe pas des objets parce qu'ils sont « liés » dans un diagramme ; on les regroupe parce qu'une règle exige qu'ils changent ensemble sous une même garde. Trouve l'invariant, et la frontière se dessine presque toute seule.
La racine, seule porte d'entrée
Contourne le gardien, et l'invariant tombe.
On a posé le principe : toute modification passe par la racine. Reste à comprendre pourquoi c'est non négociable — et le meilleur moyen, c'est de voir ce qui casse quand on triche. Il y a deux façons classiques de contourner la racine, deux violations qui reviennent tout le temps dans le code réel.
Violation 1 — exposer un objet interne et le muter de l'extérieur
La racine expose ses lignes, sincèrement, pour qu'on puisse les afficher. Sauf qu'en PHP, rendre une collection, c'est rendre des références vers les objets. Quiconque récupère une ligne peut désormais la modifier dans son coin, sans que Order ne soit jamais au courant — et donc sans que le total ne soit recalculé.
// Le contrôleur récupère une ligne… et la mute directement. $line = $order->getLines()[0]; $line->setQuantity(10); // La racine n'en sait rien. // Résultat : la ligne dit 10, le total stocké dit encore l'ancien montant. // L'invariant « total = somme des lignes » est faux, sans coupable.
Violation 2 — vérifier un invariant en dehors de l'agrégat
La seconde fuite est plus sournoise, parce qu'elle a l'air propre. On déplace la règle métier dans un service, « pour bien séparer les responsabilités » :
// Dans un service, hors de l'agrégat — la logique fuit. if ($order->getStatus() !== OrderStatus::Shipped) { $order->getLines()[] = new OrderLine(/* … */); }
Le problème n'est pas que ce code soit faux ici. C'est qu'il fait de chaque appelant le gardien de la règle. Il suffit d'un second endroit dans la base de code qui oublie le if, et une commande expédiée se voit ajouter une ligne. La garantie n'existe plus : elle dépend de la discipline de tous, pour toujours. C'est ce qu'on appelle une logique qui fuit (leaking logic).
La correction : ramener tout à l'intérieur
Les trois remèdes tiennent en une phrase : appeler l'opération via la racine, ne renvoyer que du lecture seule, et garder l'invariant dedans. La méthode addLine qu'on a écrite à la section précédente fait déjà tout ça : elle vérifie le statut, ajoute la ligne, recalcule le total — le tout en un seul geste que personne ne peut découper.
$this->lines->toArray() ou une projection, jamais la Collection mutable elle-même.
Le simulateur ci-dessous te met les deux chemins côte à côte sur la même commande. Applique la même opération « en direct » (en contournant la racine) puis « via la racine », et regarde ce qui arrive à l'invariant. Un chemin corrompt en silence ; l'autre corrige ou refuse. C'est toute la différence entre un modèle qui tient et un modèle qui ment.
Deux chemins, une commande. Choisis une opération, puis fais-la contourner la racine ou passer par elle. Passe la commande en « expédiée » pour voir la racine refuser ce que le contournement laisse casser.
Un agrégat, ce n'est pas des relations
L'erreur la plus répandue : concevoir par le schéma, pas par les règles.
Voici l'erreur que presque tout le monde commet en découvrant les agrégats, et elle est parfaitement compréhensible. On regarde son schéma de base de données, on voit une commande qui a plusieurs lignes, un one-to-many bien net, et on conclut : « voilà mon agrégat, la commande et ses lignes ». On a raison par accident, cette fois — mais la méthode est fausse, et elle va te trahir au premier cas un peu tordu.
La nuance est fine mais décisive. Deux objets peuvent être fortement reliés dans ton schéma sans partager le moindre invariant — et alors ils n'ont aucune raison de vivre dans le même agrégat. Un client a des commandes ; faut-il pour autant que Customer contienne toutes ses Order dans une seule grappe ? Sûrement pas : aucune règle n'exige que le client et ses cent commandes soient chargés, verrouillés et sauvegardés d'un bloc. La relation existe ; l'invariant partagé, non. Donc pas d'agrégat commun.
Ne demande pas « qu'est-ce qui est relié à quoi ». Demande « quelle règle exige que ces objets changent ensemble, dans la même transaction, sous une même garde ». Seul un invariant partagé justifie de mettre deux objets dans le même agrégat. La relation, elle, peut parfaitement se traduire par une simple référence d'un agrégat vers un autre.
Les invariants ne concernent que les écritures
Voilà l'observation qui fait tout basculer, et qui vient droit de la pratique. Un invariant, c'est une règle qui protège un changement d'état. « On ne dépasse pas le plafond » n'a de sens qu'au moment où l'on débite. « Le total suit les lignes » n'a de sens qu'au moment où l'on modifie les lignes. Une règle d'intégrité, par définition, garde une écriture.
Or que fait la grande majorité de ton application ? Elle lit. Elle affiche la liste des commandes, le détail d'une facture, un tableau de bord, un historique. Et pour lire, tu n'as besoin d'aucun invariant : personne ne peut violer une règle en regardant. Afficher une commande ne met en danger aucune cohérence.
Puisque l'agrégat n'existe que pour protéger les écritures, ne t'en sers que pour ça. Les commandes (au sens « ordres de changement d'état ») passent par l'agrégat, qui garantit les invariants. Les requêtes de lecture, elles, court-circuitent complètement l'agrégat : elles vont chercher les données via un modèle de lecture optimisé — une requête SQL directe, une vue, une projection — sans jamais charger ni reconstruire l'agrégat. C'est le principe CQRS (Command Query Responsibility Segregation).
Côté écriture (commande)
// On charge l'agrégat entier, // il garantit ses invariants. $order = $orders->get($id); $order->addLine($p, 2); $orders->save($order);
Côté lecture (requête)
// Aucun agrégat : SQL direct // vers un modèle de lecture. $rows = $db->fetchAll( 'SELECT id, total, status FROM orders WHERE …');
L'effet secondaire heureux : des agrégats plus petits
Quand tu arrêtes de bourrer l'agrégat de tout ce qui pourrait un jour servir à l'affichage, il maigrit. Il ne garde que ce qui participe réellement à un invariant d'écriture. Tout le reste — les données qu'on ne fait que consulter, les jointures pratiques pour un écran — sort de la frontière et rejoint le modèle de lecture. Le résultat est un agrégat focalisé, léger, rapide à charger et à verrouiller. On reparlera de cette chasse au superflu dans la section sur la taille.
Identité et cycle de vie
Une commande n'est pas définie par ce qu'elle contient, mais par le fait qu'elle est elle.
Imagine deux commandes rigoureusement identiques : mêmes articles, mêmes quantités, même montant, passées à la même seconde par deux clients différents. Sont-elles la même commande ? Évidemment non. Ce sont deux commandes distinctes, qui suivront chacune leur destin — l'une sera annulée, l'autre expédiée. Ce qui les distingue n'est aucune de leurs valeurs : c'est leur identité.
Une entité est définie par une identité qui persiste dans le temps, indépendamment de ses attributs. Deux entités aux valeurs identiques restent distinctes — comme deux vrais jumeaux. Un value object, à l'inverse, est défini par ses valeurs : deux montants de 20 € sont interchangeables, il n'y a rien à distinguer. La racine d'un agrégat est toujours une entité : elle a une identité stable qui la suit du premier jour au dernier.
Cette distinction n'est pas de la philosophie. Elle a une conséquence directe : c'est parce que la racine a une identité stable que le monde extérieur peut la référencer par cette identité seule (l'OrderId), sans jamais tenir l'objet lui-même. On y revient à la section suivante — retiens juste que l'identité est ce qui rend un agrégat référençable.
Un agrégat a une vie, avec un début et une fin
Une commande n'apparaît pas par magie et ne reste pas éternellement « en cours ». Elle naît, elle vit, elle se termine — et à chaque étape, c'est le domaine, pas la technique, qui décide. On appelle ça le cycle de vie de l'agrégat, et le concevoir explicitement évite bien des modèles qui gonflent sans fin.
La genèse est pilotée par un événement métier : une commande est passée, un contrat est signé, un client est enregistré. Ce n'est jamais « on instancie un objet vide qu'on remplira plus tard » — l'agrégat naît déjà valide, avec ce qu'il faut pour respecter ses invariants dès la première seconde. La vie, c'est la suite des changements d'état légitimes : on ajoute des lignes, on applique une remise, on paie, on expédie. La fin est, elle aussi, un fait du domaine : la commande est livrée et archivée, le contrat expire, le compte est clôturé. Après quoi l'agrégat ne change plus.
« Close the books » : clore une période pour ne pas grossir sans fin
Certains agrégats risquent de ne jamais s'arrêter. Pense à un compte qui accumule chaque mouvement depuis son ouverture : au bout de trois ans, recharger tout l'historique pour ajouter une seule opération devient absurde. Les comptables ont résolu ce problème il y a des siècles, et le vocabulaire du DDD leur a emprunté l'image.
Plutôt que de laisser un agrégat gonfler indéfiniment, on clôt délibérément une période — un mois, un exercice — et on en ouvre une nouvelle qui ne retient que le solde de report, l'état courant qui résume tout ce qui précède. L'historique clôturé part dans un modèle de lecture ou une archive ; l'agrégat actif, lui, reste petit et rapide. On borne sa croissance au lieu de la subir.
Mapper un agrégat avec Doctrine
Traduire une frontière de cohérence en attributs de mapping — sans laisser l'ORM dicter le modèle.
Jusqu'ici, tout était conceptuel. Descendons dans le concret : comment un agrégat prend-il corps avec Doctrine ORM ? La bonne nouvelle, c'est que les concepts se traduisent remarquablement bien — la frontière devient une histoire de cascade, la possession du cycle de vie devient orphanRemoval, la racine devient le seul point d'accès du repository. La moins bonne nouvelle, c'est qu'il y a un piège à éviter : laisser Doctrine décider de la forme de ton modèle.
La racine possède ses enfants : cascade + orphanRemoval
Une OrderLine n'a aucune existence hors de sa commande. Elle naît avec elle, meurt avec elle, et personne ne la manipule séparément. En termes Doctrine, cela se dit exactement : la racine porte l'association vers ses enfants avec une cascade de persistance et de suppression, et un retrait des orphelins.
// Order.php — la racine possède le cycle de vie de ses lignes. #[ORM\OneToMany( targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist', 'remove'], orphanRemoval: true, )] private Collection $lines;
cascade: ['persist'] — enregistrer la commande enregistre ses lignes neuves ; on ne persiste jamais une ligne à la main.cascade: ['remove'] — supprimer la commande supprime ses lignes.orphanRemoval: true — une ligne retirée de la collection est supprimée en base, parce qu'une ligne sans commande n'a pas de raison d'exister. C'est la traduction fidèle de « la racine possède le cycle de vie de ses enfants ».
Les value objects deviennent des embeddeds
Le montant d'une ligne n'est pas un simple int : c'est un Money, une somme couplée à une devise, avec ses règles (on n'additionne pas des euros et des dollars). C'est un value object typique, et Doctrine sait le stocker « à plat » dans la table de l'entité qui le porte, sans table séparée, via un embeddable.
#[ORM\Embeddable] final class Money { public function __construct( #[ORM\Column] public readonly int $amount, // en centimes #[ORM\Column(length: 3)] public readonly string $currency, ) {} } // Dans OrderLine : #[ORM\Embedded(class: Money::class)] private Money $unitPrice;
Le repository ne connaît que la racine : get / save
Voici une règle qui déroute au début : tu n'écris de repository que pour les racines d'agrégats. Pas de OrderLineRepository. Jamais. Une ligne n'a pas d'existence propre, donc pas d'accès propre. Et le repository de la racine se réduit à deux verbes : récupérer l'agrégat entier, sauvegarder l'agrégat entier.
interface OrderRepository { public function get(OrderId $id): Order; // charge la grappe complète public function save(Order $order): void; // persiste la grappe complète }
« Mais comment j'affiche la liste des commandes, alors ? » Par le modèle de lecture, pas par le repository de l'agrégat — on l'a vu à la section précédente. Le repository sert les écritures ; les écrans passent par des requêtes dédiées. Séparer les deux garde le repository minuscule et l'agrégat concentré sur son rôle.
Une racine bien conçue expose des intentions (
$order->addLine(), $order->ship()), pas des accesseurs anonymes (setLines(), setTotal()). Le change-tracking de Doctrine (son unit of work) observe l'objet et déduit les UPDATE nécessaires au flush() — tu n'as pas à exposer des setters pour ça. Laisse les méthodes métier piloter l'état ; laisse Doctrine observer le résultat.
Bidirectionnalité = couplage ; référencer par l'identité
Entre deux agrégats, on ne se tient pas par la main — on se connaît par son nom.
On arrive au cœur de la question qui coûte le plus cher en pratique : comment deux agrégats se référencent-ils ? Notre commande appartient à un client. Le réflexe Doctrine est immédiat — un #[ManyToOne] de Order vers Customer, et pendant qu'on y est, un #[OneToMany] de Customer vers ses Order pour pouvoir naviguer dans les deux sens. C'est exactement ce qu'il ne faut pas faire, et il faut comprendre pourquoi.
La règle de Vaughn Vernon : référencer les autres agrégats par leur identité
Un agrégat référence un autre agrégat par son identité (son ID), jamais par une référence d'objet directe.
Order ne tient pas un Customer ; il tient un CustomerId.
Pourquoi cette règle, qui semble d'abord peu pratique ? Parce qu'une référence d'objet direct t'invite en permanence à franchir une frontière que tu voulais respecter. Si Order tient un vrai Customer, rien ne t'empêche d'écrire $order->getCustomer()->changeAddress(...) — et voilà que tu modifies deux agrégats dans la même opération, ce qui viole la règle « une transaction = un agrégat » qu'on verra juste après. Tenir seulement un CustomerId ferme cette porte : pour agir sur le client, il faut aller le chercher explicitement, par son repository, en pleine conscience.
❌ Référence d'objet — les frontières fuient
#[ORM\ManyToOne( targetEntity: Customer::class)] private Customer $customer; // Tentation permanente : $order->getCustomer() ->changeAddress($a);
✅ Référence par identité — frontière nette
#[ORM\Column( type: CustomerIdType::NAME)] private CustomerId $customerId; // Pour agir sur le client, // il faut le charger exprès.
int nu. Référencer par identité ne veut pas dire stocker un entier anonyme. On mappe un value object d'identité (CustomerId) via un type Doctrine custom — la même mécanique que les identifiants typés déjà en place dans ce projet, où un CourseId est persité par un type dédié plutôt que par un ManyToOne. Tu gardes le typage fort et l'intention, sans traîner l'objet entier.
La bidirectionnalité, c'est du couplage qu'on s'inflige
Reste la tentation de la navigation dans les deux sens. Elle paraît confortable — $customer->getOrders(), si pratique — mais elle a un prix qu'on paie longtemps. Une association bidirectionnelle lie les deux entités par un fil permanent : chacune doit connaître l'autre, maintenir la cohérence des deux côtés, et le moindre changement de l'une peut secouer l'autre. Tu as créé une dépendance là où une simple référence suffisait.
À l'intérieur d'un agrégat, la navigation est unidirectionnelle : la racine connaît ses enfants (
Order → OrderLine), pas l'inverse. Entre agrégats, la référence est unidirectionnelle elle aussi et passe par l'ID (Order → CustomerId). Le besoin « toutes les commandes d'un client » existe bel et bien — mais c'est une lecture, et on le sert par une requête sur le modèle de lecture (SELECT … WHERE customer_id = ?), pas par une collection mappée qui couple les deux agrégats pour toujours.
Ce raisonnement sur « qui doit vraiment porter l'association » est précisément celui qu'on déroule en détail, sur un cas many-to-many épineux, dans DDD : démêler les relations many-to-many. Si tu veux voir la bidirectionnalité Doctrine faire des dégâts concrets et comment la démonter proprement, c'est là qu'il faut aller.
Une transaction = un agrégat
À l'intérieur, tout de suite. Entre agrégats, un peu plus tard.
On a répété qu'un agrégat est l'unité de cohérence. Il est aussi, et c'est indissociable, l'unité de transaction et l'unité de concurrence. Ces trois rôles n'en font qu'un, et c'est la raison d'être profonde de tout ce qu'on a construit jusqu'ici.
Une transaction modifie un seul agrégat. Tout ce qui doit changer ensemble de façon atomique est dans la même frontière ; tout ce qui est dans une autre frontière sera mis à jour dans une autre transaction.
La conséquence est nette : à l'intérieur de la frontière, la cohérence est immédiate — le total suit les lignes dans la même transaction, sans le moindre délai. Entre deux agrégats, en revanche, on renonce à cette immédiateté. Evans le formule sans détour :
« Any rule that spans aggregates will not be expected to be up to date at all times. »
Une règle qui traverse plusieurs agrégats ne sera pas tenue à jour à chaque instant. On accepte un décalage — une cohérence à terme (eventual consistency).
Comment cette mise à jour différée se propage-t-elle d'un agrégat à l'autre ? Par des domain events : quand la commande est payée, elle émet un événement, et le programme de fidélité du client réagit plus tard, dans sa propre transaction. C'est un sujet à part entière, qu'on traite dans DDD : les Domain Events — ici, il suffit de retenir que la frontière de l'agrégat est aussi la frontière entre « tout de suite » et « bientôt ».
L'unité de concurrence : le verrou optimiste
Rappelle-toi le problème du tout début : deux requêtes qui modifient la même commande au même instant. Sans protection, la seconde écrase la première sans s'en apercevoir — c'est la mise à jour perdue (lost update). L'agrégat étant l'unité de concurrence, c'est lui qu'on protège, et Doctrine offre pour ça le verrouillage optimiste.
Plutôt que de verrouiller la ligne en base pendant toute la durée du traitement (verrou pessimiste, coûteux), on parie que les conflits sont rares. La racine porte un numéro de version ; à chaque
flush, Doctrine vérifie que la version en base est toujours celle qu'on avait chargée. Si quelqu'un est passé entre-temps, la version a changé et Doctrine lève une OptimisticLockException : ta transaction est refusée, à toi de recharger et de rejouer.
// Order.php — une seule ligne suffit à protéger tout l'agrégat. #[ORM\Version] #[ORM\Column] private int $version = 1;
Élégance de la chose : la version est sur la racine, et elle protège toute la grappe. Modifier n'importe quelle ligne incrémente la version de la commande. C'est cohérent avec tout le reste — la racine est le point de contrôle unique, y compris pour la concurrence. Le simulateur ci-dessous te fait vivre le scénario de bout en bout.
Deux sessions, une commande. Fais charger la commande par A et par B, puis fais-les enregistrer chacune une ligne. En séquentiel, tout passe. En concurrence — les deux chargent avant que l'une n'enregistre — le verrou optimiste rattrape la mise à jour perdue.
Quelle taille pour un agrégat ?
Aussi gros que nécessaire, aussi petit que possible.
La question de la taille est celle qui sépare ceux qui récitent les agrégats de ceux qui savent les concevoir. Trop petit, et un invariant réel n'est plus protégé. Trop gros, et tout devient lent, verrouillé, douloureux à faire évoluer. Il existe une formule pour viser juste, empruntée à Thomas Ploch, et elle mérite d'être gravée quelque part.
L'heuristique de frontière : les invariants dedans, jamais de dépendance au dehors
Comment savoir concrètement où passe le trait ? Coopman et Verschatse proposent un test simple et redoutablement efficace. Dessine la frontière, puis vérifie une seule chose : pour prendre sa décision, l'agrégat ne doit jamais avoir besoin de regarder à l'extérieur.
Si toute l'information dont un invariant a besoin pour être vérifié se trouve dans la frontière, la cohérence est facile à garantir. Si l'agrégat doit consulter une donnée extérieure pour décider, il y a un problème : cette donnée extérieure peut changer en même temps, sous concurrence, et ta décision devient fausse à l'instant où tu la prends.
Un exemple concret du piège. Imagine une règle « on ne peut ouvrir la porte que si le badge est valide », où la validité du badge vit dans un autre agrégat. Tu vérifies, c'est valide, tu ouvres — mais entre ta vérification et l'ouverture, le badge a été révoqué ailleurs. Ta décision reposait sur une information extérieure devenue fausse. Deux issues : soit tu agrandis la frontière pour que la validité du badge soit dedans (mais l'agrégat grossit), soit tu introduis une boucle de confirmation (une action faillible suivie d'un accusé de réception qui rétablit la cohérence). C'est un arbitrage taille contre cohérence, et il n'a pas de réponse unique — il dépend du métier.
Les trois villains : les agrégats qui tournent mal
Coopman a une galerie de portraits des agrégats ratés. En reconnaître les traits t'évitera de les peindre toi-même.
| Anti-pattern | Symptôme | Origine / remède |
|---|---|---|
| Le mauvais agrégat | La frontière protège une règle qui n'existe pas vraiment (« un utilisateur doit être unique par e-mail » — alors que le métier tolère les doublons). | Mauvaise compréhension des contraintes. Retourner voir le métier, corriger l'invariant supposé. |
| L'agrégat géant | Une racine qui charge des centaines d'objets, verrouille large, s'écrit lentement, provoque des conflits à répétition. | On a regroupé par relations, pas par invariants. Danger sournois : « once you see an aggregate, it's hard to unsee it » — une fois qu'on l'a dessiné gros, on cesse d'imaginer les découpes alternatives. Refactor douloureux. |
| L'agrégat inutile | Une frontière soigneusement tracée… autour de rien. Aucun invariant à protéger. | Ce n'était pas un agrégat. C'était peut-être une simple entité, ou même un bounded context. Language matters : donne-lui son vrai nom. |
L'objectif de Coopman : l'agrégat exactement nécessaire — celui qui contient tous les invariants à protéger et rien de plus. On y arrive par une boucle de rétroaction : on part petit, et on n'agrandit que lorsqu'un invariant réel l'exige, jamais « au cas où ». La tendance naturelle pousse à grossir ; le travail de conception consiste à résister à cette pente et à sortir sans cesse ce qui ne mérite pas d'être dedans.
Vaughn Vernon dit la même chose depuis l'angle de la performance : concevoir de petits agrégats est sa deuxième règle, précisément parce que la taille se paie en mémoire, en durée de verrou et en fréquence de conflits. Petit n'est pas un compromis esthétique — c'est ce qui rend le système tenable quand il a du succès. La boucle est bouclée avec la toute première section.
Synthèse : penser en agrégats
Trois questions, et un guide pour décider.
On a beaucoup traversé : la frontière, la racine, les invariants, le mapping Doctrine, la référence par identité, la concurrence, la taille. Il est temps de replier tout ça en quelques réflexes utilisables au quotidien. Thomas Ploch résume la démarche en trois questions, à se poser dans l'ordre.
Quel invariant faut-il protéger, et quelle concurrence faut-il encaisser ? Pas d'invariant, pas de concurrence : sans doute pas besoin d'agrégat.
Quels objets doivent rester cohérents ensemble à chaque écriture ? Ceux-là, et seulement ceux-là, entrent dans la frontière. La racine devient leur unique porte.
Aussi gros que nécessaire, aussi petit que possible. Tout ce qui ne sert aucun invariant d'écriture sort — vers un autre agrégat ou vers le modèle de lecture.
« Est-ce vraiment un agrégat ? »
Avant de baptiser quelque chose « agrégat », pose-toi honnêtement la question du nom. Le vocabulaire n'est pas cosmétique en DDD : c'est le langage ubiquitaire, partagé avec le métier, et l'employer de travers déforme le modèle. Une entité complexe avec des enfants n'est pas automatiquement un agrégat — il lui faut une frontière explicite, un invariant réel à garder, et de la concurrence à gérer. S'il manque l'un de ces trois éléments, cherche le bon mot : peut-être une simple entité, peut-être un value object, peut-être même un bounded context.
L'agrégat trace une frontière autour de quelques objets ; le bounded context trace une frontière autour d'un modèle entier et de son langage. Ce sont deux échelles du même geste — délimiter pour maîtriser la complexité. « Les frontières sont partout », dit Evans : l'agrégat en est la version rapprochée. Un agrégat vit toujours à l'intérieur d'un bounded context, et ses consommateurs n'en voient que la racine, jamais les rouages.
Guide de décision express
| Question | Si oui… | Si non… |
|---|---|---|
| Une règle exige-t-elle que ces objets changent ensemble, atomiquement ? | Même frontière. | Frontières séparées, référence par ID. |
| L'opération est-elle une écriture qui met en jeu un invariant ? | Passe par l'agrégat. | Modèle de lecture (CQRS), pas d'agrégat. |
| L'invariant a-t-il besoin d'une info extérieure à la frontière ? | Agrandir la frontière ou boucle de confirmation. | La frontière est bien placée. |
| Deux agrégats doivent-ils se connaître ? | Référence par identité (ID typé), unidirectionnelle. | Ne rien mapper. |
| Deux agrégats doivent-ils rester cohérents entre eux ? | Cohérence à terme via domain events. | Rien à faire. |
Traduction Doctrine, en un coup d'œil
| Principe | Doctrine ORM 3 |
|---|---|
| Racine possède ses enfants | #[OneToMany(cascade: ['persist','remove'], orphanRemoval: true)] |
| Value objects | #[Embeddable] / #[Embedded] |
| Référence par identité | Colonne d'ID (type custom) au lieu d'un #[ManyToOne] |
| Accès par la racine seule | Repository get/save, uniquement pour les racines |
| Unité de concurrence | #[Version] → OptimisticLockException |
| Cohérence entre agrégats | Domain events (voir cours dédié) |
Pour aller plus loin
Deux cours prolongent directement celui-ci : DDD : démêler les relations many-to-many pour voir la référence entre agrégats et la bidirectionnalité à l'œuvre sur un cas concret, et DDD : les Domain Events pour la cohérence à terme entre agrégats.
Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003) — le « Blue Book » : définition de l'agrégat, invariants, cohérence à terme.
Vaughn Vernon, Implementing Domain-Driven Design (2013) et son article Effective Aggregate Design — les quatre règles (vrais invariants, petits agrégats, référence par identité, cohérence à terme).
Conférences DDD Europe : Thomas Ploch (What Is an Aggregate?), Milan Coopman & Thibaut Verschatse (Aggregates in Depth), et les interventions d'Eric Evans sur les bounded contexts.