Qué es una máquina de estados
Una máquina de estados define un conjunto finito de estados, las transiciones permitidas entre ellos y los eventos que activan dichas transiciones. En cualquier momento, el sistema se encuentra en exactamente un estado; solo las transiciones declaradas pueden llevarlo a otro. Todo lo demás queda prohibido por diseño.
Cómo ayudan
- Hacen explícitas las reglas: los estados y las transiciones están escritos en el código en lugar de darse por supuestos, así que los casos límite quedan a la vista en vez de esconderse en condicionales.
- Impiden saltos no permitidos: las transiciones imposibles (por ejemplo, «enviar» antes de «pagado») sencillamente no existen, lo que elimina categorías enteras de errores.
- Mejoran la observabilidad: el estado actual y las transiciones recientes forman una línea de tiempo que se puede registrar, auditar y vigilar con alertas.
- Simplifican las pruebas: cada transición es un pequeño contrato que se puede probar, y los fixtures se centran en los eventos y en el siguiente estado esperado.
- Facilitan los reintentos y la idempotencia: las transiciones se disparan por eventos con precondiciones claras, lo que permite reintentar sin riesgo y descartar duplicados.
- Acotan la concurrencia: con un único estado de referencia y transiciones protegidas, las condiciones de carrera se reducen a puntos de conflicto bien definidos.
- Facilitan la marcha atrás: revertir es simplemente otra transición (hacia un estado anterior o de error) en lugar de retocar flags sobre la marcha.
- Dan un vocabulario común: un diagrama o una tabla compartida de estados y transiciones permite que producto, QA e ingeniería hablen de lo mismo.
Cuándo usar una
- Flujos de trabajo con aprobaciones, pagos, aprovisionamiento o envíos donde el orden importa.
- Procesos de larga duración con reintentos, tiempos de espera (timeouts) y callbacks o webhooks externos.
- Sistemas con muchas rutas de error que necesitan recuperarse de forma ordenada (por ejemplo, pagos, facturación o alta de clientes).
- Procesos con múltiples actores (cliente, back office, proveedor externo) en los que hay que impedir saltos no permitidos.
Directrices de diseño
- Mantenga pocos estados: es mejor un puñado de estados con significado que docenas de microestados. Guarde los detalles como datos o flags.
- Dispare las transiciones con eventos y nómbrelos según hechos del negocio (por ejemplo,
payment_captured,document_approved). - Centralice las condiciones de guarda (guards): cada transición tiene precondiciones y, si no se cumplen, se rechaza con un motivo claro.
- Modele el fallo de forma explícita: añada estados de error finales o ciclos de recuperación, no operaciones que no hacen nada en silencio.
- Separe el estado de los efectos secundarios: calcule primero el siguiente estado y ejecute los efectos (correos, tareas) después de guardarlo.
- Registre las transiciones: incluya el actor, la marca de tiempo, el estado anterior, el siguiente, el evento y el ID de correlación.
- Versione la máquina: si cambia estados o transiciones, versione o migre con cuidado las instancias ya guardadas.
Errores habituales
- Explosión de estados al modelar cada subcaso como un nuevo estado en lugar de datos.
- Transiciones ocultas en condicionales sueltos fuera de la máquina.
- Efectos secundarios dentro de las funciones de transición que pueden aplicarse parcialmente en caso de fallo.
- Omitir la idempotencia: los eventos duplicados deben ignorarse o tratarse como reintentos seguros.
- Ignorar el tiempo: no gestionar los tiempos de espera o caducidades deja elementos bloqueados.
- Falta de dueño: no está claro qué servicio o componente es la fuente de verdad del estado.
Lista de comprobación para implementarla
- Enumere los estados y los eventos, y rechace todo lo que no esté en la lista.
- Defina las transiciones permitidas como una tabla o un grafo y bloquee el resto.
- Especifique las guardas y los efectos de cada transición, y haga que los efectos se puedan reintentar.
- Guarde el estado de forma atómica junto con el evento que lo provocó o su ID de correlación.
- Haga que las transiciones sean idempotentes y gestione duplicados y eventos fuera de orden.
- Exponga el estado y el historial para la observabilidad y las auditorías.
- Añada métricas: recuentos por estado, transiciones correctas y fallidas, y tiempo en cada estado.
Ejemplo de pseudocódigo (flujo de pedidos)
Este ejemplo es independiente del lenguaje y mantiene claras las responsabilidades: define estados/eventos, declara transiciones permitidas con protecciones y efectos secundarios opcionales, y después un pequeño manejador que hace cumplir la tabla.
Estados: CREATED, PAID, PACKING, SHIPPED, FAILED.
Eventos: pay, pack, ship, fail.
Tabla de transiciones (con guardas y efectos secundarios):
machine = {
CREATED: {
pay(event) -> PAID if event.valid_payment
fail(reason) -> FAILED
},
PAID: {
pack(event) -> PACKING
fail(reason) -> FAILED
},
PACKING: {
ship(trackingId) -> SHIPPED with sideEffect(sendEmail(trackingId))
fail(reason) -> FAILED
},
SHIPPED: {},
FAILED: {}
}
Manejador (aplica la tabla y las guardas, guarda el estado y después ejecuta los efectos secundarios):
- Busca la transición permitida para el estado actual y el evento entrante.
- Rechaza el evento si no existe ninguna transición o si falla su guarda.
- Aplica la transición para obtener el siguiente estado y cualquier efecto secundario.
- Guarda el cambio de estado con los datos del evento y luego ejecuta los efectos secundarios.
- Devuelve el nuevo estado.
state = CREATED
function handle(event):
current = state
transition = machine[current][event.type]
if not transition:
return error("illegal transition")
if transition.hasGuard and not transition.guard(event):
return error("guard failed")
nextState, sideEffects = transition.apply(event)
persist(currentState=current, nextState=nextState, event=event)
for effect in sideEffects:
effect.run()
state = nextState
return ok(state)
Notas:
- Las guardas se evalúan antes de cambiar el estado; si una falla, el estado no cambia.
- Guarde el estado (y el evento o ID de correlación) antes de ejecutar los efectos secundarios, para que los reintentos sean seguros.
- Los efectos secundarios deben poder reintentarse o ser idempotentes. Registre cada transición para auditorías y alertas.
- Para añadir tiempos de espera, modélelos como eventos y transiciones (por ejemplo,
timeout -> FAILED).