Conception d'Aggregates : Pièges Courants et Solutions
L'Aggregate est l'un des building blocks les plus critiques du DDD tactique, pourtant c'est aussi l'un des plus mal compris. Voici les erreurs les plus fréquentes et leurs solutions.
Qu'est-ce qu'un Aggregate ?
Un aggregate est une grappe d'objets du domaine traités comme une unité pour les changements de données. Il a une racine (Aggregate Root) qui est le seul point d'entrée pour modifier l'état de l'aggregate.
Les règles fondamentales
- Cohérence transactionnelle : Un aggregate garantit la cohérence de ses invariants dans une seule transaction
- Frontière de cohérence : Les invariants métier ne peuvent être maintenus qu'à l'intérieur d'un aggregate
- Référencement par ID : Les aggregates se référencent entre eux uniquement par ID, jamais par référence d'objet
Erreur #1 : Aggregates trop gros
Le problème
Inclure d'autres aggregates (Customer, Inventory) ou multiplier les collections (Payments, Shipments) crée un aggregate monolithique. Chaque modification requiert de charger toute la structure, augmentant les risques de conflits concurrentiels.
Anti-pattern : Un Order qui contient directement Customer, Inventory, Payments, Shipments. Cinq responsabilités métier différentes, cinq raisons de conflits concurrentiels.
Pourquoi c'est problématique
- Contention élevée : Plus l'aggregate est gros, plus il y a de chances de conflits concurrentiels
- Performance dégradée : Charger tout l'aggregate pour une petite modification
- Couplage fort : Mélange des responsabilités métier différentes
La solution
Concentrer l'aggregate sur son périmètre de cohérence strict : les lignes de commande et leur état. Référencer les autres aggregates par ID uniquement (CustomerId, pas Customer). Déléguer la coordination entre aggregates à un service applicatif qui orchestre les transactions.
Erreur #2 : Aggregates anémiques
Le problème
Exposer des setters publics transforme l'aggregate en simple conteneur de données. La logique métier se retrouve dispersée dans les services applicatifs, rendant impossible le maintien des invariants.
Anti-pattern : public OrderStatus Status { get; set; } permet de passer directement de Draft à Completed sans vérifier qu'il y a des lignes de commande ou que le paiement est effectué.
La solution
Encapsuler la logique métier dans l'aggregate avec des méthodes qui expriment les intentions métier (Complete(), Cancel(), AddOrderLine()). Les invariants sont vérifiés à chaque opération, garantissant qu'aucun état incohérent n'est possible. Exposer uniquement des getters et des collections en lecture seule.
Erreur #3 : Violations de cohérence
Le problème
Des setters publics et des collections mutables permettent de contourner la logique métier. On peut modifier le solde d'un compte sans créer de transaction, brisant l'invariant fondamental "Balance = somme des transactions".
Anti-pattern : account.Balance = 1000 ou account.Transactions.Clear() violent directement la cohérence de l'aggregate.
La solution
Forcer le passage par des méthodes métier (Debit(), Credit()) qui maintiennent systématiquement la cohérence entre le solde et l'historique des transactions. Chaque opération garantit que l'invariant reste vrai. Aucun setter public, collections privées exposées uniquement en lecture seule.
Erreur #4 : Mauvaise gestion du cycle de vie
Le problème
Un constructeur public par défaut permet de créer des aggregates dans un état invalide. Sans données obligatoires comme CustomerId, l'aggregate peut être persisté en violation des règles métier.
Anti-pattern : var order = new Order(); repository.Save(order); crée une commande orpheline sans client.
La solution
Utiliser des factory methods statiques (Order.CreateNew(customerId)) qui garantissent la cohérence dès la création. Le constructeur privé empêche toute instanciation qui contournerait les validations métier initiales. L'aggregate est toujours créé dans un état valide.
Patterns utiles
1. Aggregate Factory
Quand la création d'un aggregate nécessite des validations externes (vérifier l'existence d'un client, enrichir avec des données du catalogue), une factory encapsule cette orchestration complexe tout en déléguant la logique métier à l'aggregate lui-même.
2. Repository avec Unit of Work
Le repository gère la persistance de l'aggregate et la publication de ses domain events. L'Unit of Work garantit la cohérence transactionnelle : soit l'aggregate ET ses events sont sauvegardés, soit rien ne l'est.
3. Immutabilité dans les Aggregates
Règle d'or TypeScript : readonly > Object.freeze
En TypeScript, Object.freeze est un anti-pattern. Le système de types fort offre déjà les garanties d'immutabilité via readonly, sans overhead runtime ni complexité de debugging. Préférez les types readonly pour les Value Objects et les collections en lecture seule pour les aggregates.
Conclusion
Un bon design d'aggregate respecte ces principes :
- Taille appropriée : Ni trop gros (contention), ni trop petit (performance)
- Encapsulation forte : Pas de setters publics, collections protégées
- Invariants maintenus : Impossible de mettre l'aggregate en état incohérent
- Cycle de vie contrôlé : Factory methods pour la création, pas de constructeur par défaut
- Immutabilité pragmatique : readonly en TypeScript, private setters en C#
L'aggregate est le gardien de la cohérence métier. Traitez-le avec le respect qu'il mérite !