Bob le développeur
Estimer

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

Bob Le Développeur

07/03/2026 · Mis à jour le 14/10/2026

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 l'état initial pour tout libellé absent du mapping, jamais undefined (seule exception : un libellé qui porte le nom d'une propriété héritée d'un objet JavaScript, comme constructor, renvoie une fonction ; une Map ou un test Object.hasOwn ferme ce cas).

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({
    where: { id: recordId },
    relations: { user: true },
  });
  if (!record) {
    throw new NotFoundException(`Dossier ${recordId} introuvable`);
  }

  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.R0],
  [RecordState.POST_R1]: [RecordState.PRE_R2, RecordState.R0],
  // ...
};

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

// Point d'entrée unique : c'est ici que la table est appliquée et que l'état est enregistré
async changeState(recordId: string, newState: RecordState) {
  const record = await this.recordRepo.findOneBy({ id: recordId });
  if (!record) {
    throw new NotFoundException(`Dossier ${recordId} introuvable`);
  }
  if (record.state === newState) {
    return; // même statut renvoyé deux fois : ni email ni log en double
  }
  if (!this.canTransition(record.state, newState)) {
    throw new ConflictException(
      `Transition ${record.state} vers ${newState} refusée pour le dossier ${recordId}`,
    );
  }

  // Side effects et log d'abord (le dossier porte encore l'ancien état), puis enregistrement
  await this.onStateChange(recordId, newState);
  await this.recordRepo.update({ id: recordId }, { state: newState });
}

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 (12 statuts de dossier).

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

Ce contenu vous a servi ? Ajoutez Bob le développeur à vos sources préférées sur Google.

Renfort

Besoin de renfort dans votre équipe ?

Des développeurs React, Next.js et Node.js seniors, intégrés à vos sprints et à vos revues de code.

  • Démarrage en quelques jours
  • Engagement au mois

30 min, gratuit, sans engagement.

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.