Comandos: guardar la operación y su inversa
Si guardar el estado entero sale caro, guarda solo lo que hiciste y cómo se deshace. El patrón comando sustituye el historial de valores por un historial de operaciones, cada una acompañada de su inversa, y con ello el coste de un paso pasa de ser proporcional al tamaño del estado a ser proporcional al tamaño del cambio: una diferencia de varios órdenes de magnitud en editores, lienzos y documentos grandes. Esta lección construye el patrón desde su definición algebraica —una operación es reversible cuando componerla con su inversa es la identidad— y muestra que la dificultad real no está en aplicar comandos sino en diseñar inversas correctas, porque toda operación que destruye información exige capturar esa información en el momento de ejecutarse o quedará fuera del alcance del deshacer para siempre. Se cubren la composición de comandos en transacciones, la ley de ida y vuelta que debe verificarse con pruebas basadas en propiedades, y el coste de mantenimiento que hace que este enfoque, siendo el más eficiente, sea también el más fácil de corromper en silencio.
La lección anterior terminó con tres síntomas que anuncian el final de las instantáneas: nodos grandes que no se pueden compartir, cambios continuos y demasiado frecuentes, y un historial que además tiene que viajar. Los tres apuntan al mismo desperdicio. Cuando el usuario mueve un rectángulo dos píxeles dentro de un documento con diez mil formas, guardar el documento entero para poder retroceder esos dos píxeles es una desproporción difícil de defender. Lo que cambió cabe en una frase: la forma con identificador siete se desplazó de una posición a otra. Y si eso cabe en una frase, el historial también. El patrón comando lleva esa observación hasta el final: no se guarda lo que el estado era, se guarda lo que se hizo, junto con la receta exacta para desandarlo. El resultado es un historial ligero, transmisible y semánticamente rico, capaz de decirte no solo que algo cambió sino qué significó ese cambio. El precio es exigente y esta lección no lo esconde: por cada operación que añadas al producto tendrás que escribir y sostener su inversa, y una inversa incorrecta no falla al escribirla sino tres deshacer más tarde, cuando ya nadie recuerda el commit que la introdujo.
- Definir un comando como un par de funciones ligadas por la ley de ida y vuelta.
- Capturar en el momento de ejecutar la información que la operación destruye, para poder invertirla.
- Componer comandos en transacciones y revertirlas en orden inverso estricto.
- Sopesar el coste de mantenimiento y el modo silencioso en que una inversa incorrecta corrompe el estado.
El comando como par de funciones
Un comando es un valor que representa una intención junto con la manera de aplicarla y la manera de revertirla. La formulación mínima en TypeScript son dos funciones del estado en el estado, y la propiedad que las liga es la que define todo el sistema: revertir después de aplicar debe devolver exactamente el estado de partida. Es la ley de ida y vuelta, y no es una recomendación sino la condición de existencia del historial. Si falla para un solo comando, el historial deja de ser un registro del pasado y se convierte en una fuente de estados que nunca ocurrieron.
type Comando<S> = {
nombre: string // etiqueta semantica, util en la UI y en logs
aplicar: (s: S) => S
revertir: (s: S) => S
}
// Ley de ida y vuelta que todo comando debe cumplir:
// revertir(aplicar(s)) equivale a s, para todo s valido
Esa ley tiene una lectura algebraica que conviene tener presente porque explica por qué el enfoque funciona. El conjunto de operaciones sobre el estado, con la composición como ley interna y la operación neutra como elemento identidad, forma un monoide; exigir que toda operación tenga inversa lo convierte en un grupo. Deshacer no es entonces un truco de interfaz sino la aplicación del elemento inverso, y rehacer la reaplicación del elemento original. La consecuencia práctica es inmediata: si una de tus operaciones no tiene inversa dentro de tu modelo, el conjunto no es un grupo y ninguna cantidad de código de interfaz va a arreglarlo. Hay que cambiar el modelo.
El historial pasa entonces a ser una pila de comandos con un puntero, en lugar de una pila de estados. La estructura es la misma que la del zipper de la lección anterior, pero lo que se apila pesa lo que pesa la operación.
type Registro<S> = { hechos: Comando<S>[]; deshechos: Comando<S>[] }
function ejecutar<S>(s: S, r: Registro<S>, c: Comando<S>): [S, Registro<S>] {
return [c.aplicar(s), { hechos: [...r.hechos, c], deshechos: [] }]
}
function deshacer<S>(s: S, r: Registro<S>): [S, Registro<S>] {
const c = r.hechos.at(-1)
if (!c) return [s, r]
return [c.revertir(s), { hechos: r.hechos.slice(0, -1), deshechos: [...r.deshechos, c] }]
}
Inversas correctas: la información que se destruye
Aquí está la dificultad genuina del patrón, y no es la que se anticipa. Aplicar comandos es fácil; invertirlos solo es fácil cuando la operación es simétrica. Mover una forma de una posición a otra es trivialmente reversible porque no se pierde nada: la inversa es el mismo movimiento con los extremos intercambiados. Borrar un elemento de una lista no lo es en absoluto, porque la operación destruye información —el contenido borrado y su posición— y esa información no está en el estado resultante. La única forma de recuperarla es capturarla en el instante en que aún existe. Por eso los comandos reales no son funciones puras aisladas sino cierres que memorizan su contexto de ejecución.
type Doc = { items: string[] }
// Correcto: el comando se construye capturando lo que va a destruir
function borrarItem(indice: number, borrado: string): Comando<Doc> {
return {
nombre: "borrar-item",
aplicar: (d) => ({ ...d, items: d.items.filter((_, i) => i !== indice) }),
revertir: (d) => ({
...d,
items: [...d.items.slice(0, indice), borrado, ...d.items.slice(indice)],
}),
}
}
// Incorrecto y silencioso: sin el valor borrado, la inversa inventa un hueco
// revertir: (d) => ({ ...d, items: [...d.items.slice(0, indice), "", ...] })
La regla general que se extrae de esto es que un comando debe construirse en el momento de la acción, no en el momento del deshacer, y debe llevar consigo todo lo que la operación vaya a hacer irrecuperable. Cuando la información destruida es voluminosa —el contenido de una tabla que se vacía, una selección de mil nodos que se elimina— el comando engorda hasta parecerse a una instantánea parcial, y eso es correcto y esperable: el enfoque no promete comandos pequeños, promete comandos proporcionales al cambio. Un cambio grande produce un comando grande, y ahí ya no hay ahorro que buscar.
Un comando que consulta el reloj, genera un identificador aleatorio o lee cualquier fuente externa produce un resultado distinto cada vez que se aplica, de modo que rehacer no reconstruye el mismo estado que la ejecución original. La solución es la misma que se usará en la última lección de este nivel para el replay: sacar la indeterminación fuera de la función y congelarla dentro del comando en el momento de crearlo. El identificador se genera una vez, al construir el comando, y a partir de ahí forma parte del dato, no del cálculo.
Transacciones y el coste de mantenimiento
Una acción del usuario rara vez corresponde a un único comando. Agrupar varias formas alinea cinco elementos, pegar un fragmento inserta un nodo y actualiza tres índices. Para que el deshacer respete la unidad perceptual de la acción —el asunto que ocupa por completo la lección cuatro de este nivel— los comandos se componen en uno solo. La composición es la operación natural del monoide, y su única sutileza es el orden: al revertir, los subcomandos se recorren en orden inverso, porque cada uno se escribió asumiendo el estado que dejó el anterior.
function transaccion<S>(nombre: string, cs: Comando<S>[]): Comando<S> {
return {
nombre,
aplicar: (s) => cs.reduce((acc, c) => c.aplicar(acc), s),
// LIFO estricto: el ultimo aplicado es el primero revertido
revertir: (s) => [...cs].reverse().reduce((acc, c) => c.revertir(acc), s),
}
}
Con la composición resuelta, el enfoque queda completo y es objetivamente superior a las instantáneas en coste, en expresividad y en transmisibilidad: un historial de comandos se puede enviar por la red, registrar en un log de auditoría, mostrar como una lista legible de acciones y reproducir en otro cliente. Y aun así muchos equipos lo abandonan, por una razón que no aparece en los tutoriales y que conviene enunciar sin adornos.
Lo que se gana
Coste proporcional al cambio, no al estado. Historial serializable y transmisible. Semántica explícita: cada entrada sabe qué significó. Base directa para colaboración, auditoría y sincronización.
Lo que se paga
Duplicación estructural: cada operación del producto necesita su inversa, escrita y mantenida a mano. El error no se manifiesta al escribirlo sino varios pasos después, lejos de su causa y sin traza que lo señale.
Ese desfase entre causa y síntoma es lo que hace peligroso al patrón. Una inversa incorrecta no lanza ninguna excepción: produce un estado plausible pero falso, que el usuario acepta sin darse cuenta y sobre el que sigue trabajando. La corrupción se acumula en silencio. El único antídoto proporcionado al riesgo son las pruebas basadas en propiedades: en lugar de comprobar casos concretos, se genera aleatoriamente una secuencia larga de comandos válidos y se verifica que aplicarlos todos y revertirlos todos devuelve el estado inicial. Esa única prueba cubre el espacio entero de combinaciones, incluidas las que nadie pensó.
El log de comandos vale más que el deshacer
Hay una consecuencia del patrón que suele descubrirse tarde y que por sí sola justifica adoptarlo. Al guardar operaciones en lugar de estados, el historial deja de ser una estructura interna y se convierte en un artefacto con valor propio: una descripción ordenada, legible y compacta de todo lo que ocurrió. Esa lista es simultáneamente el motor del deshacer, el registro de auditoría, el formato de sincronización entre dispositivos y el caso de prueba que reproduce un fallo. Cuatro necesidades que en otras arquitecturas se resuelven con cuatro mecanismos distintos, aquí las cubre el mismo dato.
flowchart LR A[accion del usuario] --> C[comando con inversa] C --> E[aplicar sobre el estado] C --> L[log de comandos] L --> D[deshacer y rehacer] L --> S[sincronizar con otros clientes] L --> AU[auditoria y trazabilidad] L --> RP[reproducir un fallo en local] style L fill:#cba6f7,color:#11111b
Esta es también la puerta de entrada al event sourcing, donde el log deja de ser un acompañante del estado y pasa a ser la única fuente de verdad: el estado actual no se guarda, se calcula plegando el log desde el principio. Llevado hasta ahí, el patrón cambia de escala y arrastra sus propios problemas —el pliegue completo se vuelve caro y hay que introducir instantáneas periódicas como puntos de apoyo, la evolución del esquema obliga a versionar cada tipo de comando, y el log crece sin techo—. La observación interesante es que la solución a la lentitud del pliegue es precisamente el enfoque de la lección anterior: los dos extremos del espectro acaban necesitándose el uno al otro.
// Event sourcing en miniatura: el estado es un pliegue del log
const estadoActual = log.reduce((s, c) => c.aplicar(s), inicial)
// Y el punto de apoyo periodico que lo hace viable
type Checkpoint<S> = { indice: number; estado: S }
const desdeCheckpoint = <S,>(cp: Checkpoint<S>, log: Comando<S>[]) =>
log.slice(cp.indice).reduce((s, c) => c.aplicar(s), cp.estado)
El giro que hay que dar para trabajar bien con comandos es dejar de ver la inversa como código auxiliar del deshacer y empezar a verla como parte de la definición de la operación. Una operación sin inversa conocida no es una operación incompleta a la que le falta una utilidad: es una operación que destruye información, y esa destrucción es un hecho sobre tu modelo de datos, no sobre tu interfaz. Cuando no consigues escribir la inversa de algo, la lectura correcta casi nunca es que el deshacer sea difícil, sino que el modelo pierde datos en un punto donde no debería, y muy a menudo ese mismo punto es el que más adelante hará imposible la auditoría, la sincronización entre dispositivos y la reproducción de un fallo reportado. Es la misma exigencia que reaparece en cada sistema serio que guarda historia: una migración de base de datos sin migración inversa, un evento sin evento compensatorio, un despliegue sin plan de vuelta atrás. Todos son la misma pregunta —cómo se desanda esto— formulada en dominios distintos, y todos comparten la misma lección: la reversibilidad no se añade al final, se diseña desde el principio o no existe. Escribir la inversa junto a la operación, en el mismo commit y en el mismo archivo, es lo que impide que esa deuda se acumule sin que nadie la vea.
- Implementa el tipo
Comandoy un registro con ejecutar, deshacer y rehacer sobre un documento con una lista de elementos. - Escribe los comandos insertar, borrar y renombrar. Comprueba cuál de los tres necesita capturar información en el momento de construirse y explica exactamente qué destruye.
- Escribe una prueba basada en propiedades que genere cien comandos aleatorios, los aplique todos, los revierta todos y verifique la igualdad profunda con el estado inicial.
- Rompe adrede la inversa de borrar para que restaure el elemento una posición más adelante y observa cuántos pasos tarda tu prueba en detectarlo frente a cuántos tardaría un usuario.
- Implementa
transacciony demuestra con un caso concreto que revertir en orden directo, y no inverso, produce un estado incorrecto. - Estima para un documento de diez mil formas la memoria de cien pasos por instantáneas frente a cien pasos por comandos, y sitúa el punto de cruce entre ambos enfoques.