
Table des matières
Ouvrir table des matières
- Quand l’état n’est plus à vous
- Le décor : un objet qui voyage entre plusieurs mains
- 1. Votre enum interne n’est pas l’ensemble réglementaire
- 2. La machine à états est une DONNÉE, pas du flot de contrôle
- 3. La provenance du statut décide de votre obligation
- 4. Tous les statuts ne se valent pas : obligatoire vs facultatif
- 5. États terminaux et garde NÉGATIVE explicite
- Un mot sur le temps
- Les enseignements à emporter
- Épilogue
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 :
- émis par vous : vous l’avez déclaré, vous en êtes responsable ;
- reçu d’un tiers : un autre acteur l’a déclaré, on vous le relaie pour info ;
- obligatoire : la loi vous impose de le transmettre, sous un certain délai ;
- facultatif : un simple marqueur de progression, purement informatif.
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 :
- une étape (
DEPOSITED,RECEIVED,APPROVED…) correspond bien à un statut réglementaire. Ça, c’est le vocabulaire du domaine externe ; - mais votre application a besoin de tracer des choses que la réglementation ne nomme même pas. Typiquement : l’accusé de réception d’un flux. Quand vous déposez quelque chose, est-ce que ça a été accepté, rejeté, ou jugé irrecevable ? Ça, c’est une préoccupation purement applicative. La spec s’en fiche, mais vous, vous devez le savoir.
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 » :
- si c’est vous qui le déclarez (origine locale), ça déclenche une obligation légale : transmettre l’information à l’autorité dans un certain délai — disons 24 heures à titre d’illustration. Il faut donc armer un minuteur ;
- si ce même statut arrive d’un tiers (origine ingérée), il est purement informatif. Le tiers a déjà rempli son obligation de son côté. Si vous armez un minuteur, vous fabriquez une fausse alerte de dépassement de délai pour une obligation… qui n’est même pas la vôtre.
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 où 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 :
- les statuts porteurs d’obligation méritent un délai, un minuteur, une éventuelle ré-émission, des notifications. C’est le cœur du réacteur ;
- les statuts informatifs n’ont besoin de rien de tout ça. Ils alimentent juste une timeline consultable par l’utilisateur. Pas de délai, pas d’alerte, pas de stress.
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.

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 :
- 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.
- 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. - 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
LOCALvsINGESTEDcomme une dimension à part entière. - 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.
- 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 :
- Architecture hexagonale : reprenez le contrôle du code métier — pour comprendre pourquoi votre domaine ne doit jamais être l’esclave d’un format externe.
- Les design patterns — la machine à états en est un, et il en existe bien d’autres qui vous éviteront de réinventer la roue.
- Les tests unitaires — parce qu’une table de transitions, ça se teste merveilleusement bien (et ça vous évite les
[V]à l’aveugle).

