Domaines et cycles de vie - 05 - Transitions, guards et side effects

Les transitions, guards et side effects dans une state machine. Comment protéger ton cycle de vie avec des preconditions testables en TypeScript.

05 - Transitions, guards et side effects

Ce que tu vas apprendre

  • Ce qu'est une transition et ses propriétés
  • Ce qu'est un guard et comment il protégé le cycle de vie
  • Ce qu'est un side effect et quand il se déclenché
  • La différence entre une transition implicite et explicite

Prerequisites


La transition : le passage d'un état a un autre

Une transition est le passage d'un état A a un état B. C'est l'unité de base du cycle de vie. Chaque transition a quatre propriétés :

Explicite

Elle a un nom. Pas "on met à jour le status", mais ENRICH, VALIDATE, PUBLISH. Le nom dit ce qui se passe en termes métier.

Contrainte

Elle n'est autorisee que si les guards (gardes) sont satisfaits. On ne peut pas transitionner "comme ca".

Atomique

Elle reussit complètement ou pas du tout. Il n'y a pas d'état "a moitie transitionne". Si le guard echoue ou si le side effect plante, on reste dans l'état d'origine.

Auditable

Elle laisse une trace : qui a déclenché la transition, quand, depuis quel état, vers quel état. C'est fondamental pour le debug et la conformité.

Le guard : la precondition

Un guard (garde) est une condition qui doit etre vraie pour qu'une transition soit autorisee. C'est le gardien du cycle de vie.

Exemples concrets :

  • Pour passer de DRAFT a ENRICHED : "la Place doit avoir au moins une categorie ET au moins une traduction avec description"
  • Pour passer de IMAGES_PROCESSED a READY_FOR_PUBLICATION : "la Place doit avoir au moins 3 images avec Top 1, 2 et 3 assignes"
  • Pour passer de READY_FOR_PUBLICATION a PUBLISHED : "les traductions EN et FR doivent avoir une description non vide"
typescript// Guard : une fonction pure qui retourne un booleen
function canEnrich(place: PlaceEntity): boolean {
  return (
    place.categories.length > 0 &&
    place.translations.length > 0 &&
    place.translations.every((t) => t.description !== null && t.description.length > 0)
  );
}

// Guard pour la validation d'images
function canValidateImages(place: PlaceEntity): boolean {
  const tops = place.images.filter((i) => i.top !== null);
  return tops.length >= 3 && new Set(tops.map((i) => i.top)).size >= 3;
}

Propriétés d'un bon guard

  • Fonction pure : pas de side effects, pas d'appels réseau, pas d'écriture en base
  • Deterministe : pour les memes donnees, toujours le meme résultat
  • Testable unitairement : un guard se teste en une ligne
  • Independant du contexte : il regarde l'entité, pas "qui" demande la transition

Le side effect : l'action déclenchée

Un side effect (effet de bord) est une action qui se produit apres qu'une transition a reussi. C'est tout ce qui n'est pas le changement d'état lui-meme.

Exemples :

  • Envoyer un email de notification quand une Place passe a PUBLISHED
  • Mettre à jour un cache quand une Place passe a ENRICHED
  • Logger la transition pour l'audit trail
  • Declencher le pipeline d'images quand on passe a READY_FOR_IMAGES
typescriptconst transitions = {
  READY_FOR_PUBLICATION: {
    PUBLISH: {
      target: "PUBLISHED",
      guard: canPublish,
      sideEffects: [
        (place) => auditLog.record("PUBLISH", place.id, Date.now()),
        (place) => notificationService.send(`Place ${place.name} publiee`),
        (place) => cacheService.invalidate(`place:${place.id}`),
      ],
    },
  },
};

Regle importante

Les side effects se declenchent apres le changement d'état, jamais avant. Si un side effect echoue, l'état a deja change. C'est pour ca qu'on les concoit pour etre idempotents (on verra ca dans l'article 08) : si on les re-exécuté, ca ne casse rien.

Mauvais exemple : transitions implicites

Voici ce que ca donne sans transitions explicites :

typescript// Mauvais : transition implicite dans du code imperatif
async function handlePlaceUpdate(place: Place, data: UpdateData) {
  place.name = data.name;
  place.categories = data.categories;

  // La transition est cachee dans un if/else
  if (data.categories.length > 0 && data.translations.length > 0) {
    place.status = "ENRICHED"; // Qui a autorise ca ? Quel guard ? Aucune trace.
  }

  await db.save(place);
}

Problèmes : pas de guard formel, pas de trace, pas de validation que la transition est autorisee depuis l'état actuel. Si place.status etait PUBLISHED, on vient de le repasser a ENRICHED sans aucun contrôle.

Bon exemple : transitions declaratives

typescript// Bon : chaque transition est declaree, gardee, tracee
async function enrichPlace(placeId: string, enrichmentData: EnrichmentData) {
  const place = await db.findById(placeId);

  // La transition map sait que DRAFT -> ENRICHED est autorise
  // Le guard verifie les preconditions
  // Le side effect logge la transition
  const updated = transition(place, "ENRICH");

  await db.save(updated);
}

La logique de "qui peut transitionner ou" est séparée de la logique de "comment on fait la transition". C'est ca, la séparation des responsabilités.


Résumé

  • Une transition est explicite, contrainte, atomique et auditable
  • Un guard est une precondition pure et testable qui protégé une transition
  • Un side effect est une action déclenchée apres une transition reussie
  • Les transitions implicites (if/else dans du code métier) sont la source de bugs et d'états incoherents
  • Les transitions declaratives (transition map) rendent le système lisible, testable et sur

Article précédent : 04 - La machine a états Article suivant : 06 - Single Source of Truth (SSOT)

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.