Entités et objets valeur

VoletTactique Les deux briques de base du modèle — et le choix entre les deux est plus déterminant qu’il n’y paraît.

Warning

Rappel de dosage avant d’entrer dans le détail : les patrons tactiques ne sont rentables que dans le cœur de métier. Sur un sous-domaine de support, un objet valeur par champ est une dépense sans retour. Voir Sous-domaines.

Le critère : identité ou valeur

Une seule question sépare les deux notions.

Si deux de ces choses ont exactement les mêmes attributs, sont-elles la même chose ?

Si oui, c’est un objet valeur. Deux montants de 12,50 € sont le même montant ; il n’y a pas lieu de les distinguer.

Si non, c’est une entité. Deux personnes qui portent le même nom, la même date de naissance et la même adresse restent deux personnes.

La réponse dépend du contexte, pas de la nature des choses. Un billet de banque est un objet valeur pour un commerçant — seule sa valeur compte — et une entité pour la banque centrale, qui suit son numéro de série. Le même objet du monde réel, deux modélisations, parce que deux contextes.

Les objets valeur

Ce sont eux qui font l’essentiel du travail dans un bon modèle, et ce sont eux qu’on oublie. Trois propriétés les définissent.

Ils sont définis par leurs attributs, donc comparés par valeur et non par référence.

Ils sont immuables. On ne modifie pas un montant, on en calcule un autre. Cela supprime d’un coup toute une famille de bugs — l’objet partagé modifié par inadvertance — et permet de les échanger librement.

Ils valident à la construction. Un objet valeur invalide n’existe pas. Cela retire la validation de partout ailleurs : si vous détenez un Email, il est bien formé, et il n’y a plus lieu de le vérifier.

​
export class Montant {
  private constructor(
    private readonly centimes: number,
    readonly devise: Devise,
  ) {}

  static depuisCentimes(centimes: number, devise = Devise.EUR): Montant {
    if (!Number.isInteger(centimes)) throw new MontantInvalide('centimes non entiers');
    return new Montant(centimes, devise);
  }

  static zero(devise = Devise.EUR): Montant {
    return new Montant(0, devise);
  }

  plus(autre: Montant): Montant {
    this.memeDevise(autre);
    return new Montant(this.centimes + autre.centimes, this.devise);  // Nouvelle
  }                                                                   // instance

  multiplie(quantite: Quantite): Montant {
    return new Montant(this.centimes * quantite.valeur, this.devise);
  }

  depasse(autre: Montant): boolean {
    this.memeDevise(autre);
    return this.centimes > autre.centimes;
  }

  egale(autre: Montant): boolean {                    // Égalité par valeur
    return this.centimes === autre.centimes && this.devise === autre.devise;
  }

  private memeDevise(autre: Montant): void {
    if (this.devise !== autre.devise) throw new DevisesIncompatibles();
  }
}
from dataclasses import dataclass


@dataclass(frozen=True)            # frozen ⇒ immuable ET égalité par valeur
class Montant:
    centimes: int
    devise: Devise = Devise.EUR

    def __post_init__(self) -> None:
        if not isinstance(self.centimes, int):
            raise MontantInvalide("centimes non entiers")

    @classmethod
    def zero(cls, devise: Devise = Devise.EUR) -> "Montant":
        return cls(0, devise)

    def __add__(self, autre: "Montant") -> "Montant":
        self._meme_devise(autre)
        return Montant(self.centimes + autre.centimes, self.devise)   # Nouvelle
                                                                      # instance
    def multiplie(self, quantite: Quantite) -> "Montant":
        return Montant(self.centimes * quantite.valeur, self.devise)

    def depasse(self, autre: "Montant") -> bool:
        self._meme_devise(autre)
        return self.centimes > autre.centimes

    def _meme_devise(self, autre: "Montant") -> None:
        if self.devise is not autre.devise:
            raise DevisesIncompatibles
#[derive(Debug, Clone, Copy, PartialEq, Eq)]   // Égalité par valeur, dérivée
pub struct Montant {
    centimes: i64,                              // Champ privé : pas de
    devise: Devise,                             // construction hors du module
}

impl Montant {
    pub fn depuis_centimes(centimes: i64, devise: Devise) -> Self {
        Self { centimes, devise }               // Un i64 est déjà entier :
    }                                           // l'invariant est dans le type

    pub fn zero(devise: Devise) -> Self {
        Self { centimes: 0, devise }
    }

    pub fn multiplie(self, quantite: Quantite) -> Self {
        Self { centimes: self.centimes * quantite.valeur(), ..self }
    }

    pub fn depasse(self, autre: Self) -> bool {
        assert_eq!(self.devise, autre.devise, "devises incompatibles");
        self.centimes > autre.centimes
    }
}

impl std::ops::Add for Montant {
    type Output = Montant;

    fn add(self, autre: Montant) -> Montant {
        assert_eq!(self.devise, autre.devise, "devises incompatibles");
        Montant { centimes: self.centimes + autre.centimes, ..self }
    }
}
Note

Les trois langages n’ont pas le même effort à fournir. Python obtient l’immuabilité et l’égalité par valeur d’un seul frozen=True. Rust les obtient par défaut — Copy et PartialEq dérivés, champs privés, aucun constructeur implicite — au point que l’objet valeur y est le style naturel plutôt qu’une discipline. TypeScript demande le plus de travail : constructeur privé, fabrique statique, et une méthode egale écrite à la main puisque === compare les références.

L’obsession des primitifs

C’est la première cause de modèles pauvres, et l’objet valeur en est le remède.

Un domaine décrit avec des string, des number et des boolean ne porte aucune règle. Comparez deux signatures :

reserver(client: string, spectacle: string, places: number, prix: number): void

reserver(client: ClientId, seance: SeanceId, places: Quantite, prix: Montant): void

La première accepte reserver(spectacleId, clientId, prix, places) — les paramètres inversés, deux fois — et compile parfaitement. La seconde ne compile pas. Aucun test n’a été écrit, et une classe entière de bugs vient de disparaître.

Le gain va plus loin que la sécurité de type. Les règles trouvent enfin un endroit : où mettre « un montant ne s’additionne qu’à un montant de même devise », si le montant est un number ? Nulle part — donc dans un service, dispersée, répétée, oubliée quelque part. Sur un objet Montant, il n’y a qu’une réponse possible.

Les candidats habituels, dans l’ordre où ils se rencontrent : identifiants, montants et devises, quantités et unités, périodes et durées, adresses, courriels et téléphones, codes et références métier, pourcentages et taux, plages de valeurs.

Tip

Un signe qui ne trompe pas : une méthode qui prend trois string à la suite. Non seulement l’appelant peut les intervertir, mais le fait qu’il puisse signifie que le modèle ne sait pas les distinguer.

Les entités

Une entité a une identité qui persiste à travers ses changements d’état. La commande n° 4711 reste la même commande après ajout d’une ligne, changement d’adresse ou passage en expédition.

Trois conséquences pratiques :

L’égalité se fait sur l’identifiant, jamais sur les attributs. Deux instances chargées séparément avec le même identifiant sont la même entité.

L’identifiant est généré par le domaine, pas par la base de données. C’est un détail qui a de grandes conséquences : un identifiant attribué à l’insertion oblige à sauvegarder pour connaître l’identité, ce qui interdit de construire un graphe d’objets cohérent en mémoire, casse les tests sans base, et complique la publication d’événements. Un UUID — ou tout autre identifiant généré côté domaine — supprime le problème.

L’état mute, mais par des méthodes qui portent un nom métier. Le point le plus important, et la frontière exacte entre un modèle riche et un modèle anémique : pas de setEtat(), mais confirmer(), suspendre(motif), annuler(raison).

​
export class Reservation {
  private etat: EtatReservation = EtatReservation.Provisoire;
  private places: Quantite;

  private constructor(
    readonly id: ReservationId,          // Identité : immuable, publique
    readonly seance: SeanceId,
    places: Quantite,
  ) {
    this.places = places;
  }

  static ouvrir(seance: SeanceId, places: Quantite): Reservation {
    return new Reservation(ReservationId.nouveau(), seance, places);  // Identité
  }                                                                  // côté domaine

  confirmer(paiement: ReferencePaiement): void {
    if (this.etat !== EtatReservation.Provisoire) {
      throw new TransitionInterdite(this.etat, 'confirmer');
    }
    this.etat = EtatReservation.Confirmee;
  }

  egale(autre: Reservation): boolean {
    return this.id.egale(autre.id);      // Égalité par identité
  }
}
class Reservation:
    def __init__(
        self,
        id: ReservationId,               # Identité : immuable
        seance: SeanceId,
        places: Quantite,
    ) -> None:
        self._id = id
        self._seance = seance
        self._places = places
        self._etat = EtatReservation.PROVISOIRE

    @classmethod
    def ouvrir(cls, seance: SeanceId, places: Quantite) -> "Reservation":
        return cls(ReservationId.nouveau(), seance, places)   # Identité côté
                                                              # domaine
    @property
    def id(self) -> ReservationId:
        return self._id

    def confirmer(self, paiement: ReferencePaiement) -> None:
        if self._etat is not EtatReservation.PROVISOIRE:
            raise TransitionInterdite(self._etat, "confirmer")
        self._etat = EtatReservation.CONFIRMEE

    def __eq__(self, autre: object) -> bool:                  # Égalité par
        if not isinstance(autre, Reservation):                # identité
            return NotImplemented
        return self._id == autre._id

    def __hash__(self) -> int:
        return hash(self._id)
pub struct Reservation {
    id: ReservationId,                   // Identité : privée, jamais modifiée
    seance: SeanceId,
    places: Quantite,
    etat: EtatReservation,
}

impl Reservation {
    pub fn ouvrir(seance: SeanceId, places: Quantite) -> Self {
        Self {
            id: ReservationId::nouveau(),          // Identité côté domaine
            seance,
            places,
            etat: EtatReservation::Provisoire,
        }
    }

    pub fn id(&self) -> ReservationId { self.id }

    pub fn confirmer(&mut self, _paiement: ReferencePaiement)
        -> Result<(), TransitionInterdite>
    {
        if self.etat != EtatReservation::Provisoire {
            return Err(TransitionInterdite { depuis: self.etat, action: "confirmer" });
        }
        self.etat = EtatReservation::Confirmee;
        Ok(())
    }
}

impl PartialEq for Reservation {
    fn eq(&self, autre: &Self) -> bool { self.id == autre.id }   // Par identité
}
impl Eq for Reservation {}

Ce qui n’est pas une entité

Deux confusions valent d’être nommées, parce qu’elles sont systématiques.

Une entité n’est pas une table. Le modèle relationnel et le modèle de domaine répondent à des contraintes différentes : normalisation d’un côté, expression des règles de l’autre. Une entité peut correspondre à trois tables, et trois entités à une seule. Laisser le schéma dicter le modèle est le plus court chemin vers un domaine qui parle SQL.

Une entité n’est pas une classe annotée par l’ORM. Le mot « entité » de Hibernate, JPA, TypeORM ou SQLAlchemy désigne un objet mappé à une table, ce qui n’a de commun avec l’entité de DDD que le nom. Faire porter les deux rôles au même objet est possible et fréquent, mais ce n’est pas neutre : les contraintes de l’ORM — constructeur sans argument, champs mutables, accesseurs publics, chargement paresseux — sont exactement l’inverse de ce qu’un modèle riche exige.

Tip

Le compromis pragmatique consiste à garder deux représentations et à traduire dans le dépôt : le modèle de domaine d’un côté, le modèle de persistance de l’autre. C’est du code de correspondance supplémentaire — la critique la plus fondée contre cette approche, reconnue comme telle en architecture hexagonale — et c’est ce qui permet aux deux d’évoluer indépendamment.

Où mettre le comportement

La règle est simple à énoncer et exigeante à tenir : le comportement va là où sont les données dont il a besoin.

Une règle qui ne dépend que d’un montant va sur Montant. Une règle qui ne dépend que de l’état d’une réservation va sur Reservation. Une règle qui a besoin de deux entités distinctes ne va sur ni l’une ni l’autre : c’est le cas du service de domaine.

Ce qui reste est le point d’entrée de la notion suivante. Une réservation ne se valide pas seule : il faut vérifier que les places sont disponibles dans la séance, ce qui pose la question de savoir jusqu’où va la cohérence — et donc celle des agrégats.

Références