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

07/03/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 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é
- Un enum pour les états possibles
- Un mapping depuis les systèmes externes
- Une table de transitions autorisées
- Un
onStateChangecentralisé pour les side effects - 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
