Post

Modéliser les états

Principes de modélisation des objets métier et de leurs états via des types union.

Modéliser les états

Nous allons détailler les principes structurants pour modéliser des états métier consistants et évolutifs, avec des transitions explicites et sans perte de traçabilité.
En accord avec les notions de gravité data-first et de température des données exposées dans le manifeste, cette approche élève les états au rang de citoyens de première classe, tout en décomposant les domaines complexes en facettes orthogonales pour éviter les mélanges sémantiques.

ADT (type union) : la brique de base

Ici, ADT = Algebraic Data Type, utilisé comme un type union (union discriminée) : un seul état possible à la fois, et chaque état a ses propres champs.

Un type union est un type « ou-bien » composé de variantes exclusives (les états).
En pratique : une union discriminée où chaque variante peut embarquer ses propres champs, distincte d’une simple énumération qui ne capture pas cette richesse structurée.

  • Résultat d’une opération Success | Failure avec des champs spécifiques à chaque cas.
  • État d’une commande Pending | Paid | Cancelled | Refunded, chaque état ayant ses propres attributs.

Les types union sont courants en programmation fonctionnelle (Haskell, OCaml, F#) et se rapprochent des types discriminés ou union types dans les langages de la vie réelle.

Principes fondamentaux

Ces principes posent la base d’une modélisation des états métier : fermée sur la cardinalité mais ouverte sur l’enrichissement des caractéristiques, suivant le modèle open / closed des principes SOLID.

Relation domaine – état

  1. Mono-domaine – Un schéma d’état ne porte qu’un métier (un seul ADT), correspondant à une facette unique (ex. : paiement ou livraison pour une commande). Plusieurs facettes orthogonales impliquent plusieurs ADT parallèles, chacun exclusif et exhaustif dans son axe.
  2. Exhaustivité – Le modèle couvre tous les états métier possibles, sans lacune ni ambiguïté. Cette fermeture est valable à un instant t ; un ajout d’état reflète un changement métier profond, nécessitant une nouvelle version du modèle.
  3. Unicité – À un instant t, pour une entité donnée et un domaine donné, un seul état peut être actif.
  4. État dérivable – L’état se déduit entièrement des données. Aucun “status magique” n’est nécessaire. Par exemple, l’état courant est le dernier état ouvert, et sa date de fin est dérivée de la date de début de l’état subséquent (intervalle [start, next.start[). Un état terminal reste ouvert à droite, sans successeur.
  5. Temporalité – Tout état s’inscrit dans un intervalle de temps. Sans temporalité, il s’agit d’une simple caractéristique, pas d’un état.
  6. Transitions explicites – Tout changement d’état résulte de la création d’un nouvel état via une transition métier identifiée. Les états terminaux sont explicitement marqués comme tels, sans transitions sortantes possibles.

Stabilité de la modélisation

  1. Les états sont fermés – L’ajout de nouveaux états traduit un changement profond du métier, pas une simple évolution technique.
  2. Les attributs des états sont ouverts – Les champs d’un état peuvent être enrichis sans altérer la cohérence du modèle.
  3. Optionnels non-décisionnels – Une valeur optionnelle n’altère jamais l’appartenance à l’état : elle n’enrichit que sa description. Si l’absence, la présence ou la valeur d’un attribut change l’appartenance à l’état, il devient décisionnel et intègre la structure de l’état (ou déclenche une transition).
  4. Invariants localisés – Chaque état porte ses propres règles de validité. Les contraintes sont locales, pas globales.
  5. Historisation – L’historique des états est conservé intégralement. On clôt l’ancien état et on insère le nouveau, sans jamais modifier ou supprimer le passé.

En accordant aux états du domaine une place centrale et prépondérante, on renforce intrinsèquement la lisibilité et la cohérence des données métier, tout en facilitant l’évolution par itérations successives.

Anti-patterns

On retrouve des booléens et des énumérations porteuses d’état dans la majorité des schémas relationnels, souvent à l’origine de problèmes de maintenabilité et d’évolution. Nous détaillons ci‑dessous deux anti‑patterns courants, et proposons la décomposition en facettes comme antidote pour éviter les domaines croisés.

Booléens décisionnels

Les booléens décisionnels mènent rapidement à des combinaisons ambiguës ou incohérentes.

L’usage de booléens pour représenter des états métier est un anti‑pattern fréquent. Un booléen is_paid peut sembler simple, mais il ne capture pas la complexité des états possibles (ex. PENDING, PAID, CANCELLED, REFUNDED). Il conduit souvent à un état ambigu, voire incohérent.

Dans un exemple typique, une table de commandes inclut plusieurs booléens comme is_paid, is_cancelled et is_refunded. Cela génère des ambiguïtés :

1
2
3
4
5
6
7
8
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  is_paid BOOLEAN DEFAULT false,
  is_cancelled BOOLEAN DEFAULT false,
  is_refunded BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now(),
  modified_at TIMESTAMPTZ DEFAULT now()
);
CasValeurs possiblesInterprétation
Ambiguis_paid = true, is_cancelled = trueCommande payée puis annulée ?
Ambiguis_paid = false, is_refunded = trueCommande remboursée sans paiement ?
Incohérentis_paid = true, is_cancelled = true, is_refunded = trueCommande payée, annulée et remboursée ?

Ces cas illustrent l’ambiguïté combinatoire : plusieurs flags vrais produisent des combinaisons impossibles à interpréter. Les autres ambiguïtés (temporelle, granularité, domaine croisé) sont détaillées ci‑dessous.

Les booléens de statut simplifient excessivement des situations riches. Quatre ambiguïtés dominantes expliquent la dérive :

  1. Combinatoire – Plusieurs flags vrais (is_paid + is_cancelled + is_refunded) forment des combinaisons que le schéma ne sait pas interpréter ni interdire (cf. tableau ci‑dessus).
  2. Temporelle – L’ordre des faits est perdu : is_paid = true et is_cancelled = true ne disent pas lequel est survenu en premier, empêchant toute logique métier fiable.
  3. Granularité – Un flag ne porte ni montants, ni raisons, ni distinctions (autorisé vs capturé, partiel vs total). L’état réel a besoin d’attributs structurés.
  4. Domaine croisé – Des flags de domaines différents (is_paid, is_shipped, is_delivered) se retrouvent sur la même table ; on autorise implicitement des états illégaux (livré sans payé) faute d’invariants exprimés.

L’usage de booléens pour représenter des états métier conduit à des combinaisons ambiguës, impossibles à valider proprement, et à une explosion de cas incohérents. On ne traite pas un is_cancelled comme un simple champ décoratif. Il décide du récit métier.

La table devient une matrice implicite d’états difficile à valider et à rejouer. Remplacer les booléens par un journal d’événements ou un ADT de variantes rend l’historique explicite, encode les attributs indispensables et formalise les transitions autorisées. Le SDD fonctionne avec ou sans journal d’événements ; son cœur reste les états et leurs transitions explicites.

Énumération porteuse d’état

Une énumération ne suffit pas à capturer la richesse des états métier.

L’utilisation d’un enum plat (ex. status ENUM('PENDING','PAID','CANCELLED','REFUNDED')) impose globalement d’ajouter des champs optionnels pour chaque état (ex. cancel_reason, refunded_at).

  • Le caractère optionnel est directement lié à l’état, ce qui viole l’axiome des optionnels non-décisionnels.
  • Certains champs deviennent obligatoires selon l’état, mais cette contrainte est rarement exprimée dans le schéma, menant à des données invalides.
  • Des attributs peuvent être partagés entre plusieurs états, ajoutant de la confusion et des effets de bord difficiles à identifier.

Le code applicatif devient le seul responsable de maintenir la cohérence entre status et les champs associés, ce qui favorise les bugs et la dette technique par l’introduction de la nullité et des règles implicites.

Dans un exemple typique, une table de commandes inclut un champ status avec des colonnes associées comme cancel_reason, refunded_at et paid_at.

1
2
3
4
5
6
7
8
9
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  status ENUM('PENDING','PAID','CANCELLED','REFUNDED') NOT NULL,
  cancel_reason TEXT,
  refunded_at TIMESTAMPTZ,
  paid_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now(),
  modified_at TIMESTAMPTZ DEFAULT now()
);
  • cancel_reason n’a de sens que pour CANCELLED,
  • refunded_at uniquement pour REFUNDED,
  • paid_at pour PAID.
  • modified_at est partagé, mais n’a pas de signification claire selon l’état.

La moitié des colonnes deviennent donc inutiles ou NULL selon la valeur de status. Au fil du temps, de nouveaux états apparaissent (RETURNED, FAILED, PARTIALLY_REFUNDED…), chacun amenant de nouvelles colonnes optionnelles.

La colonne modified_at n’est plus l’unique source de vérité selon l’état:

  • Pour PENDING, c’est created_at.
  • Pour PAID, c’est paid_at.
  • Pour CANCELLED, c’est modified_at.
  • Pour REFUNDED, c’est refunded_at.

Le code applicatif doit alors gérer une matrice croissante de conditions et d’assertions pour maintenir la cohérence entre status et les champs associés.

Immutabilité

La mutabilité doit rester l’exception : privilégier les objets immuables pour représenter les états métier.

Quand le domaine le permet, les objets représentant des états métier doivent être traités comme immuables. Une fois créés, ils ne doivent pas être modifiés, mais plutôt remplacés par de nouveaux objets représentant les états suivants.

Cependant, dans certains cas exceptionnels, les champs relatifs à un état peuvent nécessiter des mises à jour. Dans ces situations, il est crucial de documenter clairement les règles de modification et il faut s’assurer que ces mutations n’altèrent pas l’intégrité de l’état.

Exemple :

Un champ optionnel delivery_notes peut être ajouté à un état SHIPPED pour capturer des informations supplémentaires sur la livraison. Tant que ce champ n’affecte pas la nature de l’état SHIPPED, il peut être mis à jour sans violer le principe d’immuabilité de l’état lui-même.

Le principe de l’immuabilité ne doit pas conduire à créer un ADT pour chaque petite modification. Il est important de distinguer entre les changements qui affectent l’état métier et ceux qui sont simplement des mises à jour d’attributs non décisionnels.

Implémentation en base de données relationnelle

Pour appliquer les principes du State-Driven Design (SDD) dans un contexte de persistance relationnelle, il convient d’adopter des structures qui renforcent l’immutabilité, l’exhaustivité et les transitions explicites. Les éléments suivants intègrent des mécanismes avancés pour garantir l’intégrité structurelle, tout en facilitant l’évolutivité et la traçabilité.

Relation entre états via tables de mapping

Les transitions entre états peuvent être encodées de manière déclarative via des tables de mapping spécifiques, par exemple une table reliant un état “pending” à un état “paid”. Ces tables utilisent des références étrangères pour restreindre les flux autorisés, évitant ainsi les contraintes dynamiques ou les triggers. Cette approche scelle les états finaux en interdisant implicitement les transitions sortantes non prévues, tout en préservant une traçabilité immutable de la chaîne d’états. Elle aligne le schéma sur le principe de fermeture des états, facilitant les audits et prévenant les ambiguïtés combinatoires.

Cette structure garantit que seul un état donné peut mener à un autre état spécifique, renforçant l’intégrité sans logique applicative supplémentaire.

Unicité du timestamp

Pour unifier les timestamps et renforcer la linéarité temporelle, il est préférable d’éliminer une colonne dédiée à la fermeture d’un état au profit d’une dérivation basée sur la date de création de l’état subséquent. Cela simplifie le schéma en évitant la redondance, tout en classifiant les états en initial, transitoire et final. Les états terminaux sont scellés par des vérifications structurelles, telles que l’absence de tables de mapping sortantes. Bien que cela puisse complexifier légèrement les requêtes, des vues matérialisées peuvent résoudre ce point, alignant ainsi la modélisation sur les principes de temporalité et d’historisation du SDD.

Une vue dédiée peut alors être utilisée pour dériver les intervalles temporels, assurant une cohérence sans altérer les données sous-jacentes.

Tables d’extension pour l’enrichissement ouvert

Les tables d’extension permettent d’enrichir les états sans compromettre leur immutabilité. Le choix du positionnement de l’extension dépend de la nature de l’attribut ajouté :

  • Dans la structure de l’état si c’est du décisionnel métier (c’est-à-dire si l’attribut impacte directement les invariants ou les transitions de l’état).
  • Dans une structure adjacente (table d’extension) quand c’est de l’enrichissement non décisionnel (propriétés optionnelles qui n’altèrent pas la nature de l’état). Une extension ne doit jamais faire basculer la variante d’état ; si un attribut devient décisionnel, il sort de l’extension et intègre l’état.

Ces tables stockent les propriétés optionnelles non décisionnelles via une référence à l’identifiant unique de l’état, avec des timestamps pour la traçabilité. Cette séparation localise les mises à jour, évitant la prolifération de colonnes nullables dans les tables principales. Elle respecte le principe d’ouverture aux attributs tout en préservant les invariants métier, favorisant ainsi une modélisation flexible et maintenable.

Cette structure permet des itérations successives sans impact sur les états centraux, en conformité avec les anti-patterns évités dans le SDD.

ADT dans les langages de la vie réelle

Venant du monde des langages de programmation fonctionnels, les ADT se retrouvent sous différentes formes dans les langages courants, ce concept n’est plus réservé aux langages exotiques.

Types discriminés en TypeScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type PendingOrder = {
  kind: 'PENDING';
  createdAt: Date;
};

type PaidOrder = {
  kind: 'PAID';
  paidAt: Date;
  amount: number;
};

type CancelledOrder = {
  kind: 'CANCELLED';
  cancelledAt: Date;
  reason: string;
};

type RefundedOrder = {
  kind: 'REFUNDED';
  refundedAt: Date;
  paidAmount: number;
  refundAmount: number;
};

type Order = PendingOrder | PaidOrder | CancelledOrder | RefundedOrder;

Présentation sur l’utilisation des types lors de la modélisation du métier avec TypeScript.

Sealed classes en Java

1
2
3
4
5
sealed interface Order permits Pending, Paid, Cancelled, Refunded {}
record Pending(LocalDateTime createdAt) implements Order {}
record Paid(LocalDateTime paidAt, BigDecimal amount) implements Order {}
record Cancelled(LocalDateTime cancelledAt, String reason) implements Order {}
record Refunded(LocalDateTime refundedAt, BigDecimal amount) implements Order {}

Présentation du pattern matching et du Data Driven Design en Java.

Articles en relation

This post is licensed under CC BY 4.0 by the author.