← Tous les cours / DDD : entités & value objects Cours
00 — POURQUOI DES VALUE OBJECTS

Un prix n'est pas un float

On stocke un prix dans un float, un e-mail dans un string, un pays dans deux lettres. Ça marche — jusqu'au jour où l'on additionne des euros avec des dollars sans que rien ne proteste.

Imagine une méthode qui calcule le total d'une commande. Elle reçoit des montants, elle les additionne, elle retourne un nombre. Un jour, une ligne en dollars se glisse parmi des lignes en euros. Le code additionne quand même — 10.0 + 12.5 fait 22.5, peu importe la devise. Aucune erreur, aucune alerte. Juste un total silencieusement faux, qui partira en facture.

Le problème n'est pas le calcul. C'est qu'on a représenté un concept riche — une somme d'argent — par un type pauvre : un simple nombre. Le nombre ne sait pas qu'il est en euros. Il ne sait pas qu'on ne mélange pas les devises. Toute cette connaissance métier vit ailleurs, éparpillée dans des if, ou nulle part.

Primitive obsession
L'habitude de représenter des concepts métier avec des types primitifs — float pour un prix, string pour un e-mail, int pour un numéro de téléphone. Martin Fowler en a fait un « code smell » : le type ne porte ni les règles ni le sens du concept.

Peter Hilton, dans une conférence consacrée à la modélisation des valeurs, résume l'enjeu d'une formule qu'on va garder en tête tout le cours :

Peter Hilton
« It is not enough to say that a value is text or a number or a date. »

Le remède porte un nom dans le Domain-Driven Design : le value object. Au lieu d'un float nu, une petite classe Money qui tient ensemble le montant et la devise, qui refuse d'être construite invalide, et qui sait qu'additionner des euros et des dollars n'a pas de sens. Le concept devient explicite, et les règles vivent avec lui.

Ce cours est la fondation tactique du DDD. On y verra deux natures d'objets — les entités et les value objects — puis tout ce qui fait la force des seconds : l'égalité par valeur, l'immutabilité, la validation à la construction, le « whole value ». On finira par les brancher sur Doctrine : embeddeds, types custom, enums. C'est la brique qui sert ensuite à bâtir les agrégats.

01 — ENTITÉ VS VALUE OBJECT

Entité ou value object : une question d'identité

Deux billets de dix euros sont interchangeables : personne ne te rendra « ton » billet précis. Deux personnes qui portent le même nom, elles, restent deux personnes distinctes. Toute la distinction est là.

Prends deux billets de dix euros dans ton portefeuille. Peux-tu les distinguer ? Non — et surtout, ça n'a aucune importance. Si tu en prêtes un et qu'on te rend « l'autre », tu n'as rien perdu. Ils sont définis entièrement par ce qu'ils valent : dix euros. C'est un value object.

Maintenant, deux clients de ton application qui s'appellent tous deux « Jean Martin ». Sont-ils interchangeables ? Absolument pas. Chacun a son historique, ses commandes, sa continuité dans le temps. Même s'ils partageaient tous leurs attributs — même nom, même adresse, même date de naissance — ils resteraient deux personnes différentes. C'est une entité.

Entité
Un objet défini par une identité et un fil de continuité dans le temps, pas par ses attributs. Ses attributs peuvent tous changer sans qu'il cesse d'être lui-même. Elle est généralement mutable. Exemples : Customer, Order.
Value object
Un objet qui décrit, mesure ou quantifie quelque chose, et qui n'a aucune identité propre : il est entièrement défini par ses attributs. Deux value objects aux mêmes attributs sont, conceptuellement, le même. Exemples : Money, EmailAddress, PostalAddress.

Le test mental qui tranche presque toujours : « ai-je besoin de suivre cet objet dans le temps, de le distinguer d'un autre qui lui ressemble ? ». Si oui, c'est une entité, il lui faut une identité. Si non — si seule sa valeur compte — c'est un value object.

💡 Le même concept peut basculer selon le contexte. Une adresse postale est un simple value object pour un service de facturation — on ne « suit » pas une adresse, on la lit. Mais pour un fournisseur d'électricité, chaque adresse est un point de livraison qu'on suit dans le temps, avec un compteur et un historique : là, c'est une entité. Ce n'est pas l'adresse qui décide, c'est ton domaine. On retrouve ici l'idée de bounded context du versant stratégique.

Notre fil rouge pour tout le cours : une entité Customer, qui porte plusieurs value objects — EmailAddress, PersonName, PostalAddress, Money pour son solde de fidélité. Le Customer a une identité stable ; ses valeurs, elles, se remplacent au fil du temps sans jamais le transformer en quelqu'un d'autre.

Customer
entité — a une identité
porte →
EmailAddress
value object
PersonName
value object
Money
value object

Une heuristique de Vaughn Vernon, qui a écrit la référence sur l'implémentation du DDD, vaut d'être gardée en boussole : « strive to use value objects ». En cas de doute, penche pour le value object. On verra en fin de cours pourquoi les value objects rendent le code tellement plus sûr et plus simple à tester — et cette section-ci va commencer par leur propriété la plus fondamentale : l'égalité.

02 — L'ÉGALITÉ PAR VALEUR

L'égalité par valeur : même valeur, même objet

Deux value objects se comparent sur ce qu'ils contiennent, pas sur l'endroit où ils vivent en mémoire. En PHP, cette distinction se joue sur un caractère : `==` ou `===`.

Reprenons les deux billets de dix euros. Ils sont « égaux » parce qu'ils valent la même chose. C'est ça, l'égalité par valeur : deux value objects sont égaux si — et seulement si — tous leurs attributs sont égaux. Peu importe qu'il s'agisse de deux instances différentes en mémoire.

Égalité structurelle
new Money(10, 'EUR') doit être logiquement égal à un autre new Money(10, 'EUR'), bien que ce soient deux objets distincts. Conséquence directe : les value objects sont interchangeables — on peut remplacer l'un par l'autre sans que rien ne change.

Une entité, elle, se compare sur son identité. Deux Customer sont le même si — et seulement si — ils ont le même identifiant, même si tous leurs autres attributs diffèrent. C'est l'exact opposé du value object.

En PHP, il y a un piège de langage à connaître par cœur, parce qu'il produit des bugs silencieux. Sur deux objets, l'opérateur == compare les propriétés une à une, tandis que === vérifie que c'est strictement la même instance en mémoire.

$a = new Money(10, 'EUR');
$b = new Money(10, 'EUR');

$a == $b   // true  — mêmes propriétés
$a === $b  // false — deux instances différentes

Se reposer sur == est fragile : il ne connaît pas les règles de ton domaine (faut-il ignorer la casse d'un e-mail ? normaliser une devise ?). La bonne pratique est d'exposer une méthode equals() explicite, qui dit exactement ce que « égal » veut dire pour ce concept.

final readonly class EmailAddress
{
    public function __construct(public string $value) { /* … validation … */ }

    public function equals(self $other): bool
    {
        // un e-mail est insensible à la casse : on compare en minuscules
        return strtolower($this->value) === strtolower($other->value);
    }
}
🔑 À retenir : une entité expose equals() qui compare des identités ; un value object expose equals() qui compare des valeurs. Ne jamais laisser === décider de l'égalité métier — il répond à une question de mémoire, pas de sens.

Le simulateur ci-dessous rend cette bascule tangible. Deux montants aux attributs strictement identiques : selon que tu les traites comme des value objects ou comme des entités, la réponse à « sont-ils égaux ? » s'inverse. Manipule, la conclusion n'est pas devinable à la lecture.

Deux montants, mêmes attributs. Choisis la nature de l'objet, puis demande s'ils sont « égaux ». Observe comme la réponse dépend entièrement de la façon dont tu as décidé de modéliser le concept.

objet A

Money(10, EUR)

objet B

Money(10, EUR)
a.equals(b) : ? a === b : ?
03 — IMMUTABILITÉ

Immutabilité : remplacer plutôt que muter

Un value object ne change jamais après sa naissance. Pour « le modifier », on en fabrique un nouveau. Cette règle, qui paraît rigide, élimine toute une famille de bugs sournois.

Puisqu'un value object n'est que sa valeur, le modifier n'aurait aucun sens : changer le montant d'un Money(10, 'EUR') pour en faire 15, ce n'est plus « le même billet modifié », c'est un autre billet. Le principe en découle naturellement.

Immutabilité
Un value object ne change jamais après sa construction. Pour obtenir une valeur différente, on crée une nouvelle instance et on remplace la référence — on ne touche pas à l'ancienne.

PHP 8 rend ce contrat vérifiable par le moteur lui-même. Une propriété readonly ne peut être écrite qu'une fois, dans le constructeur ; toute tentative ultérieure lève une erreur. Une classe final readonly gèle l'ensemble.

final readonly class Money
{
    public function __construct(
        public int    $amount,    // en centimes, pour éviter les flottants
        public string $currency,
    ) {}

    // Ne mute pas : retourne un NOUVEAU Money. C'est un « wither ».
    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new CurrencyMismatchException($this->currency, $other->currency);
        }
        return new self($this->amount + $other->amount, $this->currency);
    }
}

Remarque la méthode add() : elle ne modifie pas $this, elle renvoie un tout nouveau Money. C'est ce qu'Eric Evans appelle une side-effect-free function — une opération qui calcule un résultat sans jamais altérer d'état. On les compose sans crainte, on les teste sans mock ni base de données.

Pour changer une valeur portée par une entité, on remplace donc le value object entier, via une méthode métier explicite :

// Dans l'entité Customer — on remplace, on ne mute pas.
public function changeEmail(EmailAddress $email): void
{
    $this->email = $email;   // nouveau VO, entièrement validé, mis à la place de l'ancien
}

Pourquoi tant de rigueur ? Parce que muter une valeur partagée provoque un bug particulièrement vicieux : l'aliasing. Si deux endroits de ton code pointent vers le même objet mutable et que l'un le modifie, l'autre se retrouve modifié à son insu. Avec des objets immuables, ce bug est structurellement impossible : personne ne peut changer une valeur sous les pieds d'un autre.

Deux paniers partagent le même montant en mémoire. Tente de le « muter en place », puis de le « remplacer ». Regarde lequel des deux paniers bouge — et mesure le danger d'une valeur mutable partagée.

panier 1

panier 2

💡 Un bénéfice discret mais énorme : comme un value object immuable ne peut pas changer, tu peux le partager entre dix objets sans jamais faire de copie défensive. Pas de clone par précaution, pas de « et si quelqu'un le modifiait ? ». Le raisonnement redevient local. D'ailleurs, un domain event est lui aussi un value object immuable : un fait passé ne se modifie pas.
04 — VALIDATION À LA CONSTRUCTION

Toujours valide : la validation au constructeur

Un value object n'existe jamais à moitié faux. Il valide ses invariants à la naissance, ou il refuse de naître. Mais attention : valider n'est pas rejeter à tort — et là, la plupart des validateurs se trompent.

Un value object bien conçu a une garantie précieuse : dès qu'il existe, il est valide. Il n'y a pas de Money à devise vide qui traîne, pas d'EmailAddress sans arobase. La validation se fait dans le constructeur, et si les invariants ne sont pas respectés, l'objet n'est pas construit — une exception est levée.

Principe « always-valid »
Un value object valide ses invariants dans son constructeur et lève une exception s'ils sont violés. Conséquence : partout ailleurs dans le code, tu manipules un objet dont tu sais qu'il est valide. Plus de vérifications défensives éparpillées.
final readonly class EmailAddress
{
    public function __construct(public string $value)
    {
        if (!str_contains($value, '@')) {
            // exception du DOMAINE, pas une exception PHP générique
            throw new InvalidEmailException($value);
        }
    }
}
🔑 L'exception vient du domaine. On ne lève pas un \InvalidArgumentException générique : on crée une exception qui parle le langage métier, comme InvalidEmailException. Elle dit quoi a échoué et pourquoi, et on peut la rattraper précisément. Une exception générique noierait cette information.

Jusqu'ici, tout va bien. Mais voici le piège où presque tout le monde tombe, et c'est la contribution la plus précieuse de Peter Hilton : valider, ce n'est pas rejeter tout ce qui sort de l'ordinaire. Un validateur trop zélé fait plus de dégâts qu'un champ libre.

Peter Hilton
« Don't tell somebody that their name is invalid. »

Prends la validation des noms de personnes. La tentation est d'écrire ^[A-Za-z ]+$ — « des lettres et des espaces ». Ce validateur rejette García, O'Brien, van der Berg, Nguyễn, , Æ — c'est-à-dire une bonne partie de l'humanité. Les recommandations du W3C sur les noms, rappelle Hilton, se résument à une phrase : « allow everything ».

À l'inverse, valider un e-mail comporte en réalité quatre niveaux de certitude, et confondre le premier avec les autres mène à sur-valider :

NiveauCe qu'on vérifieComment
1 · FormatLa chaîne ressemble à un e-mail (RFC).Vérifiable dans le value object.
2 · DomaineLe domaine existe (DNS, MX).Requête réseau — hors du VO.
3 · BoîteL'adresse existe vraiment.Impossible sans envoyer.
4 · PersonneC'est bien la bonne personne.E-mail de confirmation.
💡 La bonne calibration : self-validation, oui — mais calibrée sur ton vrai domaine, pas sur une regex naïve qui refuse des valeurs parfaitement réelles. Un value object valide le niveau qu'il peut garantir seul (le format), et laisse les autres niveaux là où ils appartiennent.

Le simulateur confronte les deux philosophies sur des noms bien réels. Bascule entre le validateur « naïf » et le « permissif », et compte les gens que le premier refuse alors qu'ils existent pour de vrai.

Des noms authentiques passent au validateur. Choisis la stratégie, puis lance la vérification. Le validateur naïf va rejeter des personnes bien réelles — c'est exactement ce qu'il ne faut pas faire.

choisis une stratégie
Rejetés à tort :
05 — LE WHOLE VALUE

Le whole value : un tout qu'on ne découpe pas

Un montant sans sa devise ne veut rien dire. Un intervalle de dates dont on peut mettre la fin avant le début est un piège ambulant. Certaines valeurs ne se tiennent que d'un seul bloc.

Retour à notre Money. Pourquoi tenir le montant et la devise ensemble, dans le même objet, plutôt que de balader deux variables séparées ? Parce qu'un montant seul n'a aucun sens : « 10 » n'est ni riche ni pauvre tant qu'on ne sait pas si ce sont des euros ou des yens. Le montant et la devise forment un tout cohérent — un whole value.

Whole value
Un value object dont les attributs forment un ensemble indissociable, porteur de sens seulement pris comme un tout. Money = montant + devise. DateRange = début + fin, avec l'invariant « fin ≥ début » validé sur l'ensemble. Séparer les morceaux, c'est perdre le sens et ouvrir la porte aux états incohérents.

Le whole value a un pouvoir qu'on sous-estime : il peut faire respecter un invariant qui porte sur plusieurs attributs à la fois. Un DateRange peut garantir, dès sa construction, que la fin ne précède jamais le début. Impossible de créer un intervalle absurde ; le concept se protège lui-même.

final readonly class DateRange
{
    public function __construct(
        public \DateTimeImmutable $debut,
        public \DateTimeImmutable $fin,
    ) {
        if ($fin < $debut) {
            throw new InvalidDateRangeException($debut, $fin);  // invariant sur le TOUT
        }
    }
}

Mais il y a un versant inverse, et c'est là que Peter Hilton apporte une leçon contre-intuitive : certaines valeurs sont des touts qu'il ne faut surtout pas décomposer. Le meilleur exemple, c'est le nom d'une personne.

Peter Hilton
« You should not try to decompose personal names into their parts. It does not work. »

La tentation universelle est de casser un nom en « prénom » + « nom de famille ». Ça marche pour « Jean Martin », et ça s'effondre partout ailleurs. Dans quel champ mets-tu « van der Berg » ? Et les cultures où le nom de famille se dit en premier ? Et celles où il n'y a qu'un seul nom ? La structure d'un nom est spécifique à chaque pays, et aucun découpage universel n'existe.

💡 La solution de Hilton : traiter le nom comme un whole value, et modéliser des variantes selon l'usage plutôt que des parties. Un nom d'affichage complet, une forme courte pour dire bonjour, une forme pour le tri — chacune est une vue du tout, jamais un morceau qu'on recolle. Tu gardes ce dont tu as besoin, sans imposer une anatomie qui n'existe pas.

Retiens la double leçon : un whole value regroupe ce qui n'a de sens qu'ensemble (montant + devise), et refuse de découper ce dont la structure interne est trop variable pour être universelle (un nom). Dans les deux cas, la bonne granularité vient du domaine, jamais d'un réflexe technique de « normalisation ».

06 — LES VALEURS UNIVERSELLES

Modéliser les valeurs du monde réel

Noms, adresses, pays, devises, e-mails, numéros de téléphone : ces valeurs existent dans le monde, hors de ton logiciel. La façon de les modéliser, elle, appartient à ton domaine — et regorge de pièges.

Certaines valeurs ne t'appartiennent pas : elles existent dans le monde, bien avant ton application. Un pays, une devise, une langue, un e-mail — Peter Hilton les appelle les valeurs universelles. Le paradoxe, c'est que même universelles, la façon de les modéliser dépend entièrement de ton domaine.

Peter Hilton
« These values exist outside your software. They're out in the world. »

Première bonne nouvelle : pour beaucoup de ces valeurs, quelqu'un a déjà fait le travail. Des standards internationaux existent, et les réutiliser t'évite de réinventer — mal — une roue mondiale.

ValeurStandardForme
LangueISO 639-12 lettres minuscules — fr, en
PaysISO 3166-1 alpha-22 lettres majuscules — FR, JP
DeviseISO 42173 lettres — EUR, USD
Noms localisésUnicode CLDRrègles d'affichage par culture

Ces valeurs à ensemble fini et connu ont une traduction naturelle en PHP : l'enum. C'est d'ailleurs, note Hilton, un motif récurrent — « a common theme with modeled values is that they are frequently enumerated types ». Un enum backed est le value object le plus simple qui soit : un ensemble fermé de valeurs, validées par le langage lui-même.

// Le value object le plus simple : un enum. Impossible d'inventer une devise inconnue.
enum Currency: string
{
    case EUR = 'EUR';
    case USD = 'USD';
    case JPY = 'JPY';
}
🔑 Modéliser une valeur ≠ modéliser la liste. Écrire un value object pour un code pays est simple. Mais « la liste de tous les pays » ne fait pas consensus (Hilton insiste là-dessus) : Kosovo, Taïwan, Palestine… Là où c'est politiquement contesté, tu choisis une source de vérité et tu l'assumes — tu ne l'inventes pas.

Vient ensuite un piège que tout le monde a déjà commis : croire que « ce qui s'appelle numéro est un nombre ».

Peter Hilton
« If somebody calls it a number… it's probably not a number. »

Un numéro de téléphone commence parfois par un zéro (que le type int mangerait), contient un +, des espaces. Un numéro de maison peut être « 12 bis ». Un IBAN, un ISBN, un numéro de sécurité sociale : tous « numéros » de nom, tous du texte par nature. Les stocker comme des entiers, c'est perdre de l'information dès la première écriture.

Cela conduit à distinguer deux représentations d'une même valeur : la forme de stockage (canonique, sans fioritures) et la forme d'affichage (groupée, lisible). Un IBAN se stocke d'un bloc et s'affiche par groupes de quatre ; un téléphone se stocke en +33612345678 et s'affiche 06 12 34 56 78. Le value object peut porter les deux, et même valider un check digit — l'IBAN embarque une clé de contrôle qui détecte les fautes de frappe et les chiffres intervertis.

Deux derniers principes, brefs mais tranchants. D'abord, la règle d'or de Hilton contre le réflexe binaire :

💡 « There are always more than two options. » Ne modélise pas en booléen. Un utilisateur n'est pas « actif : oui/non » — il est inscrit, en attente, suspendu, fermé. Le jour où un troisième état apparaît (et il apparaît toujours), un enum se prolonge, un booléen se réécrit dans tout le code.

Enfin, la minimisation des données, qui rejoint l'article 5 du RGPD : « you shouldn't ask for things you don't need, especially when it's personal data ». Avant de modéliser un attribut, demande-toi si tu en as vraiment besoin. La meilleure donnée personnelle à sécuriser est celle que tu n'as jamais collectée.

07 — MAPPER : LES EMBEDDEDS

Mapper un value object composite : les embeddeds

Ton Money vit dans le code. Reste à le ranger en base sans lui inventer une table ni une identité qu'il n'a pas. Doctrine a un outil taillé pour ça : l'embeddable.

Un value object composite comme Money pose une question concrète de persistance : il n'a pas d'identité, donc pas de table à lui. Ses données doivent vivre dans la table de l'objet qui le porte. Doctrine appelle ça un embeddable : une classe dont les colonnes sont fondues dans la table du parent.

Deux attributs suffisent. #[ORM\Embeddable] marque la classe value object, et #[ORM\Embedded] l'insère dans une entité.

use Doctrine\ORM\Mapping as ORM;

#[ORM\Embeddable]
final readonly class Money
{
    public function __construct(
        #[ORM\Column(type: 'integer')]
        public int $amount,                // en centimes
        #[ORM\Column(type: 'string', length: 3)]
        public string $currency,
    ) {
        if ($amount < 0)  throw new NegativeMoneyException($amount);
    }
}

#[ORM\Entity]
class Order
{
    #[ORM\Embedded(class: Money::class, columnPrefix: 'total_')]
    private Money $total;   // → colonnes total_amount, total_currency
}

Le columnPrefix évite les collisions quand une entité porte plusieurs value objects du même type — une adresse de facturation et une de livraison, par exemple. La signature de l'attribut, vérifiée dans le code de Doctrine ORM 3, est #[Embedded(?string $class = null, string|bool|null $columnPrefix = null)] : passer false au préfixe colle les colonnes sans aucun préfixe.

⚠️ Le piège de la nullabilité. Un embeddable n'a pas d'existence indépendante : ses colonnes sont celles du parent. Gérer un embedded entièrement optionnel (tout null) est délicat, car il n'y a pas de « ligne » distincte à mettre à NULL. Le plus sûr : rendre l'embedded obligatoire (colonnes NOT NULL) ou modéliser explicitement l'absence — un Money::zero() plutôt qu'un null, par exemple.
🔑 Note d'honnêteté : ce projet-ci n'utilise aujourd'hui aucun embedded — l'exemple est pédagogique, pas un extrait du code en place. Comme toujours, vérifie le comportement exact sur ta version de Doctrine avant de t'engager : la persistance des value objects composites a des recoins qui changent d'une version majeure à l'autre.

L'embeddable règle le cas des value objects à plusieurs champs. Reste le cas plus fréquent encore : le value object à une seule valeur, souvent une identité typée. C'est l'objet de la section suivante.

08 — TYPES DBAL & ENUMS

Value object mono-valeur : types DBAL custom & enums

Comment ranger un EmailAddress dans une simple colonne texte, tout en le récupérant comme un vrai objet validé ? Doctrine te laisse écrire ta propre passerelle entre la base et le type PHP.

Un value object qui n'enveloppe qu'une valeur — un e-mail, un slug, un identifiant typé — n'a pas besoin de plusieurs colonnes. Il lui faut une traduction : objet PHP ↔ chaîne en base. Doctrine appelle ça un type DBAL custom, et c'est exactement le mécanisme qu'utilise ce projet pour ses identifiants.

Un type custom hérite d'un type Doctrine existant et implémente deux conversions : convertToPHPValue() (base → objet) et convertToDatabaseValue() (objet → base). Voici le patron réel du projet pour l'identité de cours :

// src/Infrastructure/Persistence/Doctrine/Type/CourseIdType.php  (extrait réel du projet)
final class CourseIdType extends GuidType
{
    public const NAME = 'course_id';

    public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?CourseId
    {
        return null === $value ? null : new CourseId($value);   // base → value object
    }

    public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string
    {
        return $value instanceof CourseId ? $value->getValue() : $value;   // VO → base
    }
}

Le type se déclare une fois dans la configuration, puis s'utilise sur n'importe quelle colonne :

# config/packages/doctrine.yaml
doctrine:
  dbal:
    types:
      course_id: App\Infrastructure\Persistence\Doctrine\Type\CourseIdType

// puis dans l'entité :
#[ORM\Column(type: CourseIdType::NAME, length: 36)]
private CourseId $id;

Pour les value objects à ensemble fini — ceux qu'on modélise en enum, comme vu en section 06 — Doctrine offre une voie encore plus directe : enumType. Pas de classe de type à écrire, on branche l'enum backed directement.

// enum backed comme value object, mappé sans type custom (usage réel du projet)
#[ORM\Column(enumType: AccentColor::class)]
private AccentColor $accent;
🔑 Deux outils, un même but : faire vivre des value objects dans ton entité sans jamais retomber sur des primitives. Le type DBAL custom pour un value object riche à une valeur (validation, méthodes) ; l'enumType pour un ensemble fermé de constantes. Dans les deux cas, ton code manipule des objets métier, jamais des string nus.
💡 Un mot d'honnêteté sur les exceptions. Les value objects d'identité de ce projet lèvent aujourd'hui un \InvalidArgumentException générique à la construction. C'est justement l'écart que la section 04 t'invite à ne pas reproduire : une exception du domaine (InvalidCourseIdException) porterait mieux le sens. Le patron de persistance ci-dessus reste valable ; c'est le type d'exception levée qui gagnerait à être métier.
09 — VALUE OBJECT OU ENTITÉ ?

Le guide de décision : value object ou entité ?

Face à un concept nouveau, une seule question bien posée tranche presque toujours. Et par défaut, la réponse penche du même côté : value object.

Tu tiens maintenant les deux natures et tout ce qui les distingue. Reste à décider, dans le feu de la modélisation, à quel camp appartient un concept. La question qui tranche tient en une phrase :

La question qui décide
« Ai-je besoin de suivre cet objet dans le temps, de le distinguer d'un autre qui lui ressemble trait pour trait ? » — Oui → entité (il lui faut une identité). Non, seule sa valeur compte → value object.

Applique-la à quelques cas, et vois comme elle glisse toute seule :

ConceptQuestionVerdict
Une commandeOn suit son cycle de vie, son historique.entité
Un montantSeule sa valeur compte.value object
Un clientOn le suit, il a une continuité.entité
Une adresse (facturation)On la lit, on ne la suit pas.value object
Une couleur d'accentEnsemble fini, sans identité.value object (enum)

Et quand le doute persiste ? La communauté DDD a une réponse claire, résumée par l'heuristique de Vaughn Vernon : « strive to use value objects ». Penche par défaut vers le value object, pour trois raisons très concrètes.

Pourquoi préférer le value object
Immuable, donc pas de bugs d'aliasing ni de copies défensives. Toujours valide, donc pas de vérifications défensives éparpillées. Sans identité ni persistance propre, donc testable en pur — un simple new, aucun mock, aucune base de données.

Le réflexe pratique le plus rentable, c'est de refactorer une primitive dès qu'elle porte des règles. Le jour où tu écris pour la troisième fois « un e-mail doit contenir un @ » ou « on ne mélange pas les devises », c'est le signal : ce string, ce float, veut devenir un value object. La règle a trouvé son foyer naturel.

💡 Le lien avec les agrégats : les value objects sont les briques dont on bâtit les agrégats. À l'intérieur d'un agrégat, les objets-enfants sont très souvent des value objects, et l'identité de l'agrégat lui-même est un value object typé (comme le CourseId qu'on vient de mapper). Ce cours-ci est la fondation ; les agrégats sont l'étage au-dessus. On y croise aussi la référence par identifiant plutôt que par relation d'objets, où l'identifiant est, encore, un value object.
10 — SYNTHÈSE

Synthèse : rendre l'implicite explicite

Tout ce cours tient dans une idée : les concepts de ton métier méritent des types à eux, qui portent leurs règles. Un prix n'est pas un float — c'est un Money.

On est parti d'un total silencieusement faux, faute d'avoir donné à un montant un type qui connaisse sa devise. On termine avec une boîte à outils complète pour ne plus jamais laisser un concept métier se cacher derrière une primitive.

Les deux natures

Entité

Définie par son identité et sa continuité. Mutable. Comparée sur son id. Exemples : Customer, Order.

Value object

Défini par sa valeur. Immuable, toujours valide, sans identité. Comparé par equals(). Exemples : Money, EmailAddress.

Checklist d'un bon value object

Côté Doctrine

CasOutil
VO composite (plusieurs champs)#[Embeddable] + #[Embedded]
VO à une valeur / identité typéetype DBAL custom (convertToPHPValue / convertToDatabaseValue)
VO à ensemble finienum backed + #[Column(enumType: …)]
🔑 Le fil conducteur, en une phrase : rendre l'implicite explicite. Chaque règle métier qui vivait dans un if perdu trouve, dans un value object, un foyer où elle est nommée, validée et testable. C'est là que le code se met à « se lire comme le domaine ».

Pour aller plus loin

💡 Bibliographie : Eric Evans, Domain-Driven Design (2003), chapitres Entities, Value Objects et Supple Design ; Vaughn Vernon, Implementing DDD (2013), chapitres 5-6 ; Ward Cunningham, The CHECKS Pattern Language (Whole Value) ; Martin Fowler (primitive obsession) ; Peter Hilton, Modelling universal values, DDD Europe 2022.