Concept #016
ArchitectureL'Invariant Métier
- architecture
- domain-driven-design
- programmation
Un invariant métier est une règle qui doit être vraie à tout moment, sans exception. Pas juste au moment de soumettre un formulaire — tout le temps, dans chaque couche de votre application. Si votre code laisse passer une commande avec un total négatif ou une réservation dont la fin précède le début, vous n'avez pas un bug : vous avez un objet qui ment.
Validation vs Invariant : la distinction qui change tout
Ces deux concepts se ressemblent en surface, mais ils n'ont pas la même nature, ni la même place dans votre architecture.
La validation répond à la question : "L'utilisateur a-t-il bien rempli ce formulaire ?" Elle vit à la frontière de votre système — dans l'UI, dans une couche HTTP, dans un DTO. Elle peut être contournée. Un appel direct à votre API, un script en base de données, ou une autre entrée dans votre système peuvent parfaitement court-circuiter toute validation de formulaire.
L'invariant répond à une question différente : "Est-ce que cet objet a encore un sens dans mon domaine ?" Il ne vit pas à la frontière — il vit au cœur de l'objet lui-même. Il ne peut pas être contourné parce qu'il est gravé dans la logique de construction et de mutation de l'entité.
| Validation | Invariant | |
|---|---|---|
| Question | "Le formulaire est-il correct ?" | "L'objet a-t-il encore un sens ?" |
| Où | Frontières (UI, HTTP, DTO) | Cœur du domaine (entité, Value Object) |
| Quand | Au moment de la saisie | À tout moment, en permanence |
| Si violé | Message d'erreur à l'utilisateur | Exception — état invalide interdit |
| Peut être contourné ? | Oui | Non |
La validation est utile. L'invariant est vital.
Exemples concrets : quand l'objet se ment à lui-même
Commande avec un total négatif
Considérez ce code qui semble anodin :
class Order {
public total: number;
public items: OrderItem[];
constructor(items: OrderItem[]) {
this.items = items;
this.total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
applyDiscount(amount: number): void {
this.total -= amount; // Rien n'empêche total de devenir négatif
}
}
const order = new Order([{ price: 10, quantity: 2 }]); // total = 20
order.applyDiscount(50); // total = -30 ← bombe à retardementorder.total vaut maintenant -30. Cette commande est sémantiquement absurde dans tout système e-commerce. Pourtant, le code compile, les tests passent, l'objet est persisté en base. La bombe est armée — elle explosera plus tard, dans un rapport de comptabilité ou lors du calcul d'une TVA.
Réservation avec une fin avant le début
class Reservation {
public startDate: Date;
public endDate: Date;
setDates(start: Date, end: Date): void {
this.startDate = start;
this.endDate = end; // Rien n'empêche end < start
}
getDurationInDays(): number {
// Retourne un nombre négatif si endDate < startDate
return (this.endDate.getTime() - this.startDate.getTime()) / 86_400_000;
}
}
const r = new Reservation();
r.setDates(new Date("2026-03-15"), new Date("2026-03-10"));
console.log(r.getDurationInDays()); // -5 ← une réservation de -5 joursUne réservation qui finit avant de commencer n'est pas une réservation — c'est une erreur de données déguisée en objet. Si ce type de données atteint la persistance, vous avez un problème structurel qui se propagera dans tous les rapports, toutes les factures, toutes les disponibilités calculées à partir de cet objet.
Comment implémenter : des objets qui se protègent eux-mêmes
La solution n'est pas d'ajouter des vérifications autour de l'objet. C'est de les mettre à l'intérieur, de manière à ce que l'objet refuse d'exister dans un état invalide.
Entités riches avec constructeur défensif
class Order {
private _total: number;
private _items: ReadonlyArray<OrderItem>;
constructor(items: OrderItem[]) {
if (items.length === 0) {
throw new Error("Une commande ne peut pas être vide.");
}
this._items = items;
this._total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
applyDiscount(amount: number): void {
if (amount < 0) {
throw new Error("Une remise ne peut pas être négative.");
}
const newTotal = this._total - amount;
if (newTotal < 0) {
throw new Error(
`La remise de ${amount} dépasse le total de la commande (${this._total}).`
);
}
this._total = newTotal;
}
get total(): number {
return this._total;
}
}
// Maintenant, ceci est impossible — l'objet se défend :
order.applyDiscount(50); // → Error: La remise de 50 dépasse le total de la commande (20).L'invariant "un total ne peut pas être négatif" est maintenant inviolable. Peu importe par où le code passe, quelle API appelle quelle méthode, quel service fait quelle opération — Order.total ne sera jamais négatif. C'est une garantie structurelle, pas une convention.
Value Objects : encapsuler les règles dans le type lui-même
Pour les concepts du domaine qui portent leurs propres règles (un prix, une plage de dates, une quantité), les Value Objects sont la réponse idéale. Ils encapsulent la valeur et ses invariants dans un type dédié.
class DateRange {
readonly start: Date;
readonly end: Date;
constructor(start: Date, end: Date) {
if (end <= start) {
throw new Error(
`La date de fin (${end.toISOString()}) doit être postérieure à la date de début (${start.toISOString()}).`
);
}
this.start = start;
this.end = end;
}
getDurationInDays(): number {
// Ce calcul est toujours positif — l'invariant est garanti par construction
return (this.end.getTime() - this.start.getTime()) / 86_400_000;
}
overlaps(other: DateRange): boolean {
return this.start < other.end && this.end > other.start;
}
}
class Reservation {
readonly period: DateRange; // Le type lui-même garantit la cohérence
constructor(period: DateRange) {
this.period = period;
}
}
// À l'usage : l'erreur est levée au moment de créer la plage, pas plus tard
const period = new DateRange(
new Date("2026-03-15"),
new Date("2026-03-10")
); // → Error: La date de fin doit être postérieure à la date de début.DateRange ne peut exister qu'avec une date de fin postérieure à la date de début. Reservation l'utilise — et hérite de cette garantie automatiquement, sans avoir besoin de réécrire la règle. C'est ça, la puissance des Value Objects : la règle vit dans le type, pas dans les contrôleurs.
Le lien avec le Domain-Driven Design
Dans le DDD, les invariants ne sont pas une bonne pratique optionnelle — ils sont la définition même d'un modèle de domaine riche. Eric Evans parle d'"objets dont on peut faire confiance" : des objets qui, par construction, ne peuvent jamais se retrouver dans un état qui violerait les règles du métier.
Le contraire, c'est le modèle anémique : des classes qui ne sont que des conteneurs de données avec des getters et des setters publics, et dont toute la logique de validation est éparpillée dans des services externes. Le modèle anémique place la responsabilité de maintenir la cohérence sur le code appelant, ce qui garantit qu'à un moment ou un autre, quelqu'un oubliera de le faire.
Les Aggregates du DDD vont encore plus loin : ils définissent une frontière de cohérence à l'intérieur de laquelle tous les invariants doivent être vrais à tout moment. Toute modification de l'agrégat passe par sa racine (Aggregate Root), qui est le seul point d'entrée et donc le seul gardien des invariants.
Où placer la logique : les règles d'or
Dans le constructeur pour les invariants qui doivent être vrais dès la création. Si un objet ne peut pas exister sans respecter une règle, la règle va dans le constructeur. Pas de demi-mesure.
Dans les méthodes de mutation pour les invariants qui doivent être maintenus lors de chaque changement d'état. Avant de modifier l'état interne, vérifiez que la modification respecte les règles.
Dans un Value Object dédié quand la règle est intrinsèque à un concept du domaine (un montant, une plage, un identifiant, un email). Le type lui-même devient la documentation vivante de la contrainte.
Jamais dans un service externe pour des invariants cœur. Un OrderValidationService qui vérifie si total >= 0 est une mauvaise architecture : la règle devrait être impossible à violer, pas simplement vérifiée après coup.
// Anti-pattern : l'invariant est externe à l'objet
class OrderValidationService {
validate(order: Order): void {
if (order.total < 0) throw new Error("Total invalide"); // Trop tard
}
}
// Bonne pratique : l'invariant est interne à l'objet
class Order {
private _total: number;
applyDiscount(amount: number): void {
const newTotal = this._total - amount;
if (newTotal < 0) throw new Error("Total invalide"); // Impossible à contourner
this._total = newTotal;
}
}Un invariant métier, c'est une promesse que votre objet fait au reste du système : "Tant que j'existe, mes données ont un sens." Si vous codez des setters publics sur tous vos champs, vous ne faites pas cette promesse — vous la déléguez à tous ceux qui utilisent votre objet, maintenant et dans dix ans. C'est ça, la bombe à retardement.