Agrégats

VoletTactique La notion la plus difficile de DDD, et celle qui rapporte le plus. C’est aussi celle qui est le plus souvent mal comprise — presque toujours de la même façon.

La question à laquelle un agrégat répond

Ce n’est pas « comment regrouper mes classes ». C’est :

Quel ensemble de données doit être cohérent à chaque instant, et donc modifié dans une seule transaction ?

Un agrégat est un groupe d’entités et d’objets valeur traité comme une unité de cohérence. Il possède une racine — une entité — qui est le seul point d’entrée : rien à l’intérieur n’est accessible directement de l’extérieur.

Reformulé de la manière la plus opératoire possible : un agrégat est une frontière de transaction. Tout ce qui doit être vrai en permanence est dedans ; tout ce qui peut être vrai « dans une seconde » est dehors.

Warning

L’erreur canonique consiste à construire les agrégats à partir des noms du domaine et des relations entre eux — « une commande a des lignes, un client a des commandes, donc… ». On obtient un agrégat qui grossit jusqu’à englober le modèle entier, puisque tout est relié à tout. Les agrégats se construisent à partir des invariants, jamais des relations.

L’invariant, seule justification

Un invariant est une règle qui doit être vraie à chaque instant, sans exception et sans délai. Deux exemples pour sentir la différence :

  • « Le total d’une commande est la somme de ses lignes. » Invariant : il ne peut pas être faux une milliseconde. Les lignes sont donc dans l’agrégat commande.
  • « Le chiffre d’affaires mensuel du client est à jour. » Pas un invariant : que ce total soit recalculé deux secondes plus tard ne dérange personne. Le client est donc hors de l’agrégat commande.

La seule raison légitime de placer deux choses dans le même agrégat est un invariant qui les lie. S’il n’y en a pas, ce sont deux agrégats.

Tip

La question qui tranche, et qu’il faut poser à l’expert du domaine et non à l’équipe technique : « si ces deux informations se contredisent pendant deux secondes, que se passe-t-il ? » Si la réponse est « rien, ça se rattrape », la cohérence peut être différée. Si c’est « on vend une place déjà vendue », c’est un invariant. La frontière d’un agrégat est une décision métier, pas un arbitrage technique.

Les quatre règles

Formulées par Vaughn Vernon dans Effective Aggregate Design, ce sont les règles les plus utiles de tout le DDD tactique.

1. Protéger les vrais invariants dans une frontière de cohérence

Ce qui est à l’intérieur est cohérent à chaque commit. Ce qui est à l’extérieur peut attendre. C’est la règle dont les trois autres découlent.

2. Concevoir de petits agrégats

Un gros agrégat coûte cher sur trois plans : il faut le charger entièrement pour modifier un détail, il verrouille beaucoup de données donc génère de la contention, et il finit par contenir des règles qui n’ont rien à voir entre elles.

Le contre-exemple classique est l’agrégat Client contenant ses commandes. Un client fidèle a dix mille commandes ; ajouter une ligne à la dernière charge les dix mille. Et deux commandes simultanées du même client entrent en conflit sans aucune raison métier.

La règle pratique : viser un agrégat composé d’une racine et de quelques objets valeur. Une collection d’entités filles dans un agrégat doit être bornée par le métier — les lignes d’une commande, oui ; les commandes d’un client, non.

3. Référencer les autres agrégats par identité

Pas de référence d’objet vers une autre racine : un identifiant.

C’est ce qui garde les agrégats petits, empêche le chargement en cascade, rend la frontière de transaction explicite, et permet de déplacer un agrégat vers un autre contexte sans démonter le modèle.

4. Modifier les autres agrégats en cohérence différée

Corollaire direct : une transaction ne modifie qu’un agrégat. Si une action doit en affecter deux, le second est mis à jour après coup, via un événement de domaine.

C’est la règle qui surprend le plus, et c’est la plus importante en pratique. Elle n’est pas un compromis technique consenti à la performance : elle reflète le fait que la plupart des règles métier tolèrent un délai, et que celles qui ne le tolèrent pas définissent justement une frontière d’agrégat.

Un exemple travaillé

Une réservation de places de spectacle. Le métier énonce quatre règles :

  1. Une réservation ne peut pas contenir plus de dix places.
  2. Le montant total d’une réservation est la somme de ses lignes.
  3. Une séance ne peut pas être réservée au-delà de sa capacité.
  4. Le nombre de points de fidélité du client reflète ses réservations confirmées.

Classons-les. Les règles 1 et 2 portent sur une réservation seule : invariants internes, donc les lignes sont dans l’agrégat Reservation. La règle 3 porte sur une séance et doit être vraie à chaque instant — sans quoi on vend deux fois la même place : c’est un invariant, mais d’un autre agrégat, Seance. La règle 4 tolère un délai : cohérence différée.

Trois agrégats, donc — Reservation, Seance, Client — et non un seul objet relié par des références.

​
// ---------- Entité fille : inaccessible depuis l'extérieur ----------
class LigneReservation {
  constructor(
    readonly categorie: CategoriePlace,
    readonly quantite: Quantite,
    readonly prixUnitaire: Montant,
  ) {}

  total(): Montant { return this.prixUnitaire.multiplie(this.quantite); }
}

// ---------- Racine d'agrégat ----------
export class Reservation {
  private readonly lignes: LigneReservation[] = [];
  private etat = EtatReservation.Provisoire;
  private readonly evenements: EvenementDomaine[] = [];

  private constructor(
    readonly id: ReservationId,
    readonly seanceId: SeanceId,          // Autre agrégat : par identité (règle 3)
    readonly clientId: ClientId,          // Autre agrégat : par identité (règle 3)
  ) {}

  static ouvrir(seance: SeanceId, client: ClientId): Reservation {
    return new Reservation(ReservationId.nouveau(), seance, client);
  }

  // Le seul moyen d'ajouter une ligne : par la racine, qui vérifie l'invariant
  ajouterPlaces(categorie: CategoriePlace, quantite: Quantite, prix: Montant): void {
    if (this.etat !== EtatReservation.Provisoire) {
      throw new TransitionInterdite(this.etat, 'ajouterPlaces');
    }
    if (this.nombreDePlaces().plus(quantite).depasse(Quantite.de(10))) {
      throw new TropDePlaces();                                     // Invariant 1
    }
    this.lignes.push(new LigneReservation(categorie, quantite, prix));
  }

  total(): Montant {                                                // Invariant 2
    return this.lignes.reduce((t, l) => t.plus(l.total()), Montant.zero());
  }

  confirmer(): void {
    if (this.lignes.length === 0) throw new ReservationVide();
    this.etat = EtatReservation.Confirmee;
    this.evenements.push(                        // Cohérence différée (règle 4) :
      new ReservationConfirmee(                  // le client sera crédité plus tard
        this.id, this.clientId, this.total(),
      ),
    );
  }

  private nombreDePlaces(): Quantite {
    return this.lignes.reduce((n, l) => n.plus(l.quantite), Quantite.zero());
  }
}
from dataclasses import dataclass, field


# ---------- Entité fille : inaccessible depuis l'extérieur ----------
@dataclass(frozen=True)
class _LigneReservation:
    categorie: CategoriePlace
    quantite: Quantite
    prix_unitaire: Montant

    def total(self) -> Montant:
        return self.prix_unitaire.multiplie(self.quantite)


# ---------- Racine d'agrégat ----------
class Reservation:
    def __init__(
        self,
        id: ReservationId,
        seance_id: SeanceId,        # Autre agrégat : par identité (règle 3)
        client_id: ClientId,        # Autre agrégat : par identité (règle 3)
    ) -> None:
        self.id = id
        self.seance_id = seance_id
        self.client_id = client_id
        self._lignes: list[_LigneReservation] = []
        self._etat = EtatReservation.PROVISOIRE
        self._evenements: list[EvenementDomaine] = []

    @classmethod
    def ouvrir(cls, seance: SeanceId, client: ClientId) -> "Reservation":
        return cls(ReservationId.nouveau(), seance, client)

    # Le seul moyen d'ajouter une ligne : par la racine, qui vérifie l'invariant
    def ajouter_places(
        self, categorie: CategoriePlace, quantite: Quantite, prix: Montant
    ) -> None:
        if self._etat is not EtatReservation.PROVISOIRE:
            raise TransitionInterdite(self._etat, "ajouter_places")
        if (self._nombre_de_places() + quantite).depasse(Quantite(10)):
            raise TropDePlaces                                     # Invariant 1
        self._lignes.append(_LigneReservation(categorie, quantite, prix))

    def total(self) -> Montant:                                    # Invariant 2
        return sum((l.total() for l in self._lignes), Montant.zero())

    def confirmer(self) -> None:
        if not self._lignes:
            raise ReservationVide
        self._etat = EtatReservation.CONFIRMEE
        self._evenements.append(          # Cohérence différée (règle 4) : le
            ReservationConfirmee(         # client sera crédité plus tard
                self.id, self.client_id, self.total()
            )
        )

    def _nombre_de_places(self) -> Quantite:
        return sum((l.quantite for l in self._lignes), Quantite.zero())
// ---------- Entité fille : privée au module, donc inaccessible ----------
struct LigneReservation {
    categorie: CategoriePlace,
    quantite: Quantite,
    prix_unitaire: Montant,
}

impl LigneReservation {
    fn total(&self) -> Montant { self.prix_unitaire.multiplie(self.quantite) }
}

// ---------- Racine d'agrégat ----------
pub struct Reservation {
    id: ReservationId,
    seance_id: SeanceId,          // Autre agrégat : par identité (règle 3)
    client_id: ClientId,          // Autre agrégat : par identité (règle 3)
    lignes: Vec<LigneReservation>,
    etat: EtatReservation,
    evenements: Vec<EvenementDomaine>,
}

impl Reservation {
    pub fn ouvrir(seance_id: SeanceId, client_id: ClientId) -> Self {
        Self {
            id: ReservationId::nouveau(),
            seance_id,
            client_id,
            lignes: Vec::new(),
            etat: EtatReservation::Provisoire,
            evenements: Vec::new(),
        }
    }

    // Le seul moyen d'ajouter une ligne : par la racine, qui vérifie l'invariant
    pub fn ajouter_places(
        &mut self,
        categorie: CategoriePlace,
        quantite: Quantite,
        prix_unitaire: Montant,
    ) -> Result<(), ErreurReservation> {
        if self.etat != EtatReservation::Provisoire {
            return Err(ErreurReservation::TransitionInterdite);
        }
        if (self.nombre_de_places() + quantite).depasse(Quantite::de(10)) {
            return Err(ErreurReservation::TropDePlaces);            // Invariant 1
        }
        self.lignes.push(LigneReservation { categorie, quantite, prix_unitaire });
        Ok(())
    }

    pub fn total(&self) -> Montant {                               // Invariant 2
        self.lignes.iter().map(LigneReservation::total).sum()
    }

    pub fn confirmer(&mut self) -> Result<(), ErreurReservation> {
        if self.lignes.is_empty() {
            return Err(ErreurReservation::ReservationVide);
        }
        self.etat = EtatReservation::Confirmee;
        self.evenements.push(EvenementDomaine::ReservationConfirmee {
            reservation: self.id,        // Cohérence différée (règle 4) : le
            client: self.client_id,      // client sera crédité plus tard
            montant: self.total(),
        });
        Ok(())
    }

    fn nombre_de_places(&self) -> Quantite {
        self.lignes.iter().map(|l| l.quantite).sum()
    }
}

Quatre points méritent d’être relevés dans ce code.

LigneReservation n’est jamais exposée. Il n’existe aucun moyen d’obtenir une ligne et de la modifier : toute évolution passe par ajouterPlaces, qui vérifie l’invariant. Une collection retournée telle quelle — un getLignes() public — suffirait à annuler la protection.

seanceId et clientId sont des identifiants, pas des objets. La réservation ne peut pas modifier la séance, ce qui est exactement l’effet recherché.

L’invariant est vérifié avant la mutation, dans la même méthode. Il n’y a pas de moment où l’agrégat est incohérent.

La règle sur les points de fidélité n’est pas appliquée ici : un événement est enregistré, et c’est un autre morceau du système qui s’en chargera.

Note

La version Rust obtient gratuitement une garantie que les deux autres demandent en discipline : LigneReservation étant privée au module, il est impossible de la manipuler depuis l’extérieur — le compilateur refuse. En Python, le _ initial est une convention ; en TypeScript, private disparaît à l’exécution. L’encapsulation d’agrégat est le point où le système de visibilité de Rust s’aligne le mieux avec DDD.

L’invariant qui porte sur une autre racine

Reste la règle 3 : la capacité de la séance. Elle doit être immédiate, mais elle concerne Seance. Comment faire, puisqu’une transaction ne modifie qu’un agrégat ?

La réponse est de choisir où vit l’invariant, et il n’y a que trois options honnêtes.

OptionMécanismeQuand
Déplacer la décision dans Seanceseance.reserver(places) produit une allocation ; la réservation la référenceLa capacité est un vrai invariant strict — le cas ici
Fusionner les deux agrégatsUn seul agrégat Seance contenant ses réservationsPetite jauge, forte contention acceptable
Différer et compenserOn accepte le dépassement, on détecte, on rembourseSurréservation assumée (aérien, hôtellerie)

Le choix ne se déduit pas du code : il se demande au métier. Et la troisième option, qui paraît la plus laide, est celle que les compagnies aériennes appliquent depuis cinquante ans — parce que le coût du verrou global dépasse celui de la compensation.

Warning

Si un invariant strict traverse deux agrégats de deux contextes différents, c’est un signal fort que la frontière de contexte est mal placée. La conséquence serait une transaction distribuée, et aucune sophistication technique ne rend cela souhaitable.

Concurrence et verrou optimiste

Puisque l’agrégat est l’unité de cohérence, c’est sur lui que porte le contrôle de concurrence — pas sur les lignes ni sur les tables.

Le mécanisme standard est le verrou optimiste : la racine porte un numéro de version, incrémenté à chaque enregistrement. Deux modifications concurrentes de la même réservation aboutissent au rejet de la seconde, qui doit relire et réessayer.

C’est aussi une raison supplémentaire de faire de petits agrégats : plus l’agrégat est gros, plus il attire de modifications concurrentes, et plus les conflits sont fréquents sur des données qui n’avaient rien à voir entre elles.

Comment les trouver

Une méthode qui fonctionne, dans cet ordre :

  1. Lister les règles du métier, avec leurs mots, sans se soucier des objets.
  2. Trier ces règles en deux tas : celles qui doivent être vraies à chaque instant, et celles qui tolèrent un délai. Le second tas est en général le plus gros — c’est une bonne nouvelle.
  3. Grouper les données mentionnées par chaque règle immédiate. Chaque groupe est un agrégat candidat.
  4. Choisir la racine : l’entité par laquelle le métier désigne naturellement le groupe.
  5. Vérifier la taille. Si un agrégat contient une collection non bornée, le découper.
  6. Relier le reste par identité, et traiter les règles différées par des événements.

Un event storming produit directement la matière des deux premières étapes, ce qui explique que les deux pratiques aillent presque toujours ensemble.

Ce qu’un agrégat n’est pas

  • Un graphe d’objets ORM. Si votre agrégat est défini par les relations @OneToMany de l’ORM, ce sont les tables qui décident du modèle. Le chargement paresseux à l’intérieur d’un agrégat est un symptôme : un agrégat correctement dimensionné se charge en entier.
  • Un module. Un agrégat est petit — une racine et quelques objets valeur. Un contexte délimité contient plusieurs agrégats.
  • Une unité de lecture. Un agrégat est conçu pour écrire en respectant des invariants. Les besoins d’affichage — une liste, un tableau de bord, une recherche multicritère — n’ont pas à passer par lui ; c’est le point de départ de la séparation lecture/écriture, dont CQRS est la forme aboutie.

Références