Implémenter une state machine métier dans NestJS : le pattern qu'on utilise partout

Dossier en attente, rendez-vous planifié, document manquant, contrat signé... Quasiment tous nos projets ont un objet métier qui passe par une série d'états. Voici le pattern qu'on utilise systématiquement.

Le problème

Un dossier de conseil financier passe par 12 états. Un dossier de création d'entreprise en a 11. Un devis en a 10. Chaque transition déclenche des actions : envoyer un email, demander un document, notifier un admin.

Sans structure, ça finit en forêt de if/else dans un service de 800 lignes. On l'a vu. On l'a vécu.

L'enum des états

Tout commence par un enum :

export enum RecordState {
  R0 = 'R0',           // Prospect
  R1 = 'R1',           // 1er rendez-vous
  POST_R1 = 'POST_R1', // Suivi post-R1
  PRE_R2 = 'PRE_R2',   // Préparation R2
  R2 = 'R2',           // 2e rendez-vous
  POST_R2 = 'POST_R2',
  PRE_R3 = 'PRE_R3',
  R3 = 'R3',           // Réservation
  POST_R3 = 'POST_R3',
  PRE_R4 = 'PRE_R4',
  R4 = 'R4',           // Signature
  POST_R4 = 'POST_R4', // Clôture
}

L'enum est la source de vérité. Si un état n'est pas dans l'enum, il n'existe pas.

Le mapping depuis les systèmes externes

En pratique, les systèmes CRM externes ont leurs propres statuts — souvent 60+ labels humains. On a besoin d'un traducteur :

getStateFromFactoryState(factoryState: string): RecordState {
  if (factoryState.startsWith('(HC)')) return RecordState.R0; // Hard close
  if (factoryState.startsWith('(SS)')) return RecordState.R0; // Sans suite
  if (factoryState.startsWith('(NRP)')) return RecordState.R0; // À recontacter

  const mapping: Record<string, RecordState> = {
    'Nouveau': RecordState.R0,
    '(R1) Planifié': RecordState.R1,
    '(R2) Rendez-vous reçu': RecordState.R2,
    '(R3) Pré-réservé': RecordState.R3,
    '(R4) Réalisé': RecordState.R4,
  };

  return mapping[factoryState] ?? RecordState.R0;
}

Tip : les startsWith gèrent les familles de statuts (tous les refus commencent par (HC)). Le mapping explicite gère les cas individuels. Le fallback retourne toujours l'état initial — jamais undefined.

Les actions déclenchées par transition

Chaque changement d'état déclenche des side effects. On les centralise dans une seule méthode :

async onStateChange(recordId: string, newState: RecordState) {
  const record = await this.recordRepo.findOne(recordId, {
    relations: ['user'],
  });

  switch (newState) {
    case RecordState.R1:
      await this.mailing.sendPreR1Email(record.user);
      break;
    case RecordState.POST_R1:
      await this.mailing.sendPostR1Email(record.user);
      await this.todoService.createDocumentRequest(record);
      break;
    case RecordState.R4:
      await this.mailing.sendPreSignatureEmail(record.user);
      await this.notificationService.notifyAdmin(record);
      break;
  }

  await this.logService.create({
    recordId,
    fromState: record.state,
    toState: newState,
    timestamp: new Date(),
  });
}

Le switch est volontairement simple. Chaque case appelle des services dédiés. Le log de transition est systématique — il alimente le journal d'audit.

Valider les transitions

Tous les changements d'état ne sont pas légitimes. Un dossier en R0 ne peut pas passer directement à R4. On ajoute une validation :

const ALLOWED_TRANSITIONS: Record<RecordState, RecordState[]> = {
  [RecordState.R0]: [RecordState.R1],
  [RecordState.R1]: [RecordState.POST_R1],
  [RecordState.POST_R1]: [RecordState.PRE_R2, RecordState.R0],
  // ...
};

canTransition(from: RecordState, to: RecordState): boolean {
  return ALLOWED_TRANSITIONS[from]?.includes(to) ?? false;
}

Tip : autorisez toujours le retour à l'état initial (R0). Dans la vraie vie, un dossier peut être annulé à n'importe quelle étape.

Pourquoi pas une librairie de state machine ?

On a regardé xstate, typescript-fsm, et d'autres. Pour nos besoins (12-15 états, transitions simples, side effects async), une librairie ajoute de la complexité sans valeur. Un enum + un switch + une table de transitions, c'est lisible par n'importe quel dev en 5 minutes.

Si vous avez des états parallèles, des guards complexes, ou du statechart avec hiérarchie, là oui, xstate vaut le coup.

Le pattern résumé

  1. Un enum pour les états possibles
  2. Un mapping depuis les systèmes externes
  3. Une table de transitions autorisées
  4. Un onStateChange centralisé pour les side effects
  5. Un log systématique de chaque transition

On a appliqué ce pattern sur des dossiers financiers (12 états), des dossiers de création d'entreprise (11 états), des devis de rénovation (10 états), et des rendez-vous médicaux (5 états). Ça scale.

En contexte : on a appliqué ce pattern sur un projet de conseil financier (12 états, 60+ statuts CRM, 18 templates email) et sur un projet legaltech (11 statuts de dossier).

Stack concernée : NestJS, TypeORM/Prisma, PostgreSQL

Prêt à vous lancer ?

La newsletter qu'on n'ignore pas

Abonnez-vous à notre newsletter pour recevoir nos derniers articles, retours d'expérience et conseils tech directement dans votre boîte mail.

Désinscription en un clic. Vos données restent privées.