Aller au contenu
gaetancottrez.dev

Et si l'état de votre objet ne vous appartenait pas ? Modéliser une machine à états réglementaire

Publié: at 08:00 | (15 min de lecture)
Et si l'état de votre objet ne vous appartenait pas ? Modéliser une machine à états réglementaire

Table des matières

Ouvrir table des matières

Quand l’état n’est plus à vous

Vous avez sûrement déjà croisé un tutoriel sur les machines à états. Un feu de circulation qui passe du vert au orange puis au rouge, une commande qui va de « panier » à « payée » à « expédiée ». C’est propre, c’est carré, et surtout : c’est vous qui pilotez chaque transition. Vous appuyez sur le bouton, l’état change. Vous êtes le seul maître à bord.

Sauf que dans la vraie vie d’un backend, ce n’est pas toujours aussi simple.

Il existe une catégorie de systèmes où les états ne vous appartiennent pas. Ils sont définis par une réglementation publique, et plusieurs acteurs — dont des tiers que vous ne contrôlez pas — déclarent eux-mêmes des changements de statut sur vos objets. Vous n’êtes plus le chef d’orchestre. Vous êtes un musicien parmi d’autres, et la partition a été écrite par quelqu’un d’autre.

J’ai vécu ça récemment sur un cycle de vie imposé par une spec officielle (pensez à la réforme française de la facturation électronique, mais le principe vaut pour bien d’autres domaines). Et croyez-moi, le petit enum tout mignon des tutoriels n’a pas tenu trois semaines. Voici ce que j’ai appris à le construire proprement — et pourquoi ces décisions de modélisation vous serviront bien au-delà de la facturation : workflows de commande, modération de contenu, parcours KYC, intégrations bancaires… Bref, tout système dont les états sont imposés par un tiers.

Le décor : un objet qui voyage entre plusieurs mains

Posons le problème simplement. Un objet métier — appelez-le « facture », mais lisez « commande », « dossier » ou « paiement » selon votre univers — traverse un cycle de vie. À chaque étape, il a un statut : il est « déposé », puis « reçu », « mis à disposition », « approuvé », « refusé », « encaissé »… Et chaque statut porte un code officiel, défini par la réglementation.

Jusque-là, rien de bien sorcier, me direz-vous. Sauf qu’il y a un twist : plusieurs plateformes participent au cycle. Certaines que vous opérez, d’autres opérées par des tiers. Du coup, un même statut peut avoir des origines radicalement différentes :

La tentation, quand on découvre ça, c’est de faire ce qu’on a toujours fait : un enum de statuts, une colonne status en base, et hop. Ça marche. Pendant deux semaines. Puis la réalité réglementaire débarque et fait exploser le modèle de l’intérieur. Le même code de statut se met à signifier deux choses opposées selon le contexte, vos alertes de délai partent en vrille, et vous vous retrouvez à empiler des if partout pour rattraper le coup.

Alors reprenons depuis le début, mais bien cette fois.

1. Votre enum interne n’est pas l’ensemble réglementaire

Première erreur, et la plus naturelle : recopier bêtement les codes officiels dans un enum et s’arrêter là. « Puisque la spec définit 12 statuts, mon enum aura 12 valeurs. » Logique, non ?

Eh bien non. En réalité, votre ensemble interne est à la fois plus petit et plus grand que l’ensemble réglementaire. Plus précisément : c’est un sous-ensemble par “étape” et un sur-ensemble par “suffixe”.

Décortiquons cette phrase un peu absconse :

J’ai modélisé ces accusés comme des suffixes accrochés à l’étape de base : DEPOSITED_ACCEPTED, DEPOSITED_REJECTED. Et toute la logique de progression raisonne sur l’étape de base (suffixe retiré).

// L'étape de base, c'est le vocabulaire réglementaire.
// Le suffixe, c'est NOTRE préoccupation technique (l'accusé de réception).
enum Stage {
  CREATED = "CREATED",
  DEPOSITED = "DEPOSITED",
  DEPOSITED_ACCEPTED = "DEPOSITED_ACCEPTED",
  DEPOSITED_REJECTED = "DEPOSITED_REJECTED",
  RECEIVED = "RECEIVED",
  MADE_AVAILABLE = "MADE_AVAILABLE",
  APPROVED = "APPROVED",
  REFUSED = "REFUSED",
  REJECTED = "REJECTED",
  CASHED_IN = "CASHED_IN",
}

// On retire le suffixe pour raisonner sur l'étape "réglementaire".
function baseStage(status: Stage): Stage {
  const base = status.replace(/_(ACCEPTED|REJECTED|IRRECEIVABLE)$/, "");
  return base as Stage;
}

Ce petit bout de code permet de garder deux mondes séparés : le vocabulaire imposé par la spec d’un côté, vos besoins techniques de l’autre. La fonction baseStage est le pont entre les deux.

La leçon est simple mais je la trouve fondamentale : ne confondez jamais le vocabulaire du domaine externe avec votre modèle interne. Gardez une couche de correspondance explicite, et — c’est le point souvent oublié — documentez d’où vient chaque correspondance. Si vous avez déjà lu mon article sur l’architecture hexagonale, vous reconnaîtrez l’idée : votre modèle de domaine ne doit pas être l’esclave d’un format dicté par l’extérieur. Vous traduisez à la frontière, vous restez maître chez vous.

2. La machine à états est une DONNÉE, pas du flot de contrôle

On arrive à ma décision préférée. Celle qui change tout.

La façon naïve de coder une machine à états, c’est le switch géant : « si le statut est X et que l’événement est Y, alors passe à Z, sinon si… ». Ça grossit, ça devient illisible, et personne n’ose plus y toucher au bout de six mois.

L’alternative ? Transformer la table des transitions autorisées en structure de données. Pas en code. En donnée.

// Table d'arêtes : clé = étape courante, valeur = étapes suivantes autorisées.
// C'est de la DONNÉE. Un seul "writer" la consulte ; tout le reste est refusé.
const STAGE_EDGES: Partial<Record<Stage, readonly Stage[]>> = {
  [Stage.CREATED]: [Stage.DEPOSITED],
  [Stage.DEPOSITED]: [
    Stage.RECEIVED,
    Stage.REFUSED,
    Stage.REJECTED,
    Stage.CASHED_IN,
  ],
  [Stage.RECEIVED]: [
    Stage.MADE_AVAILABLE,
    Stage.REFUSED,
    Stage.REJECTED,
    Stage.CASHED_IN,
  ],
  // états terminaux : aucune arête sortante
  [Stage.REFUSED]: [],
  [Stage.REJECTED]: [],
  [Stage.CASHED_IN]: [],
};

function assertTransitionAllowed(from: Stage, to: Stage): Result<void> {
  if (from === to) return Ok(); // ré-écriture même étape : géré à part
  const allowed = STAGE_EDGES[from] ?? [];
  return allowed.includes(to)
    ? Ok()
    : Fail(`transition interdite ${from}${to}`);
}

Le principe est limpide : une transition qui n’est pas dans la table est refusée, pas appliquée. C’est une logique de liste blanche. Par défaut, tout est interdit ; vous n’autorisez que ce que vous avez explicitement déclaré. Pour un système réglementaire, c’est exactement la posture qu’on veut : on ne laisse jamais passer une transition « par accident ».

Mais le vrai bénéfice est ailleurs, et il est énorme : la table devient auto-documentée. J’ai annoté chaque arête avec sa provenance, c’est-à-dire la raison pour laquelle elle existe :

const STAGE_EDGES: Partial<Record<Stage, readonly Stage[]>> = {
  // [R] = règle réglementaire explicite (la spec l'impose noir sur blanc)
  // [V] = séquence vérifiée par un test de conformité officiel
  // [D] = dérivée d'un ordonnancement logique
  // [E] = étape purement technique, sans équivalent réglementaire
  [Stage.CREATED]: [Stage.DEPOSITED], // [E] dépôt initial
  [Stage.DEPOSITED]: [
    Stage.RECEIVED, // [R]
    Stage.REFUSED, // [R]
    Stage.REJECTED, // [V]
    Stage.CASHED_IN, // [D]
  ],
};

Vous voyez où je veux en venir ? Le jour où un collègue (ou vous-même dans six mois) demande « mais pourquoi cette transition est-elle permise, au juste ? », la réponse n’est plus enfouie dans la tête de celui qui a écrit le code. Elle est dans la table, juste à côté de l’arête. [R], c’est la loi. [V], c’est un test de conformité qui l’a prouvé. [E], c’est purement nous.

D’ailleurs, ce point rejoint une obsession que je traîne depuis des années sur ce blog : un bon code raconte ses propres décisions. Ce n’est pas pour rien que je prêche pour les tests unitaires et les design patterns — l’idée, à chaque fois, c’est de rendre le raisonnement lisible, pas seulement le résultat.

Et puisqu’on parle de structure de données plutôt que de flot de contrôle : qui peut écrire dans cette machine ? Un seul écrivain. Un point d’entrée unique pour toutes les transitions. C’est ce qui rend la chose vérifiable. Si vous laissez dix endroits du code modifier la colonne status, vous n’avez plus de machine à états, vous avez un champ de mines.

3. La provenance du statut décide de votre obligation

Accrochez-vous, c’est le point le plus contre-intuitif. Et de loin le plus important.

Le même code de statut n’engage pas les mêmes obligations selon qui l’a déclaré.

Relisez cette phrase. Elle paraît anodine, mais elle a des conséquences énormes. Prenons un statut donné, mettons « reçu » :

Autant dire que si vous ignorez cette dimension, vos tableaux de bord vont se couvrir d’alertes rouges fantômes, et vos utilisateurs vont perdre confiance dans le système. J’ai donc fait remonter la provenance jusqu’à l’écrivain unique, sous la forme d’un simple drapeau. Et c’est lui, et lui seul, qui décide d’armer ou non l’obligation.

type Origination = "LOCAL" | "INGESTED";

function transitionTo(row: Row, to: Stage, origination: Origination = "LOCAL") {
  // ... verrou + garde d'arête (assertTransitionAllowed) ...

  // On n'arme l'obligation (le minuteur de délai) QUE pour un statut
  // émis localement, et UNIQUEMENT en ENTRANT dans une étape obligatoire.
  const entersObligation =
    origination !== "INGESTED" &&
    isObligatoryStage(to) &&
    baseStage(row.status) !== to;

  if (entersObligation) {
    row.deadlineAt = businessTimestamp(to).plusHours(24); // ancré sur le temps métier
    row.satisfiedAt = null; // nouvelle obligation à honorer
  }
  // sinon : on PRÉSERVE les colonnes d'obligation existantes.
  // Une étape facultative ne doit jamais perturber une obligation en cours.
}

Ce qui se joue ici, c’est qu’on a ajouté une dimension orthogonale au statut. Le statut dit en est l’objet. La provenance dit qui l’a fait avancer. Et c’est la combinaison des deux qui détermine votre travail réel.

La leçon : modélisez la provenance d’un événement, parce qu’elle change vos obligations. C’est une information de premier ordre, pas un détail technique qu’on rattrape avec un if dans un coin.

4. Tous les statuts ne se valent pas : obligatoire vs facultatif

Petit constat de terrain qui m’a bien recadré : sur la douzaine de statuts du cycle de vie, seuls quatre portaient une véritable obligation légale de transmission. Quatre. Tous les autres ? De simples marqueurs de progression.

Et ça change la façon dont vous les traitez :

Si vous traitez tous les statuts à la même altitude — en les arrosant tous de minuteurs et de notifications « au cas où » — vous vous noyez. Et vous noyez aussi votre système sous des traitements inutiles.

La parade est presque ridicule de simplicité : une petite liste explicite des étapes « porteuses d’obligation », et tout ce qui n’y figure pas est informatif par défaut.

// La minorité qui compte vraiment. Le reste est purement informatif.
const OBLIGATORY_STAGES: ReadonlySet<Stage> = new Set([
  Stage.DEPOSITED,
  Stage.RECEIVED,
  Stage.REFUSED,
  Stage.CASHED_IN,
]);

function isObligatoryStage(stage: Stage): boolean {
  return OBLIGATORY_STAGES.has(baseStage(stage));
}

Classez vos états par l’obligation qu’ils créent, pas par leur nom ni par leur ordre d’apparition. C’est le critère qui compte vraiment.

5. États terminaux et garde NÉGATIVE explicite

Dernier piège, et il est vicieux.

Certains états sont terminaux : une fois l’objet « refusé » ou « rejeté », plus rien ne doit en sortir. Aucune donnée ne doit plus être émise. Notre table d’arêtes l’exprime déjà très bien : ces états n’ont aucune arête sortante ([]). Mission accomplie ?

Pas tout à fait. Parce qu’il reste un trou.

Imaginez un rejeu : un retry, une ré-ingestion d’un flux, un message qui repasse. L’objet peut alors ré-entrer dans le même état où il est déjà. On a from === to. Et souvenez-vous de notre garde d’arête : elle commence par if (from === to) return Ok(). Elle laisse passer ! Parce qu’elle ne s’intéresse qu’aux changements d’étape, pas aux ré-écritures sur place.

Conséquence : un rejeu sur un objet déjà « refusé » pourrait, en théorie, redéclencher une émission de données. Exactement ce qu’on ne veut pas.

La solution, c’est une garde d’un autre genre. Pas une garde sur la transition « avant », mais une garde négative : « dans tel état, ne jamais faire telle action », évaluée indépendamment.

// La garde d'arête laisse passer une ré-écriture même-étape (from === to).
// Cette garde-ci ferme le trou : interdire l'émission de données
// dès qu'on est dans un état terminal d'annulation, rejeu ou pas.
function blocksDataEmission(status: Stage): boolean {
  return [Stage.REFUSED, Stage.REJECTED].includes(baseStage(status));
}

La nuance est subtile mais essentielle. Une garde sur les transitions répond à la question « ai-je le droit de passer de A à B ? ». Une garde négative répond à « ai-je le droit de faire X alors que je suis dans l’état Y ? ». Ce sont deux questions différentes, et certaines règles métier ne s’expriment proprement qu’avec la seconde — surtout dès que des rejeux entrent en jeu.

La machine à états : qui déclenche quoi

Un mot sur le temps

Vous avez peut-être tiqué sur un détail dans le code de la section 3 : businessTimestamp(to).plusHours(24). Pourquoi pas un bon vieux new Date() ?

Parce que dans un système réglementaire, le temps doit être déterministe et rejouable. Si votre délai est ancré sur l’horloge murale du serveur au moment où le code tourne, vous ne pourrez jamais rejouer un événement à l’identique. Or ces systèmes vivent souvent dans des moteurs de workflow où le rejeu est la norme, pas l’exception.

Ancrez vos délais sur le temps métier — l’horodatage de l’événement lui-même, celui qui fait foi réglementairement — et jamais sur l’instant d’exécution. C’est ce qui rend toute la mécanique reproductible. Un petit détail qui sauve des heures de débogage le jour où il faut comprendre pourquoi une obligation a été déclenchée à tel moment.

Les enseignements à emporter

Si vous ne deviez retenir que cinq choses de cet article, ce serait celles-ci :

  1. Le vocabulaire externe n’est pas votre modèle interne. Quand les états viennent d’une spec, gardez une couche de correspondance explicite. Votre enum peut être un sous-ensemble par étape et un sur-ensemble par suffixe de l’ensemble officiel.
  2. La machine à états est une donnée, pas un switch. Une table d’arêtes consultée par un écrivain unique ; annotez chaque arête avec sa provenance. La table devient sa propre documentation.
  3. La provenance d’un événement change vos obligations. Le même statut, émis par vous ou reçu d’un tiers, n’engage pas le même travail. Modélisez LOCAL vs INGESTED comme une dimension à part entière.
  4. Classez les états par l’obligation qu’ils créent, pas par leur nom. Une petite minorité porte des délais et des actions ; la grande majorité n’est qu’informative.
  5. Ancrez les délais sur le temps métier, jamais sur l’horloge murale. C’est ce qui rend le tout déterministe et rejouable.

Épilogue

Ce qui m’a le plus marqué dans cet exercice, c’est à quel point un problème en apparence banal — « gérer un statut » — cache une vraie richesse de modélisation dès que vous n’êtes plus seul maître des transitions. Le réflexe « enum + colonne status » est tellement ancré qu’on ne se pose même plus la question. Et pourtant, dès qu’un tiers entre dans la danse, ce réflexe devient un piège.

La bonne nouvelle, c’est que rien de tout ça n’est réservé à la facturation électronique. La prochaine fois que vous modéliserez un workflow de commande, un parcours de modération, un processus KYC ou une intégration bancaire, posez-vous les bonnes questions : qui déclare cet état ? Est-ce que ça crée une obligation ? Est-ce que le même code peut signifier deux choses ? Vous verrez, ça change la copie.

Et vous, êtes-vous déjà tombé sur une machine à états dont les transitions ne vous appartenaient pas ? Comment vous en êtes-vous sorti ? Racontez-moi ça en commentaire, je me ferai une joie de vous répondre et d’échanger sur vos propres galères de modélisation.

Pour aller plus loin, je vous renvoie vers ces articles qui creusent les mêmes fondations :

Vous pourriez aussi aimer

Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Article précédent
Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données
Article suivant
Comment l'algorithmique a transformé ma carrière de développeur