Ranger et ressortir un agrégat, proprement
Tu sais ce qu'est un agrégat et pourquoi il a une frontière. Reste la question du quotidien : comment le ranger en base, le ressortir intact, et le fabriquer sans trahir ses règles ?
Dans le cours sur les agrégats, on a posé la frontière de cohérence et la règle d'or « une transaction = un agrégat ». On y a nommé, sans creuser, deux objets qui font le lien avec la base de données : le repository, qui range et ressort les agrégats, et la factory, qui les fabrique. Ce cours-ci descend d'un cran dans cette mécanique.
C'est un terrain où les malentendus abondent. On confond le repository avec « la classe où l'on met toutes les requêtes SQL ». On croit qu'un constructeur suffit toujours à créer un objet. On laisse l'ORM décider de la forme du modèle métier, puis on s'étonne que le code sente le framework plus que le domaine. On va démonter ces pièges un à un.
Le repository comme illusion d'une collection en mémoire ; les deux styles (collection-oriented vs persistence-oriented) ; comment l'ORM sait quoi sauvegarder ; pourquoi un repository ne sert pas à lire pour afficher ; les factories quand le constructeur ne suffit plus ; et la différence, cruciale, entre créer et reconstituer un objet.
Fil rouge : une commande — Order et ses OrderLine —, dans la continuité du cours sur les agrégats. On l'illustrera avec Doctrine, en s'appuyant sur du vrai code de ce projet, y compris ses choix pragmatiques qui s'écartent parfois de la théorie. Car la théorie donne le cap ; les arbitrages, eux, se font sur le terrain.
Le repository : une collection imaginaire
Le meilleur repository est celui qui te fait oublier la base de données. Tu manipules tes agrégats comme s'ils vivaient tous dans une liste en mémoire — et quelqu'un, derrière, s'occupe du SQL.
Imagine un instant que tous les objets Order de ton application tiennent dans une grande collection en mémoire. Tu voudrais en ajouter un ? Tu l'ajoutes à la collection. En retrouver un précis ? Tu le demandes par son identifiant. C'est tout. Pas de SELECT, pas de connexion, pas de mapping. C'est exactement l'illusion qu'un repository cherche à créer.
Un objet qui « fournit l'illusion d'une collection en mémoire de tous les objets d'un type donné ». Il masque entièrement le stockage : le code métier ajoute et récupère des agrégats sans jamais savoir qu'il y a une base de données derrière.
Ce n'est pas qu'une commodité d'écriture. C'est ce qui découple ton domaine de ta persistance. Ta logique métier parle d'ajouter une commande et de la retrouver ; elle ignore si, dessous, c'est du PostgreSQL, un fichier ou un service distant. Le jour où le stockage change, le domaine ne bouge pas.
Derek Martin, de la chaîne CodeOpinion, pousse la sobriété à l'extrême — et cette phrase va nous accompagner tout le cours :
« Repositories have two methods: a get and a save. That's the end of it. »
Deux méthodes. Une pour ressortir un agrégat par son identité, une pour le ranger. Cela paraît austère, presque provocateur — on y reviendra en détail, parce que la réalité impose souvent des nuances. Mais garde l'idéal en tête : un repository minuscule est un bon signe.
Order te rend une commande complète, avec ses lignes ; il n'existe pas de « repository de ligne de commande ».
Reste une question que cette illusion évite de poser trop tôt : quand tu modifies un agrégat que tu as ressorti, comment la collection « sait »-elle qu'elle doit le sauvegarder ? Selon la réponse, on tombe dans deux styles de repository radicalement différents. C'est le sujet de la section suivante.
Collection-oriented ou persistence-oriented ?
Dans une vraie collection en mémoire, tu n'appelles jamais « sauvegarde ». Tu modifies un objet, et c'est fait. Un repository peut imiter ça — ou t'obliger à réclamer chaque sauvegarde. La différence est un nid à bugs.
Reprends l'image de la collection en mémoire. Si tu sors un objet d'une liste PHP, que tu changes une de ses propriétés, l'objet dans la liste change aussi — c'est le même objet. Tu n'as rien à « sauvegarder ». Un repository qui pousse l'illusion jusqu'au bout se comporte pareil. On l'appelle collection-oriented.
Il imite un
Set en mémoire. On ajoute un objet neuf une seule fois ; ensuite, toute modification d'un objet déjà « dans » la collection est détectée automatiquement. Pas de save() explicite. Cela suppose un mécanisme qui suit les changements — un unit of work.
Il faut explicitement appeler
save() (ou put()) à chaque modification pour qu'elle soit persistée. Indispensable quand le stockage ne suit pas les changements tout seul : base document, clé-valeur, ou SQL écrit à la main.
Voici le point qui étonne : Doctrine peut fonctionner en mode collection-oriented. Son unit of work suit les entités « managed », et un simple flush() en fin de requête émet les UPDATE nécessaires, même si tu n'as jamais rappelé persist(). Pourtant, ce projet a fait le choix inverse — un style persistence-oriented explicite :
// src/Infrastructure/Persistence/Doctrine/CourseRepository.php (code réel) public function save(Course $course): void { $this->em->persist($course); $this->em->flush(); }
Le persist() n'est strictement utile que pour une entité neuve ; une entité déjà managed serait écrite au flush() sans lui. En appelant save() explicitement partout, le projet rend la persistance visible et intentionnelle — au prix du risque qu'on va voir : oublier le save().
Le simulateur met ce risque à nu. Choisis un style, modifie une quantité en mémoire, décide (ou non) d'appeler save(), puis termine la requête. Regarde ce qui atterrit vraiment en base — le résultat n'est pas celui qu'on croit.
Une ligne de commande passe de 10 à 25 en mémoire. Selon le style et selon que tu appelles save(), la base sera à jour… ou pas. Termine toujours par « fin de requête (flush) ».
en mémoire
en base
Le contrat d'un côté, Doctrine de l'autre
Pour que ton domaine ignore vraiment la base, il ne doit pas dépendre de Doctrine. L'astuce : ton métier définit un contrat, l'infrastructure le réalise. La flèche de dépendance s'inverse.
Le repository promet de découpler le domaine du stockage. Encore faut-il que ce soit vrai dans le graphe de dépendances, pas seulement dans l'intention. Si ta logique métier importe une classe Doctrine, elle dépend de Doctrine, illusion ou pas.
Une manière répandue de régler ça — parmi d'autres — consiste à couper le repository en deux : une interface qui vit avec le code métier, et une implémentation qui vit dans l'infrastructure et connaît, elle, Doctrine.
// Côté métier : un contrat pur, aucun type Doctrine (code réel du projet) interface CourseRepositoryInterface { public function save(Course $course): void; public function findById(CourseId $id): ?Course; }
// Côté infrastructure : l'implémentation qui parle à Doctrine final class CourseRepository implements CourseRepositoryInterface { public function __construct(private readonly EntityManagerInterface $em) {} public function save(Course $course): void { $this->em->persist($course); $this->em->flush(); } }
Le résultat, c'est une inversion de dépendance : le métier ne dépend plus de l'infrastructure, c'est l'infrastructure qui dépend du contrat défini par le métier. La flèche pointe vers l'intérieur. Evans le formulait déjà à sa manière : l'interface appartient au domaine, tandis que la stratégie de requête — comment on va vraiment chercher en base — appartient à l'implémentation.
Cette séparation a un bénéfice très concret pour les tests : tu peux fournir une implémentation en mémoire du contrat, tester toute ta logique métier sans base de données, et réserver Doctrine à l'implémentation réelle. Le domaine devient testable en isolation totale.
Ne laisse pas l'ORM dicter ton modèle
Un jour, tu ajoutes un constructeur privé « parce que l'ORM le veut », puis un champ technique, puis un autre. Ton modèle métier se déforme pour plaire au framework. CodeOpinion propose une échappatoire.
Il arrive un moment de friction. Ton entité métier voudrait un constructeur qui impose ses invariants, mais l'ORM préfère un constructeur vide. Elle voudrait des collections encapsulées, l'ORM veut des propriétés accessibles. Petit à petit, on tord le modèle pour l'ORM. Derek Martin décrit ce glissement sans ménagement :
« People bending over backwards to get their entities to conform to the way that entity framework needs to work. »
Sa proposition, quand la friction devient trop forte : séparer le comportement des données. D'un côté une classe métier qui porte les méthodes et les règles ; de l'autre, encapsulé en privé, un simple objet de données que l'ORM peut manipuler à sa guise.
Une classe de comportement (méthodes métier, invariants) encapsule un objet de données anémique (l'entité que l'ORM hydrate). « They can be two separate things. They don't need to be one. » L'ORM parle à l'objet de données ; le métier parle à la classe de comportement.
Mais attention à ne pas ériger cette solution en norme : c'est une réponse au frottement, pas la seule. En PHP moderne avec Doctrine, l'alternative la plus courante — et souvent préférable — est l'entité riche unique, où les value objects sont mappés via des types custom ou des embeddeds (voir le cours entités & value objects). C'est précisément le choix de ce projet : une seule classe Course, riche, avec ses value objects typés, sans dédoublement.
| Option | Idée | Quand |
|---|---|---|
| Entité riche unique | Une classe qui porte comportement et mapping, VO en types custom/embeddeds. | Le mapping reste supportable (cas le plus fréquent en Doctrine). |
| Comportement / données séparés | Deux classes : l'une métier, l'autre technique encapsulée. | Le frottement ORM devient vraiment douloureux. |
Comment l'ORM sait quoi sauvegarder
Tu modifies un objet, tu appelles flush, et le bon UPDATE part — sans que tu aies écrit une ligne de SQL. Ce n'est pas magique : c'est l'unit of work qui a comparé l'avant et l'après.
Reviens sur le mode collection-oriented de la section 02. Il repose entièrement sur un mécanisme : la capacité de l'ORM à savoir, tout seul, ce qui a changé. Derek Martin y voit d'ailleurs l'intérêt numéro un d'un ORM :
« The benefit of using an ORM is the change tracking. »
L'ORM garde en mémoire une photo de chaque entité chargée. Au moment du
flush(), il compare l'état courant à cette photo (le dirty checking) et génère les INSERT, UPDATE et DELETE nécessaires — le tout dans une seule transaction. C'est l'unit of work.
Concrètement, dans ce projet, l'EntityManagerInterface est cet unit of work. Un flux de mise à jour type ressemble à ça : on charge l'agrégat, on lui applique une méthode métier, on flush. Personne n'écrit d'UPDATE ; le dirty checking s'en charge.
// Mise à jour : on charge, on mute via une méthode métier, on flush. $section = $repository->findById($id); $section->update($titre, $contenu); // mutation métier $em->flush(); // dirty checking → UPDATE ciblé, une transaction
Cette histoire de transaction unique n'est pas un détail : c'est le socle de la règle « une transaction = un agrégat » du cours agrégats, et elle rejoint tout ce qu'on peut dire sur les transactions SQL.
Mais que fais-tu sans ORM — en base document, en clé-valeur, ou en SQL brut ? Tu n'as plus de dirty checking. La proposition de CodeOpinion est élégante : faire du suivi de changements avec des événements. Chaque comportement de l'agrégat empile un événement — ItemAdded, QuantityIncremented — et au moment du save(), le repository parcourt ces événements et exécute le SQL correspondant.
« Really you're using events as your change tracking. »
Un repository ne sert pas à afficher
Charger un agrégat entier, avec toutes ses règles et tous ses enfants, juste pour afficher une liste de titres ? C'est du gâchis. Les lectures d'affichage passent ailleurs.
Revenons à la phrase provocante du début : « get et save, c'est tout ». Sa logique profonde est là : un agrégat se charge pour être modifié, pas pour être lu. Quand tu veux afficher une liste de commandes, tu n'as pas besoin de leurs invariants ni de leurs objets-enfants — tu as besoin de quelques colonnes, vite.
Le repository sert le côté commande : ressortir un agrégat pour lui appliquer un comportement, puis le ranger. Les lectures d'affichage passent par un modèle de lecture dédié — une requête taillée pour la vue, qui ne reconstruit aucun agrégat. C'est le principe CQRS, posé dans le cours agrégats.
Si l'on suit cet idéal, le repository reste minuscule : deux méthodes, et rien d'autre. Mais la réalité d'un projet est plus nuancée, et l'honnêteté impose de le montrer. Dans ce projet, l'interface du repository de cours a grandi :
// L'interface réelle — bien plus que get/save interface CourseRepositoryInterface { public function save(Course $course): void; public function findById(CourseId $id): ?Course; public function findBySlug(Slug $slug): ?Course; public function findAllPublished(): array; public function findFeatured(): array; public function findByCriteria(/* … */): array; public function findAllTags(): array; }
Ces findAllPublished, findFeatured, findByCriteria sont des requêtes de lecture — exactement ce que l'idéal déconseille de mettre dans un repository. Est-ce une faute ? Pas nécessairement.
Retiens la tension plutôt qu'une interdiction : sache que mêler lectures et commandes dans un repository a un coût (il grossit, se couple à des besoins d'affichage), et paie ce coût en connaissance de cause, pas par défaut.
Les factories : quand le constructeur ne suffit plus
Un constructeur, c'est parfait pour un objet simple. Mais assembler un agrégat entier, cohérent du premier coup ? Là, le constructeur montre ses limites, et la factory prend le relais.
Créer un value object comme Money(1000, 'EUR'), un constructeur suffit amplement : tout est disponible, l'objet est trivialement valide. Le problème surgit quand la création devient une opération à part entière — assembler une racine, ses enfants, et faire respecter des invariants qui portent sur l'ensemble.
« Déplacer la responsabilité de créer des instances d'objets complexes et d'agrégats vers un objet séparé. » La factory encapsule la connaissance nécessaire pour produire un objet valide, quand cette connaissance dépasse ce qu'un constructeur peut raisonnablement porter.
Quatre situations appellent une factory plutôt qu'un simple constructeur :
| Situation | Pourquoi le constructeur ne suffit pas |
|---|---|
| Assembler un agrégat entier | Racine + enfants + invariants croisés à garantir d'un bloc. |
| Invariant de création complexe | La validité dépasse un seul objet ou exige un calcul. |
| Cacher le type concret | On veut retourner une abstraction, choisir l'implémentation en interne. |
| Créer est une opération métier nommée | Order::place() dit quelque chose que new Order() tait. |
Les factories prennent plusieurs formes, du plus léger au plus lourd :
// 1. Factory statique nommée : la création EST une opération métier. $order = Order::place($customerId, $lignes); // 2. Méthode factory sur la racine : elle fabrique et contrôle ses enfants. $order->addLine($produit, $quantite); // la racine valide avant d'ajouter // 3. Classe factory dédiée : quand la logique d'assemblage est lourde. $order = $orderFactory->fromQuote($devis);
Ce projet illustre parfaitement la deuxième forme. La racine Course fabrique et contrôle ses Section via une méthode qui fait respecter un invariant avant d'accepter l'enfant :
// La racine garde le contrôle : ancre unique avant d'ajouter (code réel) public function addSection(Section $section): void { foreach ($this->sections as $existing) { if ($existing->getAnchor()->equals($section->getAnchor())) { throw new DuplicateAnchorException($section->getAnchor()); } } $this->sections->add($section); }
Naître vs ressusciter
Créer un objet et le recharger depuis la base, ça se ressemble — et c'est un piège. Créer, c'est une naissance avec ses règles. Recharger, c'est restaurer un état déjà validé. Confondre les deux corrompt tes données.
Le constructeur de ton agrégat impose des invariants de naissance. Dans ce projet, celui de Course force status = Draft et sections = collection vide — parce qu'un cours qui vient de naître est forcément un brouillon sans sections. C'est correct… à la naissance.
Mais imagine qu'on recharge depuis la base un cours déjà publié, avec ses douze sections. Si on le fait passer par ce constructeur, il repartirait à Draft, sections vidées. L'état soigneusement sauvegardé serait écrasé par les règles de naissance. Catastrophe silencieuse.
Créer un objet lui donne une nouvelle identité et applique les invariants de naissance. Reconstituer un objet stocké restaure une identité et un état déjà valides : on ne réattribue pas d'identifiant, on ne rejoue pas les invariants de création. Ce sont deux opérations distinctes.
C'est exactement pour ça que Doctrine, à l'hydratation, n'appelle pas ton constructeur. Vérifié dans le code de la bibliothèque : l'instantiator utilise newInstanceWithoutConstructor(), puis remplit les champs par réflexion. Le constructeur, c'est la naissance ; l'hydratation, c'est la reconstitution — deux chemins qui ne doivent surtout pas se croiser.
// Doctrine, à l'hydratation, bypasse le constructeur (doctrine/instantiator) $entity = $reflectionClass->newInstanceWithoutConstructor(); // … puis injection des champs par réflexion, y compris status = Published
Au passage, les value objects sont eux aussi reconstitués, au niveau de chaque colonne : le type custom CourseIdType::convertToPHPValue() refait un new CourseId($value) à partir de la chaîne stockée. La reconstitution descend jusqu'aux plus petites briques.
Le simulateur rend le drame visible. Une commande expédiée, total 250 €, dort en base. Recharge-la des deux façons et compare l'état obtenu — l'une la ressuscite fidèlement, l'autre la ramène à la vie en lui effaçant la mémoire.
En base : Order expédiée, total 250 €. Choisis la façon de la recharger, puis clique. Regarde si l'état survit au rechargement — ou s'il est écrasé par les règles de naissance.
état en base
total = 250 €
objet obtenu
Bonus : le Specification pattern
Si le repository ne fait que get et save, comment poser des requêtes flexibles sans le transformer en sac à SQL ? En emballant chaque critère dans un petit objet réutilisable.
Une objection légitime revient : « si mon repository se limite à get et save, comment je fais mes recherches un peu fines sans le remplir de méthodes ? ». La réponse canonique d'Evans est un patron élégant : la specification.
Encapsuler un critère — une règle métier ou une condition de sélection — dans un objet dédié, réutilisable et composable. Au lieu de multiplier les méthodes
findByCeci, findByCela, on passe une specification au repository, qui l'applique.
Une specification répond essentiellement à une question : « cet objet satisfait-il mon critère ? ». Et comme c'est un objet, on peut la combiner — un et, un ou, une négation — pour bâtir des critères riches à partir de briques simples.
interface Specification { public function isSatisfiedBy(Order $order): bool; } final class OverdueSpecification implements Specification { public function isSatisfiedBy(Order $order): bool { return $order->isUnpaid() && $order->dueDate()->isPast(); } } // Le repository mince accepte une specification, reste petit. $enRetard = $orders->satisfying(new OverdueSpecification());
Une même specification sert à trois usages, ce qui explique sa popularité :
| Usage | Exemple |
|---|---|
| Validation | « Cette commande est-elle en règle pour être expédiée ? » |
| Sélection | « Donne-moi toutes les commandes en retard. » |
| Construction à la demande | « Crée-moi un objet qui satisfasse ce critère. » |
Synthèse : trois arbitrages à trancher
Ce cours tient dans trois décisions récurrentes. À chaque agrégat, tu les reposes — et la bonne réponse dépend de ton contexte, jamais d'un dogme.
On a ouvert la mécanique fine de la persistance des agrégats. Récapitulons non pas des recettes, mais les trois arbitrages que tu reposeras à chaque fois.
Arbitrage 1 — Constructeur ou factory ?
valide d'emblée
création, type caché
Un constructeur pour ce qui est trivialement valide ; une factory (statique nommée, méthode sur la racine, ou classe dédiée) dès qu'assembler devient une opération métier à part entière. Et toujours : valide ou échoue, jamais à moitié.
Arbitrage 2 — Collection-oriented ou persistence-oriented ?
(ORM)
(document, SQL brut)
Avec change-tracking, l'illusion de collection est complète et le save() devient superflu. Sans lui, le save() explicite est obligatoire — et l'oublier fait perdre des données. Sans ORM, les événements peuvent jouer le rôle de suivi des changements.
Arbitrage 3 — get/save ou modèle de lecture ?
L'idéal garde le repository minuscule. En pratique, un projet modeste tolère un repository un peu mixte — un compromis à payer en connaissance de cause, pas par défaut.
Reconstitution : le piège à ne jamais oublier
Créer donne une identité neuve et applique les invariants de naissance ; reconstituer restaure un état déjà validé. Doctrine l'incarne en n'appelant pas ton constructeur à l'hydratation. Si tu écris un jour ta propre couche de persistance, souviens-toi : ne fais jamais renaître un objet stocké par son constructeur.