Módulo 3: Patrones para el comportamiento que varía
5. State: cuando el objeto se comporta según su estado
Descripción
Al terminar esta lección vas a saber reconocer el tercer miembro de esta familia y el más distinto de los tres. En Strategy elige quien llama; en Template Method elige quien construye el objeto; en State elige el objeto mismo, y cambia de opinión sobre la marcha como consecuencia de lo que le va pasando.
Vas a trabajarlo sobre el Order de Boletia, que se comporta distinto según esté pendiente, pagada, cancelada o reembolsada. Vas a salir con tres cosas. Primero, la habilidad de reconocer el síntoma que anuncia este patrón: el mismo condicional sobre un campo de estado, repetido en cuatro o cinco métodos del mismo objeto, con banderas sueltas que se contradicen entre sí. Segundo, la transformación: de ese enredo a un conjunto de transiciones explícitas, donde lo que se puede y no se puede hacer en cada momento está escrito en un solo lugar y se puede leer sin arqueología. Y tercero —y esto es lo que hace honesta a la lección— el punto intermedio, porque State completo con una clase por estado es exagerado la mayoría de las veces, y hay una solución de quince líneas que resuelve el 80% de los casos.
Esto importa porque los bugs de estado son de los más caros que existen. No fallan al momento: dejan un dato inconsistente que revienta tres días después, en otro módulo, con un mensaje que no tiene nada que ver. Una orden que quedó "cancelada" pero con la bandera de pagada en verdadero es un boleto emitido sin cobrar, y eso se descubre en la conciliación de fin de mes.
Conexión con el módulo: cierras aquí la familia del comportamiento variable. La lección 2 te dio Strategy —varias formas de hacer lo mismo, elegidas desde fuera—, la 3 la aplicó al caso grande de Boletia, y la 4 trajo Template Method para cuando lo que varía son pasos dentro de un orden fijo. State completa el mapa con el caso donde el control lo tiene el objeto. La lección 6 va a preguntar si toda esta maquinaria de clases hacía falta en Python, y la 7 te va a recordar que la mayoría de los condicionales que veas no son ninguno de los tres. El proyecto de la lección 8 tiene condicionales de estado dentro del checkout, y vas a tener que decidir qué hacer con ellos.
El paquete de mensajería
Pediste algo por internet y estás siguiendo el paquete. En la pantalla de la app hay botones, y los botones cambian solos.
Mientras el paquete está en preparación, puedes cancelar el pedido y puedes cambiar la dirección de entrega. Cuando pasa a en tránsito, el botón de cancelar desaparece —ya salió del almacén— pero aparece uno nuevo: redirigir a un punto de recogida. Cuando llega a entregado, se van los dos y aparece "iniciar devolución". Y si el paquete se marca como devuelto, no queda ninguno: solo la información.
Fíjate en tres cosas de esa experiencia, porque son las tres piezas de la lección.
Primera: el paquete es el mismo objeto todo el tiempo. No te dieron un paquete distinto al pasar a "en tránsito". Es el mismo, con el mismo número de guía y el mismo contenido. Lo que cambió fue qué acepta hacer.
Segunda: tú no eliges el estado. No hay un botón que diga "ponlo en tránsito". El estado cambia como consecuencia de operaciones reales: alguien escaneó el paquete al salir del almacén, alguien lo entregó. Esta es la diferencia grande con Strategy, donde tú eliges la implementación antes de llamar.
Tercera: no todas las transiciones son legales, y las ilegales no se avisan: no existen. No es que la app te deje pulsar "cancelar" en un paquete entregado y luego te muestre un error. Es que el botón no está. La ilegalidad está modelada en la interfaz, no atrapada con una validación al final.
Esa tercera observación es la que separa un código de estados bueno de uno malo. En el malo, todas las operaciones están siempre disponibles y cada una empieza con un if que averigua si en este momento tiene permiso. En el bueno, cada estado sabe qué se puede hacer desde él, y el resto no está.
Ejemplo trabajado: el Order de Boletia
Este es models/order.py como está hoy. Lo escribió alguien con prisa, dos veces, con seis meses de diferencia.
# Archivo: models/order.py
# ADVERTENCIA: versión ANTES del refactor.
class Order:
def __init__(self, id, customer_id, ticket_ids, total):
self.id = id
self.customer_id = customer_id
self.ticket_ids = ticket_ids
self.total = total
self.status = "pending" # "pending" | "paid" | "cancelled" | "refunded"
self.is_paid = False # bandera que duplica parte del status
self.refund_requested = False # otra bandera
self.paid_at = None
self.cancelled_at = None
def mark_paid(self, external_id):
if self.status == "cancelled":
raise InvalidOperation("No se puede pagar una orden cancelada")
if self.status == "refunded":
raise InvalidOperation("No se puede pagar una orden reembolsada")
# Si ya estaba pagada no hacemos nada… ¿o sí? Nadie recuerda por qué.
self.status = "paid"
self.is_paid = True
self.paid_at = now()
self.external_payment_id = external_id
def cancel(self, reason):
if self.status == "paid":
raise InvalidOperation("Una orden pagada se reembolsa, no se cancela")
if self.status == "refunded":
raise InvalidOperation("Ya fue reembolsada")
self.status = "cancelled"
self.cancelled_at = now()
self.cancel_reason = reason
def refund(self, amount):
if self.status != "paid":
raise InvalidOperation("Solo se reembolsa una orden pagada")
if amount > self.total:
raise InvalidOperation("El reembolso supera el total")
self.status = "refunded"
self.refund_requested = True
# Ojo: is_paid se queda en True. ¿Es un bug o es a propósito?
self.refunded_amount = amount
def can_be_edited(self):
# Solo se pueden cambiar los boletos antes de pagar.
return self.status == "pending"
def confirmation_message(self):
if self.status == "pending":
return "Tu orden está pendiente de pago"
elif self.status == "paid":
return "¡Listo! Tus boletos están confirmados"
elif self.status == "cancelled":
return "Tu orden fue cancelada"
elif self.status == "refunded":
return "Te reembolsamos tu compra"
return "Estado desconocido"
def is_active(self):
return self.status in ("pending", "paid")
Este código funciona. Y sin embargo tiene cuatro problemas, y vale la pena mirarlos uno por uno porque son los que el patrón resuelve.
Problema 1: el mismo condicional en cinco lugares. self.status se compara en mark_paid, en cancel, en refund, en can_be_edited, en confirmation_message y en is_active. Seis, contando bien. Agregar un estado nuevo —"parcialmente reembolsada", que Boletia va a necesitar en cuanto venda paquetes— obliga a revisar los seis y a acertar en todos. Si te olvidas de uno, no falla: devuelve algo incorrecto en silencio, que es peor.
Problema 2: las reglas de transición no están escritas en ningún lado. ¿Se puede pagar una orden ya pagada? Léelo otra vez: mark_paid no lo prohíbe, así que sí se puede, y el resultado es que se pisa paid_at y external_payment_id. ¿Fue una decisión o un olvido? Nadie lo sabe. Para reconstruir el conjunto de transiciones legales hay que leer los tres métodos y armar la tabla mentalmente. Esa tabla existe —es una regla de negocio— pero vive repartida en las guardas de cada método.
Problema 3: hay estados imposibles que el código permite representar. status es un texto y is_paid es un booleano, y nada garantiza que digan lo mismo. Después de refund(), la orden queda con status="refunded" e is_paid=True. ¿Está pagada o no? Depende de a quién le preguntes, y distintas partes del sistema preguntan a distintos campos. Este es el error más caro de todos y tiene un nombre que conviene aprender: estados ilegales representables. La regla que se deriva de él es una de las más útiles del oficio: si dos campos siempre tienen que estar de acuerdo, hazlos uno solo.
Problema 4: mezcla decidir con hacer. mark_paid valida si puede, y además ejecuta el cambio. Las dos cosas en el mismo bloque, sin separación. Es la tercera señal que la lección 1 te dio para reconocer un eje de variación que pide extracción.
Paso 1: hacer explícita la tabla de transiciones.
Antes de escribir una clase, escribe la tabla. Es un ejercicio de cinco minutos que casi nadie hace y que cambia todo lo demás:
| Estado actual | mark_paid | cancel | refund | ¿editable? |
|---|---|---|---|---|
| pending | → paid | → cancelled | ✗ | Sí |
| paid | ✗ | ✗ (se reembolsa) | → refunded | No |
| cancelled | ✗ | ✗ | ✗ | No |
| refunded | ✗ | ✗ | ✗ | No |
Mira lo que acaba de pasar. Esa tabla es la regla de negocio, completa, en doce celdas. Se puede enseñar a alguien de Producto y va a entenderla. Se puede pegar en el ticket. Y responde de una la pregunta que el código dejaba abierta: pagar una orden ya pagada no es legal, así que lo de antes era un bug.
Fíjate también en la forma de la tabla: cancelled y refunded no tienen ninguna salida. Son estados finales. Eso es información de diseño que en el código original no se veía por ningún lado.
Paso 2: un objeto por estado.
# Archivo: models/order_state.py
from typing import Protocol
class OrderState(Protocol):
"""Un estado de la orden: sabe qué se puede hacer DESDE él.
Cada operación devuelve el estado siguiente. Si no es legal, levanta
InvalidOperation. Los estados no guardan datos de la orden: solo reglas.
"""
name: str
def mark_paid(self, order) -> "OrderState": ...
def cancel(self, order, reason) -> "OrderState": ...
def refund(self, order, amount) -> "OrderState": ...
@property
def can_be_edited(self) -> bool: ...
@property
def message(self) -> str: ...
class _BaseState:
"""Comportamiento por defecto: TODO está prohibido.
Cada estado concreto abre solo las puertas que le corresponden. Así,
olvidarse de una transición produce un error claro, no un permiso.
"""
def mark_paid(self, order):
raise InvalidOperation(f"No se puede pagar una orden {self.name}")
def cancel(self, order, reason):
raise InvalidOperation(f"No se puede cancelar una orden {self.name}")
def refund(self, order, amount):
raise InvalidOperation(f"No se puede reembolsar una orden {self.name}")
@property
def can_be_edited(self) -> bool:
return False
Detente en _BaseState, porque es la decisión de diseño más importante del refactor: el valor por defecto es prohibir. Cada estado concreto abre únicamente lo suyo. Con esa elección, olvidarse de definir una transición produce un error explícito en vez de un permiso accidental, que es exactamente lo contrario de lo que pasaba en el código original.
# Archivo: models/order_state.py (continúa)
class PendingState(_BaseState):
name = "pending"
def mark_paid(self, order):
order.paid_at = now()
return PaidState()
def cancel(self, order, reason):
order.cancelled_at = now()
order.cancel_reason = reason
return CancelledState()
@property
def can_be_edited(self) -> bool:
# Se pueden cambiar los boletos mientras no se haya cobrado.
return True
@property
def message(self) -> str:
return "Tu orden está pendiente de pago"
class PaidState(_BaseState):
name = "paid"
def refund(self, order, amount):
if amount > order.total:
raise InvalidOperation("El reembolso supera el total de la orden")
order.refunded_amount = amount
return RefundedState()
@property
def message(self) -> str:
return "¡Listo! Tus boletos están confirmados"
class CancelledState(_BaseState):
name = "cancelled"
@property
def message(self) -> str:
return "Tu orden fue cancelada"
class RefundedState(_BaseState):
name = "refunded"
@property
def message(self) -> str:
return "Te reembolsamos tu compra"
Lee CancelledState y RefundedState. Son cuatro líneas cada una. No definen ninguna transición, y eso no es que estén incompletas: es que son estados finales, y ahora esa cualidad se ve de un vistazo en vez de deducirse de la ausencia de un elif en otro archivo.
Paso 3: la orden delega.
# Archivo: models/order.py
from models.order_state import PendingState
class Order:
def __init__(self, id, customer_id, ticket_ids, total):
self.id = id
self.customer_id = customer_id
self.ticket_ids = ticket_ids
self.total = total
self._state = PendingState() # el estado ES un objeto, no un texto
self.paid_at = None
self.cancelled_at = None
# Se fueron is_paid y refund_requested: eran duplicados del estado
# y podían contradecirlo.
# --- Operaciones: la orden no decide, delega y guarda el resultado ---
def mark_paid(self, external_id):
self._state = self._state.mark_paid(self)
self.external_payment_id = external_id
def cancel(self, reason):
self._state = self._state.cancel(self, reason)
def refund(self, amount):
self._state = self._state.refund(self, amount)
# --- Consultas: también delegan ---
@property
def status(self) -> str:
# Se conserva `status` como texto para no romper lo que ya lo lee
# (la base de datos, los reportes, la API). Es de solo lectura.
return self._state.name
@property
def can_be_edited(self) -> bool:
return self._state.can_be_edited
@property
def confirmation_message(self) -> str:
return self._state.message
def is_active(self) -> bool:
return self.status in ("pending", "paid")
Tres detalles que valen la pena.
status sigue existiendo, pero ahora es de solo lectura y se calcula. La base de datos, los reportes y la API HTTP siguen viendo el mismo texto de siempre, así que el refactor no obliga a tocar nada de eso. Pero ya nadie puede asignarle un valor a mano, y esa es justamente la puerta por la que entraban las inconsistencias.
Las banderas is_paid y refund_requested desaparecieron. No se arreglaron: se borraron, porque eran copias parciales del estado que podían contradecirlo. El estado ilegal que representaban ya no es representable.
Y is_active se quedó como estaba. Podría haber sido una propiedad de cada estado, y quizás lo sea el día que la regla se complique. Hoy es una pregunta sobre dos nombres y moverla no habría ganado nada. No hace falta que todo pase por el patrón.
Qué esperar. Vamos a correr el ciclo de vida completo:
order = Order(id=8812, customer_id=5, ticket_ids=[1, 2], total=1080.0)
print(order.status, order.can_be_edited)
# pending True
order.mark_paid(external_id="ch_3Nq...")
print(order.status, order.can_be_edited)
# paid False ← el mismo objeto, otro comportamiento
print(order.confirmation_message)
# ¡Listo! Tus boletos están confirmados
# Ahora una transición ilegal:
order.cancel(reason="me arrepentí")
# InvalidOperation: No se puede cancelar una orden paid
order.refund(amount=1080.0)
print(order.status)
# refunded
order.refund(amount=1080.0)
# InvalidOperation: No se puede reembolsar una orden refunded
Fíjate en el segundo bloque, que es el corazón del patrón: el mismo objeto, la misma línea de código, comportamiento distinto. order.can_be_edited pasó de True a False sin que nadie lo asignara. Cambió porque el objeto cambió de estado, y el estado cambió como consecuencia de una operación real.
Y fíjate en el intento de cancelar una orden pagada. El error viene de _BaseState, es decir, de no haber definido nada. Esa es la garantía que el diseño te da: lo que no está explícitamente permitido, está prohibido. En el código original era al revés —lo que no estaba explícitamente prohibido, estaba permitido— y por eso se podía pagar dos veces la misma orden.
Ahora la prueba de crecimiento. Boletia necesita "parcialmente reembolsada": una orden a la que se le devolvió parte, que puede recibir más reembolsos hasta agotar el total, y que sigue teniendo boletos válidos. ¿Qué se toca?
models/order_state.py ← una clase nueva, PartiallyRefundedState
+ una línea en PaidState.refund (decidir a cuál ir)
order.py no se abre. Los otros estados no se abren. Compara con la situación original, donde ese estado nuevo obligaba a revisar los seis condicionales y a acertar en todos.
Y el recibo, que aquí también hay que pasarlo: pasaste de un archivo a dos, y de cuatro comparaciones de texto a cinco clases. Para saber qué pasa al llamar order.cancel() hay que abrir order.py, ver que delega, y saltar al estado correspondiente. Es un salto más. A cambio, la tabla de transiciones dejó de ser folclore.
Anatomía de State y su diferencia con Strategy
Las piezas son tres, y son las mismas de Strategy con los papeles cambiados:
El contrato del estado. Qué operaciones existen y qué devuelven. Aquí, mark_paid, cancel y refund devolviendo el estado siguiente, más las consultas.
Un objeto por estado. Cada uno define solo lo que es legal desde él, y hereda de una base que prohíbe todo lo demás.
El objeto dueño del estado —Order—, que guarda cuál es el actual, delega, y guarda el resultado. Esa última parte es la clave: self._state = self._state.cancel(...). Sin esa reasignación no hay máquina de estados, hay una Strategy con nombres bonitos.
Ahora la distinción que confunde a todo el mundo. Estructuralmente, State y Strategy son casi idénticos: un contrato, varias implementaciones, un objeto que delega. Si te enseñaran los dos diagramas sin etiquetas, no podrías distinguirlos. La diferencia no está en la forma; está en tres cosas:
| Strategy | State | |
|---|---|---|
| Quién elige la implementación | Quien llama, desde fuera | El propio objeto, desde dentro |
| Cuándo cambia | Cuando quien llama decide cambiarla | Como consecuencia de una operación |
| ¿Las implementaciones se conocen entre sí? | No, y no deben | Sí: cada estado nombra a sus sucesores |
| Qué modela | Varias formas de hacer lo mismo | Las etapas de un ciclo de vida |
La tercera fila es la que más se subestima y la que más duele en la práctica. En una Strategy bien hecha, StripeProvider no menciona a CashProvider: son independientes y puedes borrar una sin tocar la otra. En State, PendingState importa y construye PaidState y CancelledState. Los estados forman un grafo y están acoplados a propósito, porque el grafo es la regla de negocio.
Ese acoplamiento tiene un costo concreto que conviene anticipar: cuando el ciclo de vida se complica, los estados se referencian en círculos y la lectura se vuelve un salto continuo entre archivos. Es la razón principal por la que la siguiente sección existe.
Una regla de bolsillo para no confundirlos nunca más: si quien llama elige, es Strategy; si el objeto cambia solo, es State. Y si estás dudando porque tu caso hace las dos cosas, probablemente sea Strategy con un poco de estado, no State.
Cuándo es exagerado: la tabla de transiciones
Aquí viene la parte honesta, y es la más importante de la lección: la mayoría de las máquinas de estados de un sistema real no necesitan una clase por estado. Necesitan una tabla.
Vuelve a mirar la tabla del paso 1. Doce celdas que dicen todo. Se puede escribir en código directamente:
# Archivo: models/order_states.py — la versión de quince líneas
TRANSITIONS = {
# estado actual → { operación: estado siguiente }
"pending": {"pay": "paid", "cancel": "cancelled"},
"paid": {"refund": "refunded"},
"cancelled": {}, # final
"refunded": {}, # final
}
EDITABLE = {"pending"}
MESSAGES = {
"pending": "Tu orden está pendiente de pago",
"paid": "¡Listo! Tus boletos están confirmados",
"cancelled": "Tu orden fue cancelada",
"refunded": "Te reembolsamos tu compra",
}
def next_state(current: str, operation: str) -> str:
"""Aplica una transición. Si no es legal, falla explícitamente."""
allowed = TRANSITIONS[current]
if operation not in allowed:
raise InvalidOperation(f"No se puede '{operation}' una orden {current}")
return allowed[operation]
# Archivo: models/order.py
class Order:
def mark_paid(self, external_id):
self.status = next_state(self.status, "pay")
self.paid_at = now()
self.external_payment_id = external_id
def cancel(self, reason):
self.status = next_state(self.status, "cancel")
self.cancelled_at = now()
self.cancel_reason = reason
@property
def can_be_edited(self) -> bool:
return self.status in EDITABLE
@property
def confirmation_message(self) -> str:
return MESSAGES[self.status]
Quince líneas de tabla y una función de cuatro. Y consigue casi todo lo que consiguió el State completo: las transiciones están en un solo lugar, se leen de un vistazo, lo no permitido está prohibido por defecto, agregar un estado es agregar una fila, y desaparecieron las banderas contradictorias.
Compáralas con honestidad:
| Tabla de transiciones | State con clases | |
|---|---|---|
| Líneas de código | ~20 | ~100 |
| Archivos | 1 | 2 |
| Ver todas las transiciones juntas | De un vistazo | Hay que abrir cada clase |
| Agregar un estado | Una fila | Una clase |
| Comportamiento propio por estado | Solo por tablas paralelas | Natural: métodos en la clase |
| Datos propios por estado | No cabe bien | Natural: atributos |
| Herramientas para dibujar el grafo | Fácil: la tabla es un dato | Difícil: hay que leer código |
Las dos filas del medio deciden. Si cada estado solo cambia si una operación está permitida y qué texto se muestra, usa la tabla. Diccionarios paralelos —EDITABLE, MESSAGES— resuelven eso perfectamente y son más fáciles de leer que cuatro clases.
Cambia a clases cuando cada estado empieza a tener comportamiento de verdad. Señales concretas de que llegó ese momento:
- Un estado necesita datos propios que los otros no tienen.
PartiallyRefundedStatenecesita cuánto se devolvió y cuánto queda; ponerlo enOrdersignifica un campo que la mitad del tiempo esNone. - Una transición tiene lógica, no solo destino.
PaidState.refundvalida el monto y decide si el destino es "reembolsada" o "parcialmente reembolsada" según cuánto se devuelva. Eso no cabe en una celda de tabla. - Un estado necesita efectos al entrar o al salir: mandar un correo al pasar a pagada, liberar los asientos al cancelar.
- Las tablas paralelas se multiplican. Si ya tienes
EDITABLE,MESSAGES,REFUNDABLE,SHOWS_IN_DASHBOARDySENDS_REMINDER, esas cinco tablas eran los métodos de una clase, escritos al revés.
Esa última señal es un buen detector general y funciona en muchos contextos: cuando varios diccionarios comparten exactamente las mismas llaves, esas llaves querían ser objetos.
Y el orden en que conviene hacer las cosas, que es lo que te va a servir de verdad: empieza por la tabla. Es barata, reversible y resuelve la mayoría de los casos. Cuando las señales de arriba aparezcan —y quizás no aparezcan nunca—, migrar de la tabla a las clases es un refactor pequeño, porque la tabla ya te obligó a escribir el grafo completo. Empezar por las clases "por si acaso" es la abstracción prematura del módulo 2, con máquina de estados.
Errores comunes
Guardar el estado en dos lugares (de implementación). Qué pasa: existe status como texto y además banderas como is_paid o refund_requested, que son proyecciones del mismo hecho. Cada operación tiene que acordarse de actualizar las dos, y tarde o temprano alguna ruta las deja en desacuerdo. El bug resultante es de los peores de diagnosticar, porque el objeto se ve bien desde un lado y mal desde el otro. Por qué pasa: las banderas se agregan de una en una, cada una por una necesidad legítima —"necesito saber rápido si está pagada"— y nadie ve el conjunto. Cómo detectarlo: si puedes escribir una combinación de campos que no corresponde a ningún estado real de tu negocio —status="cancelled" con is_paid=True—, tienes estados ilegales representables. Cómo corregirlo: una sola fuente de verdad, y todo lo demás calculado a partir de ella. is_paid no es un campo: es self.status == "paid". La regla vale para todo tu trabajo futuro: si dos campos siempre tienen que estar de acuerdo, hazlos uno solo.
Que el estado por defecto sea permitir (de diseño). Qué pasa: se escribe cada transición con guardas que prohíben los casos malos conocidos —"si está cancelada, no"— en vez de declarar lo que sí se puede. Funciona hasta que aparece el quinto estado, y entonces todas las transiciones que nadie actualizó lo aceptan en silencio. Por qué pasa: escribimos las guardas reaccionando a bugs concretos, y cada guarda tapa exactamente el bug que la motivó. La lista de prohibiciones nunca está completa; la de permisos sí. Cómo detectarlo: si tus validaciones son una lista de if status == X: raise, estás enumerando lo prohibido. Cómo corregirlo: invierte el sentido. Una base que prohíba todo y estados que abran solo lo suyo —o una tabla donde lo que no está listado no existe—. La ausencia debe significar "no", nunca "sí".
Meter State donde solo había un if de dos ramas (de criterio). Qué pasa: alguien termina esta lección, ve un objeto con un campo is_active y monta ActiveState e InactiveState con su contrato y su base. Ahora para saber si algo está activo hay que abrir tres archivos, y el ciclo de vida completo son dos estados que nunca van a ser tres. Por qué pasa: el patrón se enseña con ejemplos de cuatro estados y comportamiento rico, y es fácil llevarse la forma sin el criterio de cuándo aplica. Cómo detectarlo: cuenta estados y cuenta operaciones. Con dos estados, o con estados que no tienen comportamiento propio más allá de permitir o no, la respuesta es la tabla —o ni siquiera: un booleano y dos if—. Cómo corregirlo: la escalera completa, de menor a mayor: un booleano → un texto con una tabla de transiciones → una clase por estado. Sube un peldaño solo cuando el actual te quede chico, y nunca dos de golpe.
Ejercicios
Ejercicio 1 — Escribe la tabla antes que el código. Boletia va a manejar el ciclo de vida de un evento: draft (el organizador lo está armando), published (a la venta), sold_out, cancelled y finished. Las reglas: de borrador se publica; un evento publicado puede agotarse, cancelarse o terminar; uno agotado puede volver a estar publicado si alguien reembolsa un boleto, y también puede cancelarse o terminar; cancelado y terminado son finales. Escribe la tabla de transiciones y responde: ¿cuántos estados finales hay?, ¿hay algún estado al que se pueda volver?
Ver solución
| Estado actual | publish | sell_out | release_ticket | cancel | finish |
|---|---|---|---|---|---|
| draft | → published | ✗ | ✗ | → cancelled | ✗ |
| published | ✗ | → sold_out | ✗ | → cancelled | → finished |
| sold_out | ✗ | ✗ | → published | → cancelled | → finished |
| cancelled | ✗ | ✗ | ✗ | ✗ | ✗ |
| finished | ✗ | ✗ | ✗ | ✗ | ✗ |
Dos estados finales: cancelled y finished.
Sí hay un estado al que se puede volver: published. El par published ⇄ sold_out es un ciclo, y esa es la observación que hace interesante el ejercicio. Un ciclo significa que el estado no es una etapa monótona de un avance, y eso tiene consecuencias concretas: no puedes asumir que el orden de los estados es el orden del tiempo, no puedes ordenar reportes por estado como si fuera una progresión, y cualquier efecto que dispares al entrar a published —mandar un correo de "¡ya está a la venta!"— se va a disparar dos veces. Ese último detalle es un bug clásico y lo encuentras antes de escribir código, solo por haber dibujado la tabla.
Y una observación de diseño que la tabla revela: cancel es legal desde tres estados distintos. Cuando una operación es legal desde casi todos lados, conviene preguntarse si de verdad depende del estado o si es una operación transversal. Aquí sí depende —no se puede cancelar un evento ya terminado— pero la pregunta vale la pena hacerla.
Por qué funciona: escribir la tabla antes que el código es el hábito que esta lección más quiere dejarte. Cuesta cinco minutos, se puede revisar con alguien de Producto, y encuentra huecos —como el correo duplicado— que en código habrían tardado meses en aparecer.
Ejercicio 2 — Implementa la tabla y una transición con lógica. Toma la tabla del ejercicio 1 e impleméntala con el enfoque de diccionario. Después responde: el organizador pide que al cancelar un evento publicado se reembolsen automáticamente todas las órdenes, pero al cancelar uno en borrador no pase nada (no hay órdenes). ¿Cabe eso en la tabla? ¿Qué harías?
Ver solución
# Archivo: models/event_states.py
TRANSITIONS = {
"draft": {"publish": "published", "cancel": "cancelled"},
"published": {"sell_out": "sold_out", "cancel": "cancelled",
"finish": "finished"},
"sold_out": {"release_ticket": "published", "cancel": "cancelled",
"finish": "finished"},
"cancelled": {},
"finished": {},
}
def next_state(current: str, operation: str) -> str:
allowed = TRANSITIONS[current]
if operation not in allowed:
raise InvalidOperation(f"No se puede '{operation}' un evento {current}")
return allowed[operation]
¿Cabe el reembolso automático en la tabla? No directamente. La tabla dice a dónde se va, no qué pasa en el camino. Lo que el organizador pide es un efecto que depende del estado de origen, y eso es una celda con lógica.
Qué haría, en orden de menor a mayor.
Lo primero, y probablemente suficiente: dejar la tabla como está y poner el efecto en el método de la entidad, con un condicional de dos ramas.
def cancel(self, reason):
was_published = self.status in ("published", "sold_out")
self.status = next_state(self.status, "cancel")
self.cancel_reason = reason
if was_published:
# Solo hay órdenes que reembolsar si el evento llegó a estar a la venta.
refund_all_orders(self.id)
Sí, es un if. Y está bien, por las razones de la lección 7: dos ramas, no va a crecer —o hay órdenes o no las hay— y no se repite en ningún otro lado. Meter una clase por estado para evitar este condicional sería cambiar tres líneas legibles por dos archivos.
Lo segundo, si el efecto crece: agregar una tabla paralela de efectos, ON_CANCEL = {"published": refund_all_orders, "sold_out": refund_all_orders}, y que cancel la consulte. Sigue siendo datos y sigue siendo barato.
Lo tercero, y solo si aparecen más efectos por estado: ahí sí, clases. Cuando ON_CANCEL, ON_PUBLISH y ON_FINISH conviven con las mismas llaves, esas tres tablas eran los métodos de tres clases escritos al revés.
Por qué funciona: te obliga a resistir el reflejo de escalar al patrón completo en cuanto aparece una excepción. La primera excepción casi nunca justifica el salto; la tercera, sí.
Ejercicio 3 — Encuentra el estado ilegal representable. Este Ticket de Boletia tiene un problema del tipo que esta lección quiere que detectes a simple vista. Encuéntralo, escribe una secuencia de llamadas que deje el objeto en un estado imposible, y propón el arreglo.
class Ticket:
def __init__(self, id, event_id, kind, base_price):
self.id = id
self.event_id = event_id
self.kind = kind
self.base_price = base_price
self.status = "available" # "available" | "reserved" | "sold"
self.reserved_by = None # customer_id
self.reserved_until = None
self.sold_to = None # customer_id
def reserve(self, customer_id, minutes=15):
self.status = "reserved"
self.reserved_by = customer_id
self.reserved_until = now() + timedelta(minutes=minutes)
def sell(self, customer_id):
self.status = "sold"
self.sold_to = customer_id
def release(self):
self.status = "available"
self.reserved_by = None
Ver solución
El problema: ninguna operación valida desde qué estado se llama, y cada una limpia solo una parte de los campos. El resultado es que el objeto puede quedar diciendo dos cosas a la vez.
Una secuencia que lo rompe:
t = Ticket(1, 77, "general", 500.0)
t.reserve(customer_id=5) # reserved, reserved_by=5
t.sell(customer_id=9) # sold, sold_to=9 … pero reserved_by SIGUE en 5
# y reserved_until sigue en el futuro
t.release() # available … pero sold_to SIGUE en 9
Al final, el boleto está available —o sea, alguien puede comprarlo— con sold_to=9. Es decir: un boleto vendido, disponible para venderse otra vez. En un evento con aforo limitado eso es sobreventa, y se descubre en la puerta con dos personas y un asiento.
Hay además un segundo problema más sutil: sell no verifica que quien compra sea quien reservó. Cualquiera puede comprar un boleto reservado por otra persona, y la reserva no sirve de nada.
El arreglo. Dos cambios, y el orden importa.
Primero, transiciones explícitas con lo prohibido por defecto:
TRANSITIONS = {
"available": {"reserve": "reserved", "sell": "sold"},
"reserved": {"sell": "sold", "release": "available", "expire": "available"},
"sold": {}, # final: un boleto vendido no vuelve
}
Segundo —y esto es lo que de verdad elimina el estado ilegal— que los campos dependientes se limpien siempre en la transición, en un solo lugar, y que quien reservó sea parte de la validación:
def sell(self, customer_id):
if self.status == "reserved" and self.reserved_by != customer_id:
raise InvalidOperation("Este boleto está reservado por otra persona")
self.status = next_state(self.status, "sell")
self.sold_to = customer_id
self._clear_reservation() # SIEMPRE, en toda transición que salga
def release(self):
self.status = next_state(self.status, "release")
self._clear_reservation()
def _clear_reservation(self):
self.reserved_by = None
self.reserved_until = None
Y la solución de fondo, la que evita la clase entera de bugs: que los campos no puedan existir fuera de su estado. Si reserved_by y reserved_until vivieran dentro de un objeto Reservation que solo existe mientras el boleto está reservado, no habría nada que limpiar —al salir del estado, la reserva se va con él—. Eso es exactamente el argumento a favor de las clases por estado: los datos propios de un estado viven en el estado.
Fíjate en un detalle de este ejercicio que no es menor: reserved_until implica que hay un evento que no viene de una llamada —el tiempo pasa y la reserva vence—. Una máquina de estados con expiración necesita a alguien que dispare la transición (una tarea periódica, o una revisión perezosa al leer). Esa transición existe en el negocio aunque nadie la llame, y si no está en tu tabla, tu tabla está incompleta.
Por qué funciona: el bug que encontraste no se ve leyendo un método. Se ve al preguntarse "¿qué combinaciones de campos son posibles y cuáles corresponden a algo real?". Ese hábito —enumerar los estados representables y compararlos con los legales— es la herramienta más útil de toda la lección, y sirve tengas o no un patrón puesto.
Resumen y siguiente paso
En esta lección cerraste la familia del comportamiento variable con State, el caso donde elige el objeto mismo. Lo viste con el paquete de mensajería: el mismo objeto de principio a fin, con botones que cambian solos, donde tú no eliges el estado y donde lo ilegal no se rechaza sino que directamente no existe.
Lo aplicaste al Order de Boletia y diagnosticaste sus cuatro problemas: el mismo condicional en seis métodos, las reglas de transición sin escribir en ningún lado, estados ilegales representables por culpa de banderas que duplicaban el estado, y la mezcla de decidir con hacer. Escribiste primero la tabla de transiciones —doce celdas que son la regla de negocio completa— y solo después el código, que es el orden correcto. Y usaste la decisión de diseño que sostiene todo: el valor por defecto es prohibir, de modo que olvidarse de una transición produce un error y no un permiso.
Separaste State de Strategy con la regla de bolsillo: si quien llama elige, es Strategy; si el objeto cambia solo, es State. Y viste la diferencia que más duele en la práctica: en Strategy las implementaciones no se conocen; en State cada estado nombra a sus sucesores, porque el grafo es la regla de negocio.
Y —lo más importante de la lección— viste el punto intermedio. La mayoría de las máquinas de estados no necesitan una clase por estado: necesitan una tabla de quince líneas. La escalera es: un booleano → un texto con tabla de transiciones → una clase por estado, subiendo un peldaño solo cuando el actual quede chico. Las clases se justifican cuando cada estado tiene datos propios, transiciones con lógica, o efectos al entrar y salir. Y tienes el detector general: cuando varios diccionarios comparten exactamente las mismas llaves, esas llaves querían ser objetos.
Antes de avanzar deberías poder: escribir la tabla de transiciones de un ciclo de vida antes de escribir código; detectar un estado ilegal representable mirando los campos de una clase; y decir en qué peldaño de la escalera conviene quedarse para un caso dado.
Ya tienes los tres patrones de la familia. Y ahora toca la pregunta incómoda, la que mantiene honesto a todo el módulo: ¿hacían falta las clases? En Python, una Strategy suele ser una función pasada como argumento; una tabla de transiciones ya viste que puede ser un diccionario; y un método plantilla puede ser una función que recibe callables. La lección 6 hace esa pregunta en serio, con el mismo código que ya escribiste, y responde cuándo la clase se gana su lugar —estado propio, varias operaciones relacionadas— y cuándo es pura ceremonia.
Recursos
- Refactoring Guru — State — el diagrama canónico y la comparación con Strategy, que es la duda más común del patrón.
- Refactoring Guru — Replace Type Code with State/Strategy — la ficha del refactor que hiciste, partiendo justamente de un campo de texto que codifica el estado.
- Python docs — enum — antes de saltar a clases por estado, un
Enumelimina buena parte de los errores de unstatusde texto libre, y es el peldaño intermedio más barato de todos. - Martin Fowler — Domain-Specific Languages, capítulo sobre máquinas de estado — el tratamiento clásico de las máquinas de estados declarativas, es decir, la tabla llevada hasta sus últimas consecuencias.