My App

ADR-05 — State machines pour les transitions métier

Fonctions pures validées côté DB et API pour les transitions check-in, booking, reservation

Statut : Accepté Date : 2026-04 Sujet : Comment on représente et on valide les transitions de statut dans le domaine métier

Contexte

Bell a plusieurs entités avec des cycles de vie complexes :

  • Guest check-in : pending → invited → completed → arrived (avec checkout terminal)
  • Room status : available / occupied / cleaning / maintenance / reserved
  • Room service order : pending → preparing → delivering → delivered (avec cancelled possible)
  • Laundry order : pending → collected → washing → ready → delivered
  • Reservation PMS (Mews) : inquiry / confirmed / checked_in / checked_out / canceled
  • Ticket : open → in_progress → waiting → resolved → closed

Dans l'ancien projet :

  • Les statuts étaient des strings libres en DB (pas de pgEnum)
  • Les transitions étaient inline dans les services (if (status === "completed") status = "arrived")
  • Aucune validation : on pouvait passer d'arrived à pending sans erreur
  • Impossible de visualiser le cycle de vie → nouveaux devs perdus

Alternatives considérées

Option 1 — Strings + validation ad-hoc (ancien projet)

Statut quo : chaque service vérifie (ou pas) avant d'écrire.

Contre : erreurs silencieuses, duplication, impossible à auditer.

Option 2 — XState runtime complet

Bibliothèque state machines avec actors, invoke, context.

Pour :

  • Puissant, bien pensé
  • Visualiseur online

Contre :

  • Overkill pour nos transitions simples (4-5 états, transitions linéaires)
  • Ajoute une dépendance runtime non-triviale (~30 KB)
  • Courbe d'apprentissage pour les nouveaux devs
  • XState est brillant pour des UI complexes — nos cas sont des machines serveur simples

Option 3 — Tables de transitions pures + pgEnum (retenu)

Une fonction pure par domaine, une table TypeScript typée des transitions autorisées, un pgEnum Postgres pour la contrainte DB, et un check à chaque mutation API.

Décision

pgEnum côté DB

// packages/db/src/schema/enums.ts
import { pgEnum } from "drizzle-orm/pg-core";

export const checkInStatusEnum = pgEnum("check_in_status", [
  "pending",
  "invited",
  "completed",
  "arrived",
  "checked_out",
]);

export const roomStatusEnum = pgEnum("room_status", [
  "available",
  "occupied",
  "cleaning",
  "maintenance",
  "reserved",
]);

export const roomServiceOrderStatusEnum = pgEnum("room_service_order_status", [
  "pending", "preparing", "delivering", "delivered", "cancelled",
]);

// ... idem pour laundry_order_status, reservation_state, ticket_status

Contrainte DB : impossible d'insérer une valeur hors de l'enum.

Machine de transition typée

Un module par domaine dans packages/api/src/domain/ :

// packages/api/src/domain/check-in.machine.ts
export const checkInTransitions = {
  pending:     ["invited"],
  invited:     ["completed", "pending"],        // staff peut annuler l'invite
  completed:   ["arrived"],
  arrived:     ["checked_out"],
  checked_out: [],                               // terminal
} as const satisfies Record<CheckInStatus, readonly CheckInStatus[]>;

export function canTransitionCheckIn(from: CheckInStatus, to: CheckInStatus): boolean {
  return checkInTransitions[from].includes(to);
}

export class InvalidTransitionError extends Error {
  constructor(public from: string, public to: string, public domain: string) {
    super(`Invalid ${domain} transition: ${from} → ${to}`);
  }
}

export function assertTransitionCheckIn(from: CheckInStatus, to: CheckInStatus): void {
  if (!canTransitionCheckIn(from, to)) {
    throw new InvalidTransitionError(from, to, "check-in");
  }
}

Appel dans le service

// packages/api/src/modules/cardex/cardex.service.ts
export async function confirmGuestArrival(orgId: string, guestId: string) {
  return await db.transaction(async (tx) => {
    const [current] = await tx.select({ status: guest.checkInStatus })
      .from(guest).where(and(eq(guest.id, guestId), eq(guest.organizationId, orgId)));

    if (!current) throw new NotFoundError("guest");

    assertTransitionCheckIn(current.status, "arrived");

    await tx.update(guest).set({
      checkInStatus: "arrived",
      checkInArrivedAt: new Date(),
    }).where(eq(guest.id, guestId));
    // ... update room, enqueue bridge, publish SSE
  });
}

Chaque mutation passe par assertTransition* avant d'écrire. Si invalide → exception 400, jamais d'écriture en DB incohérente.

Domaines couverts dès le jour 1

DomaineFichierÉtats
Check-indomain/check-in.machine.tspending → invited → completed → arrived → checked_out
Room service orderdomain/room-service.machine.tspending → preparing → delivering → delivered (+ cancelled)
Laundry orderdomain/laundry.machine.tspending → collected → washing → ready → delivered
Spa bookingdomain/spa.machine.tsrequested → confirmed → in_progress → done (+ cancelled)
Restaurant bookingdomain/restaurant.machine.tsrequested → confirmed → seated → done (+ cancelled, no_show)
Reservation (PMS mirror)domain/reservation.machine.tsinquiry → confirmed → checked_in → checked_out (+ canceled)
Ticketdomain/ticket.machine.tsopen → in_progress → waiting → resolved → closed
Room statusdomain/room-status.machine.tsgraph libre (voir fichier, plusieurs transitions possibles)

Tests obligatoires

Voir ADR-06 pour le détail.

Chaque machine a un test file avec la matrice exhaustive des transitions :

// packages/api/src/domain/check-in.machine.test.ts
test("should allow pending → invited", () => {
  expect(canTransitionCheckIn("pending", "invited")).toBe(true);
});

test("should forbid arrived → pending", () => {
  expect(canTransitionCheckIn("arrived", "pending")).toBe(false);
});

// Test que la table couvre tous les pgEnum values
test("every checkInStatus value is a valid machine key", () => {
  for (const status of checkInStatusEnum.enumValues) {
    expect(checkInTransitions[status]).toBeDefined();
  }
});

Pourquoi pas XState

XState serait justifié si on avait :

  • États parallèles (un objet dans plusieurs états simultanés)
  • Hiérarchie d'états (composite states)
  • Guards complexes contextuels
  • Invoke d'acteurs async dans la machine

On a aucun de ces besoins. Nos machines sont des graphes simples, fonctions pures, testables en < 1 ms. Si demain un domaine devient assez complexe pour le justifier, on peut migrer ce domaine spécifique vers XState sans toucher aux autres.

Conséquences

Positives :

  • Zéro dépendance runtime, juste du TypeScript pur
  • Tests unitaires triviaux (matrice de booleans)
  • Contrainte DB (pgEnum) + contrainte app (assertTransition) = deux filets
  • Visible et auditable — un nouveau dev lit le fichier *.machine.ts et comprend le domaine en 30s
  • Types TypeScript inférés de pgEnum → une seule source de vérité
  • Erreur InvalidTransitionError avec contexte utile pour le debug

Négatives :

  • Pas d'outil de visualisation gratos (on pourrait générer un Mermaid depuis la table si besoin)
  • Discipline à tenir : toujours passer par assertTransition*, jamais d'update direct du statut

Métriques à surveiller

  • Nombre de InvalidTransitionError loggées (cible : 0 en prod stable, >0 signale un bug UI ou une race condition)
  • Couverture de test des matrices de transition (cible : 100 %)
  • Dérives entre pgEnum et la table TypeScript (cible : 0, capté par le test "every value is a valid key")

On this page