← Tous les cours / DDD : les agrégats Cours
00 — INTRODUCTION

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.

🔑 Le succès est un facteur de complexité. Plus d'utilisateurs, plus de fonctionnalités, plus d'accès concurrents : un modèle qui tenait à dix commandes par jour part en vrille à dix mille. Les agrégats sont la réponse structurée à cette montée en pression.

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 :

Le problème, dans les mots d'Evans
« 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.

Ce que tu vas construire dans ce cours
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 ?

01 — ANATOMIE

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.

Agrégat — la définition d'Evans
« 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.

💡 Cohérence immédiate ≠ transaction SQL. « Immédiatement cohérent » veut dire qu'aucun état intermédiaire incohérent n'est visible de l'extérieur. En pratique, sur une base relationnelle, on obtient ça avec une transaction — mais le concept est plus large que l'outil. La frontière est une propriété du modèle ; la transaction est un moyen de la respecter.

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.

Order (racine)
OrderLine
OrderLine

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.

Le Customer n'est pas dans la grappe
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.

02 — 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.

Invariant
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.

Un invariant relie des objets par le sens, pas par une clé étrangère
« 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();
}
⚠️ Piège : tant que la vérification vit dans la racine, l'invariant est garanti. Le jour où tu la déportes dans un service extérieur — « le contrôleur vérifiera bien que la commande n'est pas expédiée avant d'appeler 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.

03 — LA RACINE GARDIENNE

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.

💡 Ne renvoie jamais tes internes en écriture. Expose une copie, un tableau de valeurs figées, ou des objets en lecture seule (value objects immuables). L'extérieur peut regarder ; il ne peut pas modifier. En Doctrine, on renverra typiquement $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.

CONTOURNER LA RACINE
VIA LA RACINE
Total stocké :
Somme réelle :
04 — PAS DES RELATIONS

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.

⚠️ Le réflexe à désapprendre : concevoir un agrégat en pensant hiérarchie et relations (une commande a des lignes, un client a des commandes, donc j'emboîte tout). Une relation dans un schéma répond à la question « comment ces tables sont-elles liées ». Un agrégat répond à une tout autre question : « qu'est-ce qui doit rester cohérent ensemble ».

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.

La bonne question
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.

La conséquence : sépare les écritures des lectures (CQRS)
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.

💡 À retenir : un agrégat n'est pas un miroir de ton schéma relationnel. C'est la plus petite grappe d'objets qui doit rester cohérente lors d'une écriture. Pars des règles, pas des clés étrangères — et laisse les lectures passer à côté. Pour un cas concret où une relation many-to-many n'était pas l'agrégat qu'on croyait, va voir DDD : démêler les relations many-to-many.
05 — IDENTITÉ & CYCLE DE VIE

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é.

Entité (identité) vs value object (valeur)
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.

Genèse
Vie
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.

Close the books (clôturer les comptes)
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.
💡 À retenir : concevoir le cycle de vie, c'est se demander « quand cet agrégat naît-il vraiment, et quand a-t-il le droit de s'arrêter ? ». Un agrégat qui n'a pas de fin claire est souvent un agrégat trop gros, ou un agrégat qui a avalé de l'historique qui devrait vivre ailleurs. Donner une fin, c'est aussi une manière de garder l'agrégat à sa juste taille — le sujet de la fin du cours.
06 — MAPPING DOCTRINE

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;
Ce que chaque option garantit
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 ».
💡 cascade: ['remove'] et orphanRemoval ne font pas le même travail. Le premier agit quand tu supprimes la commande entière. Le second agit quand tu retires une ligne d'une commande qui, elle, continue d'exister. Un agrégat bien mappé a souvent besoin des deux : c'est exactement la configuration utilisée dans ce projet pour la relation entre un cours et ses sections — une racine et ses enfants, vérifiée dans le code.

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.

⚠️ Ne laisse pas l'ORM dicter la forme de ton modèle. Le vrai danger de Doctrine n'est pas technique, il est insidieux : à force de plier son modèle pour « faire plaisir » au mapping — setters partout pour l'hydratation, collections publiques exposées, entités anémiques réduites à des sacs de propriétés — on finit avec un modèle qui reflète le schéma SQL et non les règles métier. Or Doctrine sait s'accommoder de constructeurs riches, de propriétés privées et de méthodes au nom métier. Commence par modéliser la frontière et les invariants ; fais ensuite entrer Doctrine dans ce moule, jamais l'inverse.
Comportement d'abord, données ensuite
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.
07 — RÉFÉRENCER PAR L'ID

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é

Effective Aggregate Design — règle 3 (Vaughn Vernon)
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.
💡 Un ID typé, pas un 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.

Navigue dans un seul sens, depuis la racine
À 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.

⚠️ Le vrai bénéfice : référencer par l'ID et rester unidirectionnel, ce n'est pas de la rigidité gratuite. C'est ce qui garde tes agrégats découplés — chacun peut évoluer, être chargé, être verrouillé, voire être stocké séparément, sans traîner tout le voisinage derrière lui. Le couplage qu'on refuse ici est exactement celui qui, ailleurs, transforme une petite modification en cascade de régressions.
08 — TRANSACTION & COHÉRENCE

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.

Effective Aggregate Design — règle 4 (Vaughn Vernon)
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 :

Evans, sur la cohérence entre agrégats
« 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.

Verrou optimiste (optimistic locking)
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.

Base — commande #42 · version · lignes
SESSION A
version chargée :
SESSION B
version chargée :
09 — QUELLE TAILLE ?

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.

💡 La règle d'or : un agrégat doit être aussi gros que nécessaire, et aussi petit que possible. « Nécessaire » est fixé par les invariants : tout ce qu'une règle exige de garder cohérent doit tenir dans la frontière. « Possible » pousse dans l'autre sens : dès qu'un objet ne participe à aucun invariant d'écriture, il sort.

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.

Le test de la frontière
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-patternSymptômeOrigine / 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.
Vise le « hero aggregate »
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.

10 — SYNTHÈSE

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.

1. Pourquoi ?
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.
2. Quoi ?
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.
3. Quelle taille ?
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.

Agrégat (tactique) et bounded context (stratégique)
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

QuestionSi 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

PrincipeDoctrine 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 seuleRepository get/save, uniquement pour les racines
Unité de concurrence#[Version]OptimisticLockException
Cohérence entre agrégatsDomain events (voir cours dédié)
💡 Le réflexe à emporter : ne conçois pas tes agrégats en regardant ton schéma de tables. Conçois-les en cherchant les règles qui doivent rester vraies à chaque écriture, et en donnant à chacune la plus petite frontière qui la protège. Le schéma suivra ; l'inverse ne marche jamais bien longtemps.

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.

Sources
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.