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(aveccheckoutterminal) - Room status :
available / occupied / cleaning / maintenance / reserved - Room service order :
pending → preparing → delivering → delivered(aveccancelledpossible) - 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àpendingsans 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_statusContrainte 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
| Domaine | Fichier | États |
|---|---|---|
| Check-in | domain/check-in.machine.ts | pending → invited → completed → arrived → checked_out |
| Room service order | domain/room-service.machine.ts | pending → preparing → delivering → delivered (+ cancelled) |
| Laundry order | domain/laundry.machine.ts | pending → collected → washing → ready → delivered |
| Spa booking | domain/spa.machine.ts | requested → confirmed → in_progress → done (+ cancelled) |
| Restaurant booking | domain/restaurant.machine.ts | requested → confirmed → seated → done (+ cancelled, no_show) |
| Reservation (PMS mirror) | domain/reservation.machine.ts | inquiry → confirmed → checked_in → checked_out (+ canceled) |
| Ticket | domain/ticket.machine.ts | open → in_progress → waiting → resolved → closed |
| Room status | domain/room-status.machine.ts | graph 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.tset comprend le domaine en 30s - Types TypeScript inférés de
pgEnum→ une seule source de vérité - Erreur
InvalidTransitionErroravec 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
InvalidTransitionErrorloggé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
pgEnumet la table TypeScript (cible : 0, capté par le test "every value is a valid key")