Módulo 6: Patrones para comunicar entre partes
5. Command: empaquetar una acción como objeto
Descripción
Al terminar esta lección vas a poder hacer tres cosas con Command. Primero, entender la idea, que es más simple de lo que su reputación sugiere: en vez de hacer algo, construyes un objeto que describe lo que hay que hacer, y lo ejecutas después —o mañana, o tres veces, o al revés—. Segundo, reconocer los cuatro lugares donde de verdad se gana su lugar, que son bastante específicos y todos tienen la misma raíz: la acción necesita sobrevivir al momento en que se decidió. Y tercero —y aquí está el valor real de esta lección— saber por qué en la enorme mayoría del código que vas a escribir, una función basta, y ser capaz de defender esa posición con argumentos.
Esa advertencia no es un adorno de modestia. Command es, junto con Singleton, uno de los patrones que más código innecesario ha producido en la historia del oficio. La razón es que su forma es seductora: una interfaz Command con un método execute(), y de golpe todo en tu programa "es un comando". El problema es que en un lenguaje donde las funciones son valores —Python, JavaScript, Ruby, Go, cualquier cosa moderna— una clase con un solo método execute() es una función con ceremonia. La clase solo se gana su lugar cuando necesitas algo que una función no te da, y esta lección te va a dejar la lista exacta de esos algos.
Y hay una razón por la que Command está en este módulo y no en el 3, aunque su forma se parezca a una Strategy. La familia de este módulo trata sobre quién le avisa a quién, y Command es la otra mitad de esa conversación: si Observer maneja los hechos —cosas que ya pasaron y se anuncian—, Command maneja las órdenes —cosas que todavía no pasan y que alguien quiere que pasen—. Los dos juntos son el esqueleto de cualquier sistema de trabajo diferido que hayas usado.
Conexión con el módulo: la lección 4 cerró la parte de Observer con las reglas para diseñar un evento estable. Esta lección gira noventa grados: pasamos del pasado al futuro, del hecho a la orden. Vas a ver que Command y evento tienen la misma forma en código —una dataclass inmutable— y significados opuestos, y que confundirlos es uno de los errores más comunes de esta familia. La lección 6 vuelve a Observer para hablar del costo de trazabilidad, y Command va a reaparecer ahí como parte de la mitigación: un comando persistido deja rastro donde una llamada no deja ninguno.
La comanda del restaurante
En un restaurante, cuando pides tu comida, el mesero no va a la cocina y grita "el de la mesa cuatro quiere pasta". Escribe una comanda: un papelito con la mesa, los platos, las modificaciones ("sin cebolla") y la hora. Ese papel viaja a la cocina y se clava en un riel.
Fíjate en todo lo que ese papel permite y que un grito no permitiría.
Se puede guardar. El papel existe aunque el mesero se haya ido a otra mesa. La orden ya no depende de que alguien la recuerde.
Se puede poner en fila. Hay ocho comandas en el riel y se atienden en orden. Nadie tuvo que coordinar nada: la fila es el papel apilado.
Se puede repetir. Si el plato se cayó, el cocinero toma la misma comanda y lo vuelve a hacer. No hace falta ir a preguntarle a la mesa cuatro qué había pedido.
Se puede auditar. Al final de la noche están todas las comandas. Se sabe qué se pidió, a qué hora y en qué mesa, sin depender de la memoria de nadie.
Se puede cancelar. La mesa cuatro se arrepiente; el mesero busca el papel y lo saca del riel, si todavía no empezó a cocinarse.
Ahora pregúntate lo contrario, que es la parte que importa: ¿cuándo el papel sobra? Si tú estás cocinando en tu casa y tu pareja te pide un café, no escribes una comanda. Le haces el café. El papel resuelve problemas que aparecen cuando la orden tiene que viajar, esperar, repetirse o quedar registrada. Si nada de eso hace falta, el papel es puro trabajo.
Esa es la lección completa. Command es la comanda. Y la pregunta que decide si lo usas o no es siempre la misma: "¿esta acción necesita existir separada del momento en que se decidió?".
Ejemplo trabajado: la noche que se cayó el proveedor de SMS
Boletia ya tiene una cicatriz de este problema, y la conociste en el módulo 1. En notifications/retrying.py hay una clase que se agregó después de que el proveedor de SMS se cayó un viernes:
# Archivo: notifications/retrying.py
class RetryingChannel(NotificationChannel):
def __init__(self, inner, attempts=3, wait_seconds=2):
self.inner = inner
self.attempts = attempts
self.wait_seconds = wait_seconds
def send(self, recipient, message):
last_error = None
for _ in range(self.attempts):
try:
return self.inner.send(recipient, message)
except TransientError as e:
last_error = e
sleep(self.wait_seconds)
raise last_error
Es un Decorator, lo nombraste en el módulo 5, y funciona bien para lo que es. Pero mira la aritmética: tres intentos, dos segundos entre uno y otro. Seis segundos de cobertura. Si el proveedor se cae seis segundos, Boletia está a salvo. Si se cae cuarenta minutos —que es lo que pasó aquel viernes— los tres intentos se agotan en los primeros seis segundos, la excepción sube, y ese SMS se perdió para siempre. No hay a dónde volver: la información de qué había que mandar y a quién vivía en las variables locales de una función que ya terminó.
Ese es el problema exacto que Command resuelve. La acción "mandar este SMS a este cliente con este texto" existía solo mientras la función corría. Vamos a darle cuerpo.
Paso 1 — La orden, como dato.
# Archivo: jobs/commands.py
from dataclasses import dataclass, field
@dataclass(frozen=True)
class SendNotification:
"""Una orden: manda este aviso. Todavía no pasó; queremos que pase.
Fíjate en el nombre: está en IMPERATIVO. Es lo contrario de
OrderCompleted, que está en pasado. La forma en código es idéntica
—una dataclass inmutable— y el significado es opuesto: uno informa
de algo que ocurrió, el otro pide algo que debe ocurrir.
Todos los campos son de tipos simples, a propósito: esta orden va a
guardarse en la base de datos y a leerse desde otro proceso, tal vez
dentro de media hora. Un objeto Customer aquí adentro no sobrevive
ese viaje.
"""
customer_id: int
channel_name: str # "email" | "sms" | "push"
template_name: str # "order_confirmation" | "organizer_alert" | ...
params: dict # los datos que necesita la plantilla
attempts_made: int = 0
Paso 2 — Quién sabe ejecutarla. Y aquí viene una decisión de diseño que conviene ver, porque las dos opciones aparecen en código real.
La versión del catálogo pone la ejecución dentro del comando: un método execute(). La versión que prefiero para este caso la pone afuera, en un ejecutor que sabe interpretar el comando:
# Archivo: jobs/executor.py
from notifications.notifier import CHANNELS_BY_NAME
from notifications.templates import render
from data import repository
def execute(command: SendNotification) -> None:
"""Interpreta la orden y la ejecuta. Puede lanzar TransientError."""
customer = repository.get_customer(command.customer_id)
channel = CHANNELS_BY_NAME[command.channel_name]
message = render(command.template_name, command.params)
channel.send(recipient_for(channel, customer), message)
¿Por qué afuera? Porque el comando tiene que viajar por la base de datos, y un objeto que viaja como JSON no puede llevar comportamiento consigo. Al reconstruirlo del otro lado tienes los datos, no los métodos. Separar el dato de su intérprete es lo que hace posible la persistencia, y por eso casi todos los sistemas de trabajos en cola del mundo real lo hacen así, aunque el diagrama del libro diga otra cosa. Cuando el comando no necesita persistirse, execute() adentro es más cómodo y está perfectamente bien.
Paso 3 — La cola, en su versión más humilde.
# Archivo: jobs/queue.py
import json
from dataclasses import asdict, replace
def enqueue(command) -> None:
"""Guarda la orden para ejecutarla después.
Guardamos el nombre de la clase para poder reconstruirla, y el resto
como JSON. No hace falta nada sofisticado: una tabla con cuatro
columnas alcanza para el volumen de Boletia.
"""
repository.save_pending_job(
kind=type(command).__name__,
payload=json.dumps(asdict(command)),
run_after=now(),
)
def run_pending(limit=100) -> None:
"""Lo corre un proceso aparte, cada minuto."""
for row in repository.take_pending_jobs(limit):
command = COMMAND_TYPES[row.kind](**json.loads(row.payload))
try:
executor.execute(command)
repository.mark_job_done(row.id)
except TransientError:
# La espera crece con cada intento: 1, 2, 4, 8, 16 minutos.
# Así un proveedor caído cuarenta minutos no nos hace perder
# nada, y tampoco lo bombardeamos mientras se recupera.
retried = replace(command, attempts_made=command.attempts_made + 1)
if retried.attempts_made >= 6:
repository.mark_job_failed(row.id, reason="agotados los intentos")
logger.error("Aviso perdido definitivamente: %s", retried)
else:
repository.reschedule_job(
row.id,
payload=json.dumps(asdict(retried)),
run_after=now() + minutes(2 ** retried.attempts_made),
)
Paso 4 — El suscriptor deja de enviar y pasa a encolar.
# Archivo: notifications/subscribers.py
def send_buyer_confirmation(order_completed: OrderCompleted) -> None:
"""Encola un aviso por cada canal disponible para el comprador.
El cambio importante respecto de la lección 3: aquí ya no se manda
nada. Se dejan órdenes escritas. El checkout termina en milisegundos
aunque el servidor de correo esté lento, y ningún aviso se pierde
porque un proveedor esté caído.
"""
customer = repository.get_customer(order_completed.customer_id)
params = {"order_id": order_completed.order_id}
for channel in CHANNELS:
if channel.is_available_for(customer):
enqueue(SendNotification(
customer_id=customer.id,
channel_name=channel.name,
template_name="order_confirmation",
params=params,
))
Qué esperar de este ejemplo. Lo primero: fíjate en que el patrón resolvió un problema que Observer no podía resolver. El bus de la lección 2 desacopló quién avisa de quién escucha, pero los manejadores seguían corriendo en el mismo instante y en el mismo hilo; si el proveedor estaba caído, seguían perdiéndose los avisos. El comando persistido resuelve otra cosa: la orden sobrevive al proceso. Son dos problemas distintos y por eso son dos patrones distintos, aunque se usen juntos casi siempre.
Lo segundo: los campos attempts_made y run_after no son detalles de plomería, son la razón de ser del diseño. Un comando que se puede reintentar necesita saber cuántas veces se intentó, y eso solo es posible porque el comando es un dato que se puede modificar y volver a guardar. Con una llamada a función, esa información no tiene dónde vivir.
Lo tercero, que es una advertencia importante: al encolar, aceptaste que el aviso puede llegar más de una vez. Si execute() manda el correo y el proceso se cae antes de marcar el trabajo como hecho, el correo se manda otra vez en el siguiente ciclo. Eso se llama entrega "al menos una vez" y es la garantía normal de cualquier cola. La consecuencia práctica: los comandos deben ser idempotentes o tolerar la repetición. Un correo duplicado es molesto pero aceptable; un cobro duplicado no lo es. Si algún día encolas un cobro, necesitas una llave de idempotencia. Es exactamente el tipo de decisión que la lección 7 te va a pedir tomar con criterio.
Y lo cuarto: cuenta el costo. Apareció una tabla nueva en la base de datos, un proceso que corre cada minuto —que hay que desplegar, vigilar y reiniciar cuando se cuelgue—, un formato de serialización que mantener y una garantía nueva que todo el equipo tiene que entender. Para un sistema que manda diez avisos al día, nada de eso vale la pena y el RetryingChannel de seis segundos es la respuesta correcta. Boletia vende miles de boletos en las horas siguientes a que se anuncia un festival, y ahí sí vale. La diferencia no está en el patrón: está en el volumen y en el costo de perder un aviso.
Anatomía de Command: las cuatro piezas
Pieza 1 — El comando. El objeto que describe la acción: qué hacer y con qué datos. En Boletia, SendNotification. Su característica definitoria es que no ejecuta al construirse. Crear el comando y ejecutarlo son dos momentos separados, y ese hueco entre los dos es donde vive todo el valor del patrón.
Pieza 2 — El receptor. Quien de verdad hace el trabajo. En Boletia, el NotificationChannel correspondiente. El comando no manda correos: sabe a quién pedírselo.
Pieza 3 — El invocador. Quien decide cuándo se ejecuta. En Boletia, run_pending(), el proceso que corre cada minuto. En un editor de texto sería el botón de la barra; en un menú, el elemento del menú. Su característica clave: el invocador no sabe qué hace el comando. Sabe ejecutarlo. Ese desconocimiento es lo que permite que el mismo botón "Deshacer" funcione para veinte acciones distintas.
Pieza 4 — El historial (opcional). La lista de comandos ejecutados. Es opcional en la forma, pero es lo que habilita las dos aplicaciones más vistosas del patrón: deshacer y auditar.
Los cuatro lugares donde Command se gana su lugar
Todos tienen la misma raíz —la acción necesita existir separada del momento en que se decidió— pero conviene verlos separados porque cada uno tiene su señal propia.
1. Cuando la acción tiene que esperar (colas de trabajo). El caso de Boletia. La acción se decide ahora y se ejecuta después, tal vez en otro proceso, tal vez en otra máquina. Es, con diferencia, el uso más frecuente de Command en el software de servidor moderno, aunque casi nadie lo llame por su nombre: cada tarea de Celery, cada trabajo de Sidekiq, cada mensaje de una cola es un Command serializado. Señal de que lo necesitas: la acción no puede ejecutarse en el mismo instante, o no debe bloquear al que la pidió.
2. Cuando la acción tiene que reintentarse. Reintentar exige poder repetir la acción idéntica, lo que exige tener sus datos guardados. Señal: la acción falla por causas transitorias —una red, un servicio externo— y perderla tiene costo. Ojo con la frontera: si el reintento cabe en segundos y en memoria, un Decorator como RetryingChannel es más simple y es la respuesta correcta. Command entra cuando el reintento tiene que sobrevivir al proceso.
3. Cuando hace falta registro o auditoría. Si cada acción es un objeto, guardarla es trivial y el registro es exacto: no "alguien canceló boletos", sino "el usuario 12 ejecutó CancelTickets(order_id=4821, reason='duplicado') a las 19:04". Señal: alguien —cumplimiento normativo, soporte, el equipo de datos— necesita saber después qué se hizo, con qué parámetros y quién lo pidió.
4. Cuando hace falta deshacer. El uso clásico del catálogo, y el más raro en software de servidor. Cada comando sabe ejecutarse y sabe revertirse; una pila guarda los ejecutados. Es la arquitectura de todo editor, programa de dibujo o herramienta de diseño. Señal: existe un botón "Deshacer" o una operación de reversión que el usuario controla.
Boletia tiene un caso genuino del cuarto, y vale la pena verlo porque es la forma canónica del patrón:
# Archivo: admin/commands.py
# El panel de operaciones permite emitir cortesías y equivocarse.
class IssueCourtesyTickets:
"""Emite N boletos de cortesía para un evento. Reversible."""
def __init__(self, event_id: int, quantity: int, requested_by: int):
self.event_id = event_id
self.quantity = quantity
self.requested_by = requested_by
self._issued_ids: list[int] = [] # lo que hay que revertir
def execute(self) -> None:
for _ in range(self.quantity):
ticket = repository.create_ticket(
event_id=self.event_id, kind="courtesy", base_price=0.0,
)
self._issued_ids.append(ticket.id)
def undo(self) -> None:
# Solo se pueden revertir los que no se hayan usado. Si alguien ya
# entró con una cortesía, deshacer sería mentir sobre el pasado.
for ticket_id in self._issued_ids:
ticket = repository.get_ticket(ticket_id)
if ticket.status == "used":
raise CannotUndo(f"El boleto {ticket_id} ya se usó")
repository.delete_ticket(ticket_id)
self._issued_ids.clear()
Fíjate en dos cosas. Primero, aquí sí hace falta una clase, y no por gusto: el comando guarda estado entre execute() y undo() —la lista de identificadores emitidos— y una función no tiene dónde guardarlo. Ese estado interno es la señal más clara de que la clase se ganó su lugar. Segundo, el undo() puede fallar, y eso no es un defecto de la implementación: es el mundo real. Deshacer en un sistema con efectos externos —correos mandados, dinero cobrado, gente que ya entró al teatro— casi nunca es una reversión perfecta, sino una operación compensatoria. Si tu comando manda un correo, undo() no puede desmandarlo; lo máximo que puede hacer es mandar otro que diga "ignore el anterior". Prometer deshacer donde solo puedes compensar es una promesa que el sistema no puede cumplir.
Por qué casi siempre una función basta
Y ahora la parte honesta, que es la mitad del valor de esta lección.
Mira el comando más simple que se pueda imaginar en Python:
class SendWelcomeEmail:
def __init__(self, customer_id):
self.customer_id = customer_id
def execute(self):
customer = repository.get_customer(self.customer_id)
email_channel.send(customer.email, build_welcome(customer))
Y ahora lo mismo, con lo que el lenguaje ya te da:
from functools import partial
send_welcome = partial(send_welcome_email, customer_id=42)
# Después, cuando toque:
send_welcome()
Las dos cosas hacen exactamente lo mismo. La segunda no tiene clase, no tiene execute(), no tiene archivo propio. functools.partial es un comando: empaqueta una función con sus argumentos en un objeto que se puede guardar en una lista, pasar como parámetro y ejecutar después. Una función anidada que captura variables —una clausura— también lo es. En un lenguaje con funciones de primera clase, el patrón Command en su forma básica ya viene incluido.
Entonces, ¿cuándo necesitas la clase de verdad? Cuatro casos, y son bastante estrechos:
Cuando el comando tiene que serializarse. partial no se convierte a JSON. Si la orden tiene que guardarse en una base de datos, viajar por una cola o sobrevivir a un reinicio, necesitas un dato explícito con campos nombrados. Es el caso de SendNotification.
Cuando el comando tiene que inspeccionarse. Si necesitas mirar un trabajo pendiente y decir "esto es un envío de SMS al cliente 42", un partial no te lo dice: es una caja cerrada. Un objeto con campos, sí. Esto importa muchísimo para el registro, el panel de trabajos pendientes y el diagnóstico.
Cuando el comando tiene estado propio entre ejecución y reversión. El caso de IssueCourtesyTickets: los identificadores emitidos tienen que vivir en algún lado entre execute() y undo().
Cuando hay una familia real de comandos con operaciones comunes. Si tienes doce comandos y todos necesitan execute(), undo(), describe() y can_run_now(), un contrato compartido paga. Si tienes dos y solo necesitan ejecutarse, no.
Y aquí está la regla de bolsillo, que es lo que quiero que te lleves de esta sección: si tu clase Command tiene un solo método execute() y ningún estado, es una función con tres archivos de ceremonia alrededor. Escríbela como función. Ese diagnóstico es idéntico al que hiciste en el módulo 3 con Strategy y en la lección 2 con la clase abstracta Observer: el catálogo original se escribió para lenguajes donde una función no era un valor, y la mitad de sus formas son andamios que Python ya no necesita.
La frontera con el evento: hecho contra orden
Esta confusión es tan común que merece su propia sección, sobre todo porque en Python las dos cosas se escriben igual.
@dataclass(frozen=True)
class OrderCompleted: # HECHO. Pasado. "Esto ocurrió."
order_id: int
...
@dataclass(frozen=True)
class SendNotification: # ORDEN. Imperativo. "Haz esto."
customer_id: int
...
Misma forma, significados opuestos. Las diferencias que importan:
| Hecho (evento) | Orden (comando) | |
|---|---|---|
| Nombre | Pasado: OrderCompleted | Imperativo: SendNotification |
| Cuántos lo atienden | Cero, uno o muchos | Exactamente uno |
| Puede ser ignorado | Sí, sin problema | No: si nadie lo ejecuta, es una falla |
| Puede ser rechazado | No tiene sentido: ya pasó | Sí: la orden puede ser inválida |
| Quién lo publica sabe qué va a pasar | No | Sí |
| Se puede cancelar | No | Sí, mientras no se haya ejecutado |
La consecuencia práctica de la tercera fila es la que más se equivoca: publicar un evento que nadie escucha es normal; encolar un comando que nadie ejecuta es un bug. Por eso un bus de eventos no debe quejarse cuando no hay suscriptores, y una cola de comandos sí debe quejarse si un tipo de comando no tiene ejecutor registrado.
Y la consecuencia de la quinta fila es la que decide el diseño. Cuando publicas OrderCompleted, no sabes ni te importa qué va a pasar. Cuando encolas SendNotification, sabes perfectamente qué va a pasar: se va a mandar un aviso. Ahí está el acoplamiento que el evento te quitó y el comando te devuelve, y por eso no son intercambiables. En la arquitectura que armamos, la combinación tiene una forma clara: el checkout publica un hecho —desacoplado, no sabe quién escucha— y el suscriptor de notificaciones encola una orden —acoplada, sabe exactamente qué quiere que pase—. Cada patrón en la frontera donde le toca.
Si tienes que quedarte con una sola prueba para distinguirlos, es esta: intenta nombrarlo en pasado. Si el nombre en pasado suena natural y verdadero, es un hecho. Si te obliga a inventar algo raro como NotificationSendingRequested, tenías una orden y estabas disfrazándola.
Errores comunes
Convertir todo en un Command porque "así es más flexible" (de criterio). Qué pasa: alguien aprende el patrón y aparece jobs/commands.py con dieciocho clases, cada una con su execute() de dos líneas, cada una llamada desde un solo lugar y ejecutada de inmediato. El sistema tiene dieciocho archivos más y exactamente la misma flexibilidad que tenía. Por qué pasa: porque "empaquetar una acción" suena a arquitectura y escribir la clase se siente productivo, mientras que llamar a la función se siente demasiado fácil. Es el síndrome del martillo nuevo del módulo 1, con este martillo. Cómo detectarlo: por cada comando, pregunta "¿se ejecuta en un momento distinto de cuando se construye?". Si construir y ejecutar están en la misma línea, no hay comando: hay una llamada con pasos extra. Cómo corregirlo: llama a la función. Si el día de mañana hace falta encolar, extraer un comando desde una función es un refactor de diez minutos.
Prometer deshacer donde solo se puede compensar (conceptual). Qué pasa: se implementa undo() en comandos con efectos externos —correos enviados, cobros hechos, mensajes publicados— y el método hace lo que puede: borra el registro local y devuelve None. El sistema muestra "deshecho" y el correo sigue en la bandeja del cliente. Por qué pasa: porque el patrón se aprende con ejemplos de editor de texto, donde deshacer sí es perfecto, y se traslada a un dominio donde el pasado no es reversible. Cómo detectarlo: pregúntate si undo() puede dejar el mundo exactamente como estaba, incluido lo que está fuera de tu sistema. Si algo salió de tu proceso —un correo, un cobro, una llamada a una API— la respuesta es no. Cómo corregirlo: no lo llames undo(). Llámalo compensate() o, mejor, ponle el nombre del negocio: refund(), send_correction(), cancel_tickets(). Un nombre honesto evita que alguien construya encima una promesa que el sistema no puede cumplir.
Encolar sin pensar en la repetición (de criterio). Qué pasa: se pasa todo a la cola porque "es más robusto", y se encolan cosas que no toleran ejecutarse dos veces: sumar puntos, cobrar, emitir un boleto. Un reinicio del proceso en el momento equivocado y hay clientes con el doble de puntos, o con dos boletos. Por qué pasa: porque la garantía de las colas es "al menos una vez" y casi nadie lee esa frase con cuidado; se asume "exactamente una vez", que es mucho más cara de lograr y casi ningún sistema la da de verdad. Cómo detectarlo: por cada comando, pregunta "si esto corre dos veces seguidas, ¿qué pasa?". Si la respuesta no es "nada malo", no está listo para una cola. Cómo corregirlo: hazlo idempotente. Una llave única por operación —f"loyalty:{order_id}"— que se verifica antes de aplicar el efecto, o una condición que ya lo vuelva repetible ("poner el estado en sold" es idempotente; "restar uno al disponible" no lo es).
Ejercicios
Ejercicio 1 — Clasifica ocho acciones. Para cada una, di si en Boletia la escribirías como llamada directa a función, como comando en cola, o como comando con historial y reversión. Una línea de razón cada una.
- Calcular el precio de un boleto.
- Mandar el correo de confirmación de compra.
- Emitir veinte cortesías desde el panel de administración.
- Refrescar el panel del organizador.
- Cobrar con la pasarela de pago.
- Generar el reporte de asistentes en PDF de un festival de 8,000 personas.
- Marcar los boletos como vendidos.
- Mandar el recordatorio de 48 horas antes del evento.
Ver solución
-
Función. Es un cálculo puro, inmediato, sin efectos externos y su resultado se necesita ahora mismo. Envolverlo en un comando sería ceremonia pura.
-
Comando en cola. Es el caso trabajado: no debe bloquear la venta, falla por causas transitorias y perderlo tiene costo. Tolera bien la repetición —un correo duplicado es molesto, no grave— así que la garantía "al menos una vez" alcanza.
-
Comando con historial y reversión. Lo pide un operador desde un panel, se puede equivocar con la cantidad y quiere corregirlo. Además hay que saber quién lo hizo. Es el único de la lista donde el
undo()es genuino, con la salvedad de que deja de serlo en cuanto alguien usa una cortesía. -
Función, o comando en cola si duele. Es un refresco de un panel: si falla, no pasa gran cosa y el siguiente lo arregla. Si resulta ser lento y estar retrasando la venta, se encola. Es una decisión que se toma con una medición, no con un principio.
-
Función, y con fuerza. El cobro tiene que ocurrir ahora, su resultado decide si la compra sigue, y no tolera ejecutarse dos veces. Todo lo que hace bueno a un comando en cola —diferir, reintentar automáticamente— es exactamente lo que no quieres aquí. Si algún día hay que encolarlo, hará falta una llave de idempotencia y es un proyecto en sí mismo.
-
Comando en cola, claramente. Puede tardar minutos, nadie debe esperar frente a una pantalla en blanco, y si falla se puede reintentar. El patrón típico: se encola, se le da al usuario un identificador, y se le avisa cuando esté listo.
-
Función, dentro de la transacción del checkout. Es la conclusión de la lección 3: no es una reacción, es parte de la compra. Encolarlo introduce una ventana en la que los boletos siguen disponibles y se pueden vender dos veces.
-
Comando en cola, con fecha de ejecución. Es el caso más puro del patrón: la orden se crea hoy y se ejecuta dentro de tres semanas. Ninguna función puede hacer eso; la orden tiene que existir como dato guardado.
El patrón que quiero que veas: los que van a cola son los que pueden esperar y toleran repetirse; los que se quedan como función son los que deciden el resultado de la operación en curso. Esas dos preguntas resuelven casi todos los casos que te vas a encontrar.
Ejercicio 2 — Quita la ceremonia. Un compañero entregó esto. Reescríbelo con lo que Python ya trae, y después di en qué condición concreta la versión con clases volvería a ser la correcta.
class Command:
def execute(self): raise NotImplementedError
class SendSms(Command):
def __init__(self, phone, text):
self.phone = phone
self.text = text
def execute(self):
sms_api.post(number=self.phone, text=self.text)
class SendEmail(Command):
def __init__(self, address, subject, body):
self.address = address
self.subject = subject
self.body = body
def execute(self):
smtp.deliver(to=self.address, subject=self.subject, body=self.body)
# Uso:
commands = [SendSms("555", "hola"), SendEmail("a@b.c", "Hola", "...")]
for c in commands:
c.execute()
Ver solución
from functools import partial
commands = [
partial(sms_api.post, number="555", text="hola"),
partial(smtp.deliver, to="a@b.c", subject="Hola", body="..."),
]
for run in commands:
run()
Treinta y cuatro líneas se convirtieron en seis, y no se perdió nada: sigues teniendo una lista de acciones pendientes que se ejecutan después. La clase base Command con su NotImplementedError no aportaba nada —en Python nadie necesita heredar para poder estar en una lista— y las dos subclases eran cajas para guardar argumentos, que es exactamente el trabajo de partial.
Cuándo volvería a hacer falta la versión con clases, tres condiciones concretas:
- Si la lista se guarda.
partialno se convierte a JSON; en el momento en que estas órdenes tengan que sobrevivir a un reinicio, necesitas datos con campos nombrados. Es elSendNotificationde la lección. - Si hay que mirar la lista. Un panel que muestre "hay 3 SMS y 12 correos pendientes" no puede sacar esa información de un
partial. Con objetos con campos, sí. - Si aparece una segunda operación. El día que además de
execute()haga faltaundo(),describe()ocan_run_now(), ya no es una función: es un objeto con varias operaciones relacionadas, y ahí la clase se gana su lugar honestamente.
Fíjate en que las tres condiciones son verificables. No son "por si acaso" ni "para que sea más flexible": son hechos que o están presentes hoy o no lo están. Ese es el criterio del módulo 2 aplicado a este patrón.
Ejercicio 3 — Diseña la idempotencia. El equipo de marketing quiere que los puntos de lealtad se sumen desde una cola, porque el servicio de puntos se cae seguido. Escribe el comando y explica qué le agregas para que ejecutarlo dos veces no le regale puntos a nadie.
Ver solución
@dataclass(frozen=True)
class AddLoyaltyPoints:
"""Suma puntos por una compra. Seguro de ejecutar varias veces."""
customer_id: int
order_id: int # la clave: identifica la CAUSA, no la acción
points: int
@property
def idempotency_key(self) -> str:
# Un solo abono de puntos por orden, para siempre.
return f"loyalty:order:{self.order_id}"
def execute(command: AddLoyaltyPoints) -> None:
# La verificación y el abono tienen que ser una sola operación atómica.
# Si se hacen por separado, dos ejecuciones simultáneas pueden pasar
# las dos por el `if` antes de que ninguna haya escrito.
already = repository.claim_idempotency_key(command.idempotency_key)
if not already:
return # ya se abonó por esta orden; no hacemos nada
points_service.add(command.customer_id, command.points)
Lo que hace idempotente a este comando no es un truco de programación: es que la llave se construye a partir de la causa del abono —la orden— y no de la ejecución. f"loyalty:{uuid4()}" sería una llave distinta cada vez y no serviría de nada. La regla general: la llave de idempotencia identifica el hecho del negocio que justifica la operación. Una orden justifica un abono de puntos. Punto.
Dos detalles que vale la pena señalar. El primero: claim_idempotency_key reclama y devuelve si lo consiguió, en una sola operación de base de datos —un INSERT con restricción de unicidad, por ejemplo—. Separarlo en "consultar" y luego "escribir" reabre exactamente el agujero que estamos tapando, y es un error clásico. El segundo: fíjate en que si el comando corre dos veces, la segunda no hace nada y no falla. Eso es lo correcto: la cola va a marcar el trabajo como hecho y todos contentos. Un comando idempotente que lanza excepción al repetirse convierte un reintento normal en una alerta falsa.
Por qué funciona: la idempotencia es el precio de entrada a las colas, y casi nadie la enseña junto con el patrón. Saber pedirla en una revisión de código —"¿qué pasa si este trabajo corre dos veces?"— es una de esas preguntas cortas que evitan incidentes grandes.
Resumen y siguiente paso
En esta lección viste Command como lo que es: la comanda del restaurante, un papel que convierte una acción en algo que se puede guardar, poner en fila, repetir, auditar y a veces cancelar. Lo aplicaste sobre una cicatriz real de Boletia —el viernes que se cayó el proveedor de SMS y el RetryingChannel de seis segundos no alcanzó— y construiste la cola mínima con reintentos crecientes.
Conociste sus cuatro piezas —comando, receptor, invocador e historial— y los cuatro lugares donde se gana su lugar, todos con la misma raíz: la acción necesita sobrevivir al momento en que se decidió. Y viste la frontera con el evento, que en Python es puro significado porque la forma es idéntica: hecho contra orden, pasado contra imperativo, muchos suscriptores contra exactamente un ejecutor.
Sobre todo, te llevas la advertencia: una clase con un solo execute() y sin estado es una función con ceremonia. functools.partial y las clausuras ya son comandos. La clase se justifica cuando hay que serializar, inspeccionar, guardar estado entre ejecución y reversión, o cuando hay una familia real con varias operaciones comunes.
Antes de avanzar deberías poder: distinguir un hecho de una orden por el nombre; nombrar los cuatro lugares donde Command paga; explicar por qué un comando en cola tiene que ser idempotente; y detectar en código ajeno una clase Command que debería ser una función.
Ahora viene la lección que, si me preguntas, es la más importante del módulo. Hemos construido un sistema desacoplado, con eventos, suscriptores y comandos en cola. Es un buen sistema. Y a las tres de la mañana, cuando un organizador reclame que no le llegó el aviso de una venta, vas a descubrir lo que ese sistema te quitó: la capacidad de poner el dedo en la pantalla y seguir el flujo. La lección 6 mide ese costo sin adornos y te da las tres mitigaciones que de verdad funcionan.
Recursos
- Refactoring Guru — Command — la forma canónica del patrón con su ejemplo de editor de texto. Léelo sabiendo que su versión con jerarquía de clases responde a lenguajes sin funciones de primera clase.
- Python — functools.partial — el Command que ya viene en la caja. Vale la pena leer la sección completa de
functools, porque casi todo lo que hay ahí reemplaza a algún patrón del catálogo. - Celery — Tasks — la librería de trabajos en cola más usada de Python. Su documentación sobre reintentos e idempotencia es Command aplicado a escala, y su advertencia sobre "al menos una vez" es la de esta lección.
- Martin Fowler — Command Oriented Interface — una nota corta sobre cuándo conviene una interfaz de comandos frente a una de métodos, con el tradeoff bien planteado.