Agrégats, Fabriques & Référentiels
Maîtriser le cycle de vie des objets du domaine : frontières de cohérence, création encapsulée et reconstitution.
Tout objet a un cycle de vie. Il naît, traverse différents états, puis finit archivé ou supprimé. Pour les petits objets transitoires — créés par un simple appel au constructeur, utilisés dans un calcul, puis abandonnés au ramasse-miettes — rien de tout cela ne pose problème. Mais nous passons l'essentiel de notre temps sur des objets plus complexes, dont la vie est longue et ne se déroule pas entièrement en mémoire. Gérer ces objets persistants soulève deux difficultés que pointe Eric Evans : maintenir leur intégrité tout au long du cycle de vie, et empêcher le modèle d'être submergé par la complexité de cette gestion.
Trois patrons répondent à ces difficultés. L'agrégat (Aggregate) resserre le modèle lui-même en définissant des frontières et une propriété claires, ce qui est crucial pour l'intégrité. La fabrique (Factory) prend en charge le début du cycle de vie, en créant et reconstituant des objets complexes sans révéler leur structure interne. Enfin, le référentiel (Repository) couvre le milieu et la fin du cycle, en offrant un moyen de retrouver et de persister les objets tout en encapsulant l'immense machinerie d'infrastructure que cela suppose. Ces trois patrons opèrent ensemble — et c'est l'agrégat qui les commande.
L'agrégat : une frontière de cohérence
La plupart des domaines métier sont si interconnectés qu'on finit par parcourir de longs chemins de références entre objets. Cela reflète la réalité du monde, qui nous offre rarement des frontières nettes. Mais en logiciel, c'est un problème : dans tout système à stockage persistant, il faut pouvoir délimiter la portée d'une transaction qui modifie des données, et garantir la cohérence de ces données.
Prenons l'exemple d'Evans : vous supprimez un objet « personne » de la base. Avec lui partent son nom, sa date de naissance, son intitulé de poste. Mais l'adresse ? D'autres personnes pourraient l'habiter. Si vous la supprimez quand même, des objets « personne » référenceront un objet détruit. Le problème n'est pas technique — il est de modélisation : où commence et où finit un objet composé d'autres objets ?
Un agrégat (Aggregate) est une grappe d'objets associés que l'on traite comme une seule unité pour les changements de données. Chaque agrégat possède une racine (aggregate root) et une frontière (boundary). La frontière définit ce qui est à l'intérieur. La racine est une entité unique, le seul membre de l'agrégat que les objets extérieurs ont le droit de référencer.
À retenir
La règle d'or, souvent mal comprise : seule la racine est accessible de l'extérieur. Les objets internes ont une identité purement locale — unique seulement à l'intérieur de l'agrégat — et nul ne peut y accéder sans passer par la racine. C'est cette discipline qui rend les invariants tenables.
L'illustration du bon de commande
Evans déroule l'exemple d'un bon de commande (purchase order) découpé en lignes, avec un invariant simple : la somme des lignes ne doit pas dépasser le plafond approuvé. Trois utilisateurs travaillent en même temps. George édite la ligne 1, Amanda la ligne 2. Chacun verrouille sa propre ligne, voit un total conforme de son côté… mais une fois les deux modifications enregistrées, le total stocké viole le plafond. Une règle métier essentielle a été brisée, et personne ne le sait.
La leçon : verrouiller une ligne isolée n'est pas une protection suffisante. C'est le bon de commande entier qui forme l'unité de cohérence. L'invariant relie plusieurs objets ; il faut donc une frontière qui les englobe et un point de contrôle unique — la racine — par lequel toute modification transite.
// La racine de l'agrégat « Commande ».
class Commande {
private readonly lignes: LigneCommande[] = [];
private statut: StatutCommande = "BROUILLON";
constructor(
public readonly id: CommandeId,
public readonly clientId: ClientId, // autre agrégat : par identité
private readonly plafondApprouve: Montant,
) {}
// Seul point d'entrée pour modifier l'intérieur.
ajouterLigne(article: ReferenceArticle, quantite: number): void {
if (this.statut !== "BROUILLON") {
throw new Error("Commande non modifiable");
}
const ligne = new LigneCommande(article, quantite);
const nouveauTotal = this.total().plus(ligne.sousTotal());
// L'invariant est vérifié AVANT de muter l'état.
if (nouveauTotal.depasse(this.plafondApprouve)) {
throw new PlafondDepasse(nouveauTotal, this.plafondApprouve);
}
this.lignes.push(ligne);
}
total(): Montant {
return this.lignes.reduce(
(somme, l) => somme.plus(l.sousTotal()),
Montant.zero(),
);
}
} La LigneCommande est une entité interne : elle a une identité locale (son numéro de ligne), mais personne, à l'extérieur, ne la cherchera directement pour remonter ensuite à sa commande. On interroge la base pour trouver une commande, puis on lui demande ses lignes.
// Entité INTERNE : jamais référencée de l'extérieur.
class LigneCommande {
constructor(
public readonly article: ReferenceArticle,
private readonly quantite: number,
) {
if (quantite <= 0) throw new Error("Quantité invalide");
}
sousTotal(): Montant {
return this.article.prixUnitaire.multiplie(this.quantite);
}
} Note
Remarquez que la ligne fige le prix unitaire au moment de sa création. C'est exactement l'arbitrage d'Evans : le prix d'un article (un autre agrégat, à forte contention) peut changer sans affecter rétroactivement les commandes déjà passées. On desserre le lien faible, on resserre le lien fort.
Les règles de l'agrégat
Pour traduire le concept en implémentation, Evans énonce un jeu de règles à appliquer à toute transaction. Vaughn Vernon les a depuis condensées en quatre règles empiriques devenues canoniques.
| Règle | Énoncé | Pourquoi |
|---|---|---|
| Invariants dans la frontière | Toute règle de cohérence reliant des objets internes est garantie par la racine, à chaque transaction. | C'est la raison d'être de l'agrégat. |
| Petits agrégats | Préférez de petits agrégats (souvent une seule entité au départ). | Moins de mémoire, chargement rapide, bien moins de conflits transactionnels. |
| Référence par identité | Référencez les autres agrégats par leur identifiant, jamais par référence directe. | Empêche de modifier plusieurs agrégats dans une même transaction. |
| Une transaction = un agrégat | Une transaction ne modifie et ne valide qu'un seul agrégat. | Frontière de cohérence transactionnelle. |
| Cohérence éventuelle | Les règles qui traversent plusieurs agrégats sont résolues plus tard (événements, traitement par lot). | « Toute règle couvrant plusieurs agrégats n'a pas à être toujours à jour. » |
Référencer les autres agrégats par identité
C'est le point le plus mal compris du patron, et pourtant l'un des plus libérateurs. Lorsqu'une commande doit « connaître » son client, la tentation est de stocker un objet Client complet. Résistez.
// ❌ Avant : référence directe vers un autre agrégat.
class Commande {
constructor(
public readonly id: CommandeId,
public readonly client: Client, // tout l'agrégat Client chargé !
) {}
appliquerRemiseFidelite(): void {
// Tentation : muter le client depuis la commande.
this.client.incrementerPointsFidelite(10);
}
} Cette conception charge l'intégralité de l'agrégat Client dès qu'on touche une commande, brouille les frontières, et — pire — invite à modifier deux agrégats dans la même transaction.
// ✅ Après : on ne garde QUE l'identifiant.
class Commande {
constructor(
public readonly id: CommandeId,
public readonly clientId: ClientId, // une simple identité
) {}
// La commande ne sait plus muter le client.
// Pour cela, on charge le client dans SA propre transaction.
} Référencer par identité présente trois bénéfices concrets, qu'Evans n'avait pas pleinement systématisés et que Vernon a mis en avant :
- Frontière respectée : sans référence directe, aucun moyen facile de modifier l'autre agrégat « au passage ».
- Agrégats légers : on ne charge en mémoire que ce dont la transaction a besoin.
- Liberté de persistance : un agrégat qui ne référence ses voisins que par identité se stocke aussi bien dans une base relationnelle que dans un store documentaire ou clé-valeur.
Attention
Si vous vous surprenez à charger l'agrégat B uniquement pour le modifier depuis une opération sur l'agrégat A, c'est presque toujours le signe que votre frontière est mal placée. Soit B fait en réalité partie de A, soit la mise à jour de B doit se faire séparément, par cohérence éventuelle.
Cohérence éventuelle entre agrégats
Comment, alors, propager un changement d'un agrégat à un autre ? Reprenons un cas de logistique. Quand une commande est expédiée, le stock de chaque article doit être décrémenté. Stock et commande sont deux agrégats distincts ; on ne les modifie pas dans la même transaction.
// 1. Transaction sur la Commande : elle change de statut
// et signale ce qui s'est passé.
class Commande {
private statut: StatutCommande = "PAYEE";
expedier(): CommandeExpediee {
if (this.statut !== "PAYEE") {
throw new Error("Commande non payable");
}
this.statut = "EXPEDIEE";
return new CommandeExpediee(this.id, this.referencesArticles());
}
} L'événement CommandeExpediee est ensuite traité dans une transaction séparée qui ajuste le stock. Entre les deux, le système est brièvement incohérent — c'est assumé, car le métier tolère ce délai.
Astuce
Les événements de domaine (Domain Events) comme brique tactique, tout comme l'Event Storming ou le CQRS, sont des prolongements modernes du DDD : ils ne figurent pas comme un patron tactique dans le livre de 2003. Evans, lui, parle de « traitement par événements, traitement par lot, ou autre mécanisme de mise à jour ». Le principe — la cohérence éventuelle entre agrégats — est bien le sien ; la mécanique des Domain Events l'a outillé par la suite.
La fabrique : créer en garantissant les invariants
Une fois la frontière de l'agrégat définie, comment lui donner naissance ? Confier à un objet complexe la responsabilité de sa propre construction est rarement une bonne idée. Evans file l'analogie du moteur : on n'imagine pas un bloc-moteur qui saisirait ses pistons pour les insérer lui-même dans ses cylindres. L'assemblage est confié à autre chose — un robot, un mécanicien — qui est plus complexe que le moteur qu'il assemble. Assembler un objet composé est un métier distinct de celui que l'objet exercera une fois prêt.
Mais déplacer cette responsabilité vers le client appelant est pire encore : le client devrait connaître la structure interne de l'objet et toutes ses règles d'assemblage, ce qui brise l'encapsulation et le couple aux classes concrètes. La solution : une fabrique (Factory), un élément du domaine dont l'unique rôle est de créer d'autres objets.
Une bonne fabrique respecte deux exigences : chaque méthode de création est atomique et garantit tous les invariants du produit (elle ne peut produire qu'un objet dans un état cohérent), et elle est abstraite vis-à-vis du type désiré, pas des classes concrètes.
Méthode-fabrique sur la racine
Pour ajouter un élément à l'intérieur d'un agrégat existant, le plus naturel est une méthode-fabrique (Factory Method) portée par la racine : elle cache la structure interne tout en confiant à la racine la garantie de l'intégrité.
// La racine fabrique elle-même ses éléments internes :
// l'invariant est vérifié à la naissance de la ligne.
class Commande {
ajouterLigne(article: ReferenceArticle, quantite: number): void {
const ligne = LigneCommande.creer(
this.prochainNumeroLigne(),
article,
quantite,
);
this.verifierPlafond(this.total().plus(ligne.sousTotal()));
this.lignes.push(ligne);
}
} Fabrique autonome pour un agrégat entier
Quand la construction est complexe et qu'aucun objet existant ne fait un hôte naturel, on crée une fabrique dédiée. Elle assemble l'agrégat d'un seul tenant, en imposant ses invariants. C'est le bon endroit pour réunir les attributs essentiels et attribuer l'identité.
// Fabrique autonome construisant l'agrégat Commande complet.
class FabriqueCommande {
constructor(private readonly generateurId: GenerateurId) {}
// Atomique : tout ce qu'il faut pour un produit valide,
// en une seule interaction.
ouvrir(
client: ClientId,
plafond: Montant,
panier: ArticlePanier[],
): Commande {
if (panier.length === 0) {
throw new Error("Une commande exige au moins un article");
}
// L'identité est attribuée ici, à la création.
const commande = new Commande(
this.generateurId.suivant(),
client,
plafond,
);
for (const item of panier) {
commande.ajouterLigne(item.article, item.quantite);
}
return commande; // l'agrégat sort entier et cohérent
}
} Astuce
Quand un constructeur suffit-il ? Evans est explicite : la fabrique est sous-utilisée, mais elle peut aussi obscurcir des objets simples. Un constructeur public nu convient quand la classe est son propre type (pas de hiérarchie ni de polymorphisme), que tous les attributs sont fournis par le client, et que la construction n'est pas compliquée. Le constructeur doit alors suivre la même règle que la fabrique : être atomique et satisfaire tous les invariants.
Où placer la logique d'invariant ?
La fabrique est responsable de garantir tous les invariants à la création, mais réfléchissez à deux fois avant de sortir une règle de l'objet auquel elle appartient. La fabrique peut déléguer la vérification au produit, et c'est souvent préférable. Toutefois, certaines règles ne s'appliquent qu'à la création : l'identité d'une entité, une fois attribuée, devient immuable ; un objet-valeur (Value Object) est entièrement immuable. Inutile de traîner toute sa vie une logique qui ne jouera plus jamais. Dans ces cas, la fabrique est l'endroit logique pour héberger l'invariant et alléger le produit.
Le référentiel : retrouver les objets persistants
La fabrique gère le tout début de la vie. Mais au milieu de son cycle de vie, comment obtient-on une référence vers un objet déjà existant ? Il y a trois manières d'obtenir une référence : la créer, parcourir une association depuis un objet qu'on connaît déjà, ou interroger un magasin de données. La première relève de la fabrique. La deuxième suppose qu'on tienne déjà le premier objet. Reste la troisième.
Evans raconte un projet qui, par excès de zèle pour la conception pilotée par le modèle, voulait accéder à tout objet par création ou traversée. Résultat : l'enchevêtrement même que les agrégats cherchent à éviter. La recherche en base permet d'aller directement à n'importe quel objet, sans tout interconnecter — mais sa complexité technique (SQL, mapping objet-relationnel) noie vite le code client et fait dériver le modèle vers un style « traitement de données » où les entités deviennent de simples sacs à données.
Le référentiel (Repository) est la réponse : pour chaque type qui a besoin d'un accès global, on crée un objet qui donne l'illusion d'une collection en mémoire de tous les objets de ce type. On y ajoute, on en retire, on interroge selon des critères — et la machinerie de stockage est entièrement encapsulée derrière une interface exprimée en termes du modèle.
À retenir
Ne fournissez de référentiel que pour les racines d'agrégats qui ont réellement besoin d'un accès direct. Donner un accès global à d'autres objets brouille les distinctions essentielles et risque de violer l'encapsulation des agrégats. La règle « seule la racine est accessible » se prolonge ici : on ne cherche en base que des racines.
L'interface dans le domaine, l'implémentation dans l'infrastructure
C'est le point de conception décisif. L'interface du référentiel appartient à la couche domaine ; elle parle le langage du métier. Son implémentation, truffée de SQL ou d'appels à un ORM, vit dans la couche infrastructure. Le domaine ne dépend ainsi jamais de la technologie de persistance.
// Couche DOMAINE : une interface façon collection,
// exprimée dans les termes du modèle. Aucune trace de SQL.
interface CommandeRepository {
// Recherche par identité : presque tout référentiel l'offre.
parId(id: CommandeId): Promise<Commande | null>;
// Requêtes métier nommées.
enAttenteDExpedition(): Promise<Commande[]>;
duClient(clientId: ClientId): Promise<Commande[]>;
// Ajout / retrait : la machinerie d'insertion est cachée.
ajouter(commande: Commande): Promise<void>;
retirer(commande: Commande): Promise<void>;
} // Couche INFRASTRUCTURE : l'implémentation concrète.
// Elle délègue la reconstitution à une fabrique.
class CommandeRepositorySql implements CommandeRepository {
constructor(
private readonly db: ClientBaseDeDonnees,
private readonly fabrique: FabriqueReconstitutionCommande,
) {}
async parId(id: CommandeId): Promise<Commande | null> {
const lignes = await this.db.requete(
"SELECT * FROM commande WHERE id = $1",
[id.valeur],
);
if (lignes.length === 0) return null;
// La fabrique réassemble l'objet à partir des données.
return this.fabrique.reconstituer(lignes);
}
async ajouter(commande: Commande): Promise<void> {
await this.db.executer(/* INSERT ... */);
// Le référentiel n'effectue PAS le commit :
// le contrôle transactionnel reste au client.
}
// ... autres méthodes
} Pour les tests, cette même interface se substitue trivialement par une collection en mémoire — l'un des grands avantages cités par Evans :
// Implémentation factice pour les tests : un Map en mémoire.
class CommandeRepositoryMemoire implements CommandeRepository {
private readonly store = new Map<string, Commande>();
async parId(id: CommandeId): Promise<Commande | null> {
return this.store.get(id.valeur) ?? null;
}
async ajouter(commande: Commande): Promise<void> {
this.store.set(commande.id.valeur, commande);
}
// ...
} Piège courant
Le client ignore l'implémentation, mais le développeur, lui, ne doit pas l'ignorer. Evans cite l'anecdote d'une application qui tombait en panne de mémoire : une requête « tous les objets » instanciait toute la base d'un coup. L'encapsulation cache la mécanique au code appelant ; elle n'autorise pas à oublier les implications de performance des requêtes qu'on déclenche.
Fabrique et référentiel : qui fait quoi
D'un point de vue purement technique, reconstituer un objet stocké revient à le créer — ce qui pousse certains à confondre les deux. Mais conceptuellement, la distinction est nette et utile.
| Fabrique | Référentiel | |
|---|---|---|
| Rôle | Crée des objets neufs. | Retrouve des objets existants. |
| Identité | Attribue une nouvelle identité. | Conserve l'identité d'origine. |
| Phase | Début du cycle de vie. | Milieu et fin du cycle de vie. |
| Invariant brisé | Refuse net la création. | Doit gérer/réparer l'incohérence. |
La reconstitution d'un objet stocké n'est pas la création d'un nouvel objet conceptuel : un client n'est pas un nouveau client parce qu'on l'a relu depuis la base. C'est pourquoi les deux vues se réconcilient élégamment en faisant déléguer la reconstitution du référentiel à une fabrique. Une fabrique de reconstitution diffère sur deux points : elle n'attribue pas de nouvelle identité (sous peine de perdre la continuité de l'objet), et elle peut devoir réagir plus souplement à un invariant violé — car l'objet existe déjà, on ne peut ni l'ignorer ni ignorer la règle.
Attention
Evans déconseille la fonction « trouver ou créer » (find or create) qui fusionne les deux patrons. C'est une commodité mineure qui brouille une distinction importante. La plupart des cas où elle semble utile disparaissent dès qu'on distingue bien entités et objets-valeurs : un client voulant un objet-valeur s'adresse directement à la fabrique.
À retenir
- L'agrégat est une frontière de cohérence, pas un simple regroupement : c'est la portée à l'intérieur de laquelle les invariants sont garantis à chaque transaction, via une racine unique qui contrôle tout accès.
- Seule la racine est référençable de l'extérieur ; les entités internes ont une identité locale et ne se trouvent que par traversée depuis la racine.
- Référencez les autres agrégats par identité (un
id), jamais par référence directe : agrégats légers, frontières respectées, persistance libre. Une transaction ne modifie qu'un agrégat ; le reste se propage par cohérence éventuelle. - Préférez de petits agrégats : moins de mémoire, moins de conflits, meilleure testabilité.
- La fabrique crée d'un seul tenant un objet ou un agrégat cohérent, en garantissant ses invariants dès la naissance et en cachant la structure interne ; un constructeur nu suffit quand la construction est simple.
- Le référentiel donne l'illusion d'une collection en mémoire de racines d'agrégats : son interface vit dans le domaine, son implémentation dans l'infrastructure, et il délègue la reconstitution à une fabrique — sans jamais confondre créer un objet neuf et retrouver un objet existant.