← Tous les cours / DDD : repositories & factories Cours
00 — AU-DELÀ DE GET/SAVE

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.

Où l'on va
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.

💡 Prérequis : ce cours suppose acquis le cours DDD : les agrégats (frontière, racine, un repository par racine, transaction unique). On ne les redémontre pas ici — on part de là.
01 — UNE COLLECTION IMAGINAIRE

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.

Repository (Evans)
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 :

Derek Martin (CodeOpinion)
« 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.

🔑 Un repository par racine d'agrégat, pas un par table. C'est une règle du cours agrégats qu'on prolonge ici : on range et on ressort des agrégats entiers par leur racine, jamais leurs objets-enfants isolément. Le repository d'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.

02 — DEUX STYLES DE REPOSITORY

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.

Repository 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.
Repository persistence-oriented
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().

💡 Ni l'un ni l'autre n'est « le bon ». Le collection-oriented est plus élégant et colle mieux à l'illusion, mais dépend d'un unit of work fiable. Le persistence-oriented est plus explicite et indispensable sans ORM, mais expose au bug de l'oubli. C'est un arbitrage, pas un dogme.

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

quantité = 10

en base

quantité = 10
aucun SQL
03 — INTERFACE & IMPLÉMENTATION

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.

🔑 Un choix, pas une loi universelle. Séparer interface et implémentation est une façon de découpler, très courante mais pas obligatoire. Certaines applications, plus modestes, vivent très bien avec un repository concret unique. Le principe qui compte — « mon domaine ne doit pas dépendre du détail de stockage » — se réalise de plusieurs manières. Ne prends pas la recette pour le principe.

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.

04 — L'ORM NE DICTE PAS LE MODÈLE

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 :

Derek Martin (CodeOpinion)
« 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.

Séparer comportement et données (CodeOpinion)
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.

OptionIdéeQuand
Entité riche uniqueUne 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ésDeux classes : l'une métier, l'autre technique encapsulée.Le frottement ORM devient vraiment douloureux.
💡 Le vrai principe : c'est ton domaine qui commande la forme de ton modèle, pas ton ORM. Comment tu concilies les deux — entité riche ou séparation — est un arbitrage local. Doctrine est assez souple pour l'entité riche dans la grande majorité des cas ; garde la séparation en réserve pour les frottements réels.
05 — COMMENT L'ORM SAIT QUOI SAUVER

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 :

Derek Martin (CodeOpinion)
« The benefit of using an ORM is the change tracking. »
Unit of work & dirty checking
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.

Derek Martin (CodeOpinion)
« Really you're using events as your change tracking. »
🔑 Le pont vers les événements. Cette idée — les changements d'un agrégat sont une suite de faits qu'on rejoue — est la porte d'entrée des Domain Events. Le change-tracking de l'ORM et le flux d'événements résolvent le même problème (« qu'est-ce qui a changé, et comment le persister ? ») par deux chemins différents.
06 — UN REPOSITORY NE LIT PAS

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.

Commandes vs lectures
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.

💡 Un choix de simplicité assumé. Pour un catalogue de cours — quelques dizaines d'entrées, un trafic modeste — séparer un modèle de lecture complet serait de la sur-ingénierie. Un repository un peu mixte est ici un compromis raisonnable. À grande échelle, ou quand les vues divergent fortement du modèle d'écriture, la séparation redevient payante. La règle donne le cap ; le contexte décide de la rigueur — c'est tout l'esprit du DDD stratégique.

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.

07 — QUAND LE CONSTRUCTEUR NE SUFFIT PLUS

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.

Factory (Evans)
« 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 :

SituationPourquoi le constructeur ne suffit pas
Assembler un agrégat entierRacine + enfants + invariants croisés à garantir d'un bloc.
Invariant de création complexeLa validité dépasse un seul objet ou exige un calcul.
Cacher le type concretOn veut retourner une abstraction, choisir l'implémentation en interne.
Créer est une opération métier nomméeOrder::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);
}
🔑 Une factory produit un objet valide, ou échoue. Il n'y a pas d'entre-deux : soit elle rend un agrégat entièrement cohérent, soit elle lève une exception. C'est atomique. On ne récupère jamais d'un factory un objet « à moitié construit » qu'il faudrait finir de configurer.
💡 Où mettre l'assemblage ? Dans ce projet, c'est un point d'entrée applicatif qui joue la factory : il crée la commande, y rattache chaque ligne, tire les identités et vérifie l'unicité. Ce n'est qu'une façon de placer la création — l'essentiel est qu'un objet unique et bien nommé porte la responsabilité d'assembler un agrégat valide, où que tu décides de le loger.
08 — NAÎTRE VS RESSUSCITER

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éation vs reconstitution (Evans)
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

status = SHIPPED
total = 250 €

objet obtenu

— pas encore rechargé —
09 — LE SPECIFICATION PATTERN

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.

Specification pattern
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é :

UsageExemple
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. »
💡 À manier avec discernement. Ce projet n'utilise pas le Specification pattern — pour un catalogue simple, ce serait de la sur-ingénierie, et l'exemple ci-dessus est purement pédagogique. La specification brille quand les critères se multiplient, se recombinent et méritent d'être testés isolément. En dessous de ce seuil, quelques méthodes de requête bien nommées suffisent.
10 — SYNTHÈSE

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 ?

Objet simple,
valide d'emblée
→ constructeur
·
Agrégat, invariant de
création, type caché
→ factory

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 ?

Unit of work fiable
(ORM)
→ collection-oriented
·
Stockage sans suivi
(document, SQL brut)
→ persistence-oriented

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 ?

Charger pour modifier
→ repository (get/save)
·
Charger pour afficher
→ read model (CQRS)

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.

🔑 Le fil conducteur : le repository crée l'illusion d'une collection en mémoire ; la factory garantit qu'un agrégat naît valide ou pas du tout ; la reconstitution restaure sans réécrire. Trois rôles, une même obsession — que ton code métier ignore, autant que possible, qu'il y a une base de données derrière.

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.

Pour aller plus loin

💡 Bibliographie : Eric Evans, Domain-Driven Design (2003), chapitres Repositories et Factories ; Vaughn Vernon, Implementing DDD (2013), chapitre Repositories (collection- vs persistence-oriented) ; Martin Fowler, PoEAA (Unit of Work, Repository) ; Derek Martin, CodeOpinion, « Aggregate Root Design & Persistence ».