04. Les agrégats
Le concept le plus mal enseigné du DDD, généralement réduit à une question de relations SQL. Prenons-le par le bon bout.
La définition qui compte#
Un agrégat est un groupe d'objets traité comme une unité de cohérence métier. Il a une racine (aggregate root), seul point d'entrée des modifications, qui garantit que l'ensemble reste valide. Ses invariants sont vérifiés dans une même transaction.
SELECT
de tous les champs, on hydrate tout, on réécrit tout ». L'agrégat définit la
frontière métier et le point d'entrée des
modifications ; la stratégie de persistance reste un choix séparé, qui
peut charger partiellement, écrire de façon incrémentale, ou s'appuyer sur un
verrou optimiste. Confondre les deux pousse mécaniquement vers de gros agrégats
très coûteux — exactement ce qu'on cherche à éviter.
La question de conception n'est donc jamais « ces objets sont-ils liés ? » (presque tout est lié à presque tout) mais :
Ces objets doivent-ils être cohérents dans la même transaction ?
L'exemple canonique : Order / OrderLine#
Cet exemple n'existe pas dans ce projet — le domaine « tâches » est trop simple pour le montrer — mais il est indispensable pour comprendre.
# Exemple illustratif, hors de ce dépôt
class Order: # ← RACINE de l'agrégat
def __init__(self, id: OrderId, customer_id: CustomerId) -> None:
self.id = id
self.customer_id = customer_id # autre agrégat → par identité
self._lines: list[OrderLine] = [] # même agrégat → objets réels
self._status = OrderStatus.DRAFT
def add_line(self, product_id: ProductId, quantity: int, price: Money) -> None:
if self._status is not OrderStatus.DRAFT:
raise OrderAlreadySubmitted() # invariant d'agrégat
if self.total() + price * quantity > MAX_ORDER_AMOUNT:
raise OrderAmountExceeded() # invariant d'agrégat
self._lines.append(OrderLine(product_id, quantity, price))Deux observations décisives.
1. OrderLine est bien dans l'agrégat, sous forme
d'objets, pas d'IDs. Une ligne de commande n'a aucun sens sans sa
commande, et le total de la commande doit rester cohérent avec ses lignes à tout
instant. C'est cet invariant qui les réunit dans un seul agrégat — et c'est
parce qu'elles forment un agrégat qu'elles partagent la même transaction, pas
l'inverse.
2. Tout passe par la racine.
order.add_line(...) # ✅ l'invariant est vérifié
order._lines.append(...) # ❌ la porte de service : plus aucune garantieC'est la même idée qu'au chapitre 03, élevée d'un cran : ce que l'entité fait pour son propre état, la racine le fait pour tout son agrégat.
Comment choisir une frontière#
Quatre questions, dans cet ordre :
- Y a-t-il un invariant qui traverse ces objets ? Si modifier A doit immédiatement contraindre B (« le total de la commande ≤ 10 000 € »), ils sont dans le même agrégat. Si les deux peuvent diverger un instant sans que le métier hurle, ils n'y sont pas.
- Ces objets ont-ils le même cycle de vie ? Une
OrderLinenaît et meurt avec sa commande. UneTasksurvit très bien à… rien du tout, elle est indépendante d'unUserau sens où l'un peut être modifié sans l'autre. - Quelle est la taille maximale plausible ? Un invariant ne
se vérifie que sur ce qu'on a sous la main : plus l'agrégat est gros, plus le
coût de le rendre cohérent grimpe. Un
Usercontenant 50 000Taskest ingérable — c'est le signal le plus fiable qu'une frontière est mal placée. - Qui a besoin de verrouiller quoi ? Deux utilisateurs qui modifient chacun une tâche différente du même propriétaire ne devraient pas se bloquer mutuellement. Un gros agrégat, c'est de la contention.
En cas de doute : préfère des agrégats petits. C'est le conseil le plus constant de la littérature DDD, et le plus facile à corriger dans le mauvais sens (fusionner deux petits agrégats est plus simple que découper un gros).
Pourquoi User et Task sont deux agrégats#
Applique la grille au projet :
| Question | User + Task ensemble ? |
|---|---|
| Invariant traversant ? | Non. Aucune règle ne dit « l'état d'un user contraint l'état de ses tâches ». |
| Même cycle de vie ? | Non. Un user existe avant et après ses tâches. |
| Taille bornée ? | Non. Un user peut avoir des milliers de tâches. |
| Contention ? | Oui, si fusionnés : deux tâches du même user se bloqueraient. |
Conclusion : deux agrégats. Les transitions d’une tâche n’exigent pas de charger et modifier l’utilisateur avec toutes ses tâches. Il existe néanmoins une règle référentielle entre les deux : le propriétaire doit exister et sa suppression entraîne une cascade en base. Référencer par identité découple les objets ; cela ne supprime ni cette règle ni les choix de concurrence et de persistance.
C'est aussi ce qui explique pourquoi User ne porte
pas de collection tasks — et pourquoi
Order porte ses lines. Même principe,
deux réponses, parce que les frontières de cohérence diffèrent.
Une transaction, un agrégat — vraiment ?#
Une recommandation fréquente en DDD est de concevoir les cas d'usage pour ne modifier qu'un seul agrégat à la fois. Ce n'est pas une règle officielle gravée quelque part : c'est un conseil de conception destiné à préserver les frontières de cohérence. Il a une raison forte : si tu dois modifier systématiquement deux agrégats atomiquement, ta frontière n'était peut-être pas nécessaire — et surtout, cette habitude ne survit pas au jour où les deux agrégats vivent dans deux services ou deux bases.
En pratique, dans un monolithe sur une base unique, modifier deux agrégats dans une transaction est courant, techniquement possible, et souvent le bon compromis. Ce projet l'autorise d'ailleurs : la frontière transactionnelle entoure le use case, pas l'agrégat (ch. 08).
| Concept | Ce que dit le principe | Ce que fait ce projet |
|---|---|---|
| Portée transactionnelle | 1 transaction = 1 agrégat (idéal, prépare la distribution) | 1 transaction = 1 use case (pragmatique, base unique) |
Sache que tu déroges, et pourquoi : le jour où le contexte user
part dans un autre service, ce sont ces endroits-là qu'il faudra reprendre (avec
des événements, une cohérence à terme, ou un pattern outbox).
TODO, appeler chacune complete() et réussir leur garde locale. La transaction seule n’empêche pas cette course. Une version vérifiée à l’UPDATE, un verrou pessimiste ou une mise à jour conditionnelle est nécessaire si une seule transition doit réussir. Le repository actuel ne met pas en œuvre ce contrôle.L'anti-pattern : l'agrégat gigantesque#
Le symptôme est facile à reconnaître :
class User: # ❌
def __init__(self, ...):
self.tasks: list[Task] = []
self.notifications: list[Notification] = []
self.audit_log: list[AuditEntry] = []Conséquences : chaque lecture d'un user tire des milliers de lignes ; chaque écriture verrouille tout ; les règles des quatre concepts se mélangent dans une seule classe ; et les tests deviennent des monstres de mise en place. On appelle parfois ça le big ball of aggregate.
Le réflexe correctif : demande-toi quel invariant justifierait cette collection. S'il n'y en a pas — et il n'y en a presque jamais — c'est une requête qui est déguisée en attribut (ch. 05).
À retenir#
- Un agrégat est une frontière de cohérence transactionnelle, pas un schéma de relations.
- La question de conception : ces objets doivent-ils être cohérents dans la même transaction ?
- La racine est la seule porte d'entrée ; elle garantit les invariants de tout l'agrégat.
- À l'intérieur d'un agrégat, les collections d'objets sont normales. Entre agrégats, on passe par l'identité.
- En cas de doute : agrégats petits. Le signal d'alarme est la taille non bornée.
- « Un agrégat par transaction » est un idéal qui prépare la distribution ; y déroger dans un monolithe est un choix, pas une faute — à condition de le savoir.
À toi de jouer#
- Un
Articlede blog et sesComment. Même agrégat ou deux agrégats ? a. passe les quatre questions de conception ; b. donne le critère décisif ; c. décris une variante du besoin qui inverserait ta réponse ; d. quel compromis acceptes-tu dans le cas que tu as retenu ? - Une
Orderet sonPayment. Même exercice — et remarque ce qui change si le paiement est traité par un prestataire externe et peut arriver 3 jours plus tard. - Dans ce projet, quel invariant faudrait-il inventer pour qu'il devienne
légitime de faire de
UseretTaskun seul agrégat ? Est-il crédible ?
→ Corrigés