Patrón Command en Rust: cuando la intención no necesita ser un objeto


En muchos sistemas necesitamos algo aparentemente simple: ejecutar una acción sin acoplar quién la solicita con quién la ejecuta. Queremos botones, menús, colas o eventos que disparen comportamientos sin conocer los detalles internos.

En el mundo de la programación orientada a objetos OOP, la respuesta clásica a este problema es el patrón Command.

Rust propone una perspectiva distinta para estos casos. Si intentamos replicar fielmente enfoques aprendidos en lenguajes OOP, pronto sentiremos fricción. Cuando eso ocurre, no es que el lenguaje nos limite; es una señal de que quizás estamos enfocando el problema desde un ángulo equivocado.

Pero antes de hablar de patrones, hablemos del problema real.

I. La intención detrás del patrón Command

El patrón Command intenta resolver algo muy concreto:

  • Encapsular una intención: lo que se quiere hacer, no cómo se hace
  • Desacoplar a quien solicita una acción de quien la ejecuta (el solicitante no necesita conocer al ejecutor).
  • Permitir que esa acción pueda ser diferido, ponerse en cola, registrarse o deshacerse.

La palabra clave aquí no es objeto. Es intención.

Una analogía perfecta de la vida real sería un restaurante

  • Tú (el cliente) tienes una intención: «quiero una pasta y una ensalada césar».
  • Le dices el pedido al camarero, quien lo anota en una comanda.
  • El camarero cuelga la comanda en la cocina (o la pone en una cola si hay muchas).
  • El chef la ve, prepara el plato y lo deja listo.
  • El camarero te lo sirve.

Aquí:

  • La comanda encapsula tu intención.
  • Tú no hablas con el chef (desacoplamiento total).
  • El pedido se ejecuta más tarde (diferido), se pone en cola, se registra para la cuenta (logging), y si cambias de idea puedes cancelarlo (undo).

La comanda no es un objeto sofisticado. Es solo un papel con una intención escrita.

Esto es exactamente lo que el patrón Command pretende resolver

II. La solución clásica en OOP

Si quitamos la metáfora, lo que queda es simple: una intención representada como dato, separada del mecanismo que la ejecuta. Veamos cómo se modela esto primero en OOP y luego en Rust.

/**
 * Command
 * Encapsula una intención como un objeto.
 */
interface Command {
  execute(): void;
  undo(): void;
}

/**
 * Estructura de datos que acompaña la intención del Command.
 * No modela comportamiento ni reglas de negocio.
 */
interface Dish {
  name: string;
  table: number;
}

/**
 * Receiver
 * En la analogía del restaurante, el Chef.
 * Contiene la lógica real que ejecuta la acción.
 */
class Chef {
  prepare(dish: Dish): void {
    console.log(
      `Chef: preparando ${dish.name} para la mesa ${dish.table}`
    );
  }

  cancel(dish: Dish): void {
    console.log(
      `Chef: cancelando ${dish.name} para la mesa ${dish.table}`
    );
  }
}

/**
 * ConcreteCommand
 * La comanda del restaurante.
 * Encapsula la intención + los datos necesarios para ejecutarla.
 */
class OrderDishCommand implements Command {
  private executed = false;

  constructor(
    private readonly chef: Chef,      // Receiver
    private readonly dish: Dish        // Intención
  ) {}

  execute(): void {
    this.chef.prepare(this.dish);
    this.executed = true;
  }

  undo(): void {
    if (!this.executed) {
      console.log("Comanda no ejecutada, nada que cancelar");
      return;
    }

    this.chef.cancel(this.dish);
    this.executed = false;
  }
}

/**
 * Invoker
 * En la analogía del restaurante, el Camarero.
 * No conoce detalles del negocio ni del Receiver.
 */
class Waiter {
  private queue: Command[] = [];
  private history: Command[] = [];

  takeOrder(command: Command): void {
    console.log("Camarero: pedido registrado");
    this.queue.push(command);
  }

  serveNext(): void {
    if (this.queue.length === 0) {
      console.log("Camarero: no hay pedidos en cola");
      return;
    }

    const command = this.queue.shift()!;
    command.execute();
    this.history.push(command);
  }

  cancelLast(): void {
    if (this.history.length === 0) {
      console.log("Camarero: no hay pedidos para cancelar");
      return;
    }

    const command = this.history.pop()!;
    command.undo();
  }
}

/**
 * Client
 * El cliente arma el sistema y dispara las acciones.
 */
const chef = new Chef();
const waiter = new Waiter();

waiter.takeOrder(
  new OrderDishCommand(chef, { name: "Pasta carbonara", table: 3 })
);

waiter.takeOrder(
  new OrderDishCommand(chef, { name: "Ensalada césar", table: 3 })
);

// Ejecución diferida
waiter.serveNext();

// Cancelación (undo)
waiter.cancelLast();

// Siguiente pedido
waiter.serveNext();

Salida en consola:

[LOG]: "Camarero: pedido registrado" 
[LOG]: "Camarero: pedido registrado" 
[LOG]: "Chef: preparando Pasta carbonara para la mesa 3" 
[LOG]: "Chef: cancelando Pasta carbonara para la mesa 3" 
[LOG]: "Chef: preparando Ensalada césar para la mesa 3" 

El patrón Command funciona muy bien en el contexto para el que fue concebido.
De hecho, si lo observamos desde el punto de vista clásico, su estructura es clara y coherente:

  • Una interfaz Command con un único método execute().
  • Varias implementaciones concretas, una por cada acción posible.
  • Un invocador que solo conoce la interfaz del comando.
  • Un receptor que contiene la lógica real de la operación.

Pero el resultado es una jerarquía de clases cuyo principal propósito es sostener el patrón.

Muchas de estas clases no existen por necesidad del dominio, sino únicamente para cumplir con la estructura del patrón:

  • Objetos que encapsulan una llamada,
  • Objetos cuya única responsabilidad es delegar.
  • Objetos intermedios que existen para permitir colas, historial o ejecuciones diferidas.

En esencia, estamos introduciendo una cantidad considerable de código ceremonial para modelar algo que, conceptualmente, es mucho más simple: una intención que debe ejecutarse en algún momento.

Esto no es un defecto del patrón en sí, en lenguajes OOP clásicos es una solución elegante, pero cuando llegamos a lenguajes más modernos, con modelos distintos como Rust esa ceremonia empieza a sentirse innecesaria.

Y ahí viene la pregunta clave :

¿Realmente necesitamos toda esta estructura o solo necesitamos una forma clara y segura de representar una intención pendiente de ejecución?

III. Cómo Rust aborda el problema

Rust no elimina el problema que el patrón Command resuelve. Lo que hace es disolver la necesidad de una estructura rígida para resolverlo.

Gracias a sus primitivas fundamentales, como closures, funciones como valores, enums con datos y una separación natural entre datos y comportamiento, Rust nos permite expresar la misma intención de forma mucho más directa y segura.

En lugar de una única forma “canónica”, Rust ofrece varias maneras de modelar una intención, cada una adecuada a un contexto distinto.

A continuación veremos algunas de las formas más idiomáticas de hacerlo, ordenadas de la más simple a la más estructurada, usando siempre la analogía del restaurante.

Caso 1: La No-Abstracción

«No abstraigas hasta que realmente lo necesites.»

Si tu ejecución es síncrona y directa, el patrón Command es solo ruido. En Rust, empezamos con lo más simple: estructuras de datos y métodos.

struct Dish {
    name: String,
    table: u32,
}

struct Chef;

impl Chef {
    fn prepare(&self, dish: &Dish) {
        println!("Chef: preparando {} para la mesa {}", dish.name, dish.table);
    }
}

fn main() {
    let chef = Chef;
    let dish1 = Dish { name: "Pasta carbonara".to_string(), table: 3 };
    let dish2 = Dish { name: "Ensalada césar".to_string(), table: 3 };

    println!("Camarero: pedido registrado");
    chef.prepare(&dish1);

    // Ejecución directa: sin intermediarios, sin complicaciones.
    println!("Camarero: pedido registrado");
    chef.prepare(&dish2);
}
  • Ventajas: Rendimiento máximo y legibilidad total.
  • Cuándo usarlo: Cuando la acción se ejecuta en el mismo momento en que se solicita.

Caso 2 – Diferir ejecución con funciones o enum

Aquí es donde el problema se pone interesante: el cliente ya no espera a que el Chef termine. El camarero anota y se va. Necesitamos guardar esa «intención» para ejecutarla después. En OOP, aquí ya estarías creando interfaces. En Rust, tenemos dos caminos mucho más ligeros:

Variante A: La Función como Dato (Punteros a funciones)

// Definimos un tipo para la acción: una función que sabe procesar un plato.
type Action = fn(&Dish);

struct Dish {
    name: String,
    table: u32,
}

fn prepare(dish: &Dish) {
    println!("Chef: preparando {} para la mesa {}", dish.name, dish.table);
}

fn main() {
    // La cola no guarda "Objetos Command", guarda punteros a funciones y datos.
    let mut queue: Vec<(Action, Dish)> = Vec::new();

    let table = 3;
    queue.push((prepare, Dish { name: "Pasta carbonara".to_string(), table }));
    queue.push((prepare, Dish { name: "Ensalada césar".to_string(), table }));

    println!("Camarero: pedido registrado");
    println!("Camarero: pedido registrado");

    // El "Invocador" es un simple bucle que consume la tupla.
    while let Some((action, dish)) = queue.pop() {
        action(&dish);
    }
}

Es la mínima expresión del patrón. No hay estructuras intermedias, solo punteros.

Aquí usamos fn(&Dish) (puntero a función), no Fn. Esto implica que la acción no captura estado, lo cual reduce overhead y hace el sistema más predecible.

  • Ventaja: Memoria mínima y ejecución directa.
  • Cuándo usarlo: Sistemas de callbacks o tareas simples donde la acción siempre es la misma función pero con distintos datos.

Variante B: El Enum (La Intención como Mensaje Idiomático)

Esta es la «joya de la corona» en Rust. En lugar de pasar una función, pasamos un mensaje que describe la intención. El receptor decide cómo reaccionar.

struct Dish {
    name: String,
    table: u32,
}


enum Order {
    Prepare(Dish),
}

fn main() {
    let mut queue: Vec<Order> = Vec::new();

    queue.push(Order::Prepare(Dish { name: "Pasta carbonara".to_string(), table: 3 }));
    queue.push(Order::Prepare(Dish { name: "Ensalada césar".to_string(), table: 3 }));

    println!("Camarero: pedido registrado");
    println!("Camarero: pedido registrado");

    while let Some(order) = queue.pop() {
        match order {
            Order::Prepare(dish) => println!("Chef: preparando {} para la mesa {}", dish.name, dish.table),
        }
    }
}

Esto es mejor por que en OOP si quieres añadir un comando Cancel, tienes que crear una nueva clase, implementar la interfaz y asegurar que el Invoker la acepte, en Rust, añades una variante al Enum y el compilador te dirá exactamente dónde te falta implementar la lógica (match).

Ventajas: Static Dispatch: No hay tablas de funciones virtuales (vtables). El procesador sabe exactamente a dónde saltar.

  • Exhaustividad: Si olvidas manejar un tipo de pedido, Rust no compila. En OOP, si olvidas un caso, fallas en tiempo de ejecución.
  • Separación Real: El Dish y el Order son datos puros. La lógica (el match) es el motor.

Caso 3: Estados Explícitos para Undo/Redo

Si necesitamos gestionar un historial o deshacer acciones, en OOP añadiríamos un método .undo() y un flag boolean executed. En Rust, podemos elevar el estado al sistema de tipos.

#[derive(Clone)]
struct Dish {
    name: String,
    table: u32,
}

enum Command {
    Pending(Dish), // Intención pura
    Executed(Dish), // Acción realizada
}

struct Waiter {
    queue: Vec<Command>,
    history: Vec<Command>,
}

impl Waiter {
    fn new() -> Self { Self { queue: Vec::new(), history: Vec::new() } }

    fn take_order(&mut self, dish: Dish) {
        println!("Camarero: pedido registrado");
        self.queue.push(Command::Pending(dish));
    }

    fn serve_next(&mut self) {
        let cmd = match self.queue.pop() {
            Some(c) => c,
            None => { println!("Camarero: no hay pedidos en cola"); return; }
        };
        match cmd {
            Command::Pending(dish) => {
                println!("Chef: preparando {} para la mesa {}", dish.name, dish.table);
                self.history.push(Command::Executed(dish));
            }
        }
    }

    fn cancel_last(&mut self) {
        let cmd = match self.history.pop() {
            Some(c) => c,
            None => { println!("Camarero: no hay pedidos para cancelar"); return; }
        };
        match cmd {
            Command::Executed(dish) => {
                println!("Chef: cancelando {} para la mesa {}", dish.name, dish.table);
            }
            _ => println!("Nada que cancelar"),
        }
    }
}

fn main() {
    let mut waiter = Waiter::new();
    waiter.take_order(Dish { name: "Pasta carbonara".to_string(), table: 3 });
    waiter.take_order(Dish { name: "Ensalada césar".to_string(), table: 3 });

    waiter.serve_next();  // Ensalada
    waiter.serve_next();  // Pasta
    waiter.cancel_last(); // Cancela Pasta
}

La intención evoluciona. No es un objeto estático con un flag, es un tipo que se transforma. Aquí el Command no tiene métodos, el estado es el comportamiento.

  • Ventajas: Eliminas de raíz el error de «intentar deshacer algo que no se ha hecho».
  • Cuándo usarlo: Sistemas con transacciones, editores de texto (undo/redo) o sistemas contables.

Caso 4 — Polimorfismo dinámico real (Box<dyn Trait>)

Cuando la extensibilidad es el único requisito, lo ideal es recurrir al patrón Command clásico de la GoF. Aquí volvemos a la «ceremonia»: interfaces, punteros a memoria (Box) y dispatch dinámico.

trait Command {
    fn execute(&mut self);
    fn undo(&mut self);
}

struct Dish {
    name: String,
    table: u32,
}

struct Chef;

impl Chef {
    fn prepare(&self, dish: &Dish) {
        println!("Chef: preparando {} para la mesa {}", dish.name, dish.table);
    }
    fn cancel(&self, dish: &Dish) {
        println!("Chef: cancelando {} para la mesa {}", dish.name, dish.table);
    }
}

struct OrderDishCommand {
    chef: Chef,
    dish: Dish,
    executed: bool,
}

impl Command for OrderDishCommand {
    fn execute(&mut self) {
        self.chef.prepare(&self.dish);
        self.executed = true;
    }

    fn undo(&mut self) {
        if !self.executed {
            println!("Comanda no ejecutada, nada que cancelar");
            return;
        }
        self.chef.cancel(&self.dish);
        self.executed = false;
    }
}

struct Waiter {
    queue: Vec<Box<dyn Command>>, // Polimorfismo dinámico
    history: Vec<Box<dyn Command>>, // Polimorfismo dinámico
}

impl Waiter {
    fn new() -> Self { Self { queue: Vec::new(), history: Vec::new() } }

    fn take_order(&mut self, cmd: Box<dyn Command>) {
        println!("Camarero: pedido registrado");
        self.queue.push(cmd);
    }

    fn serve_next(&mut self) {
        let mut cmd = match self.queue.pop() {
            Some(c) => c,
            None => { println!("Camarero: no hay pedidos en cola"); return; }
        };
        cmd.execute();
        self.history.push(cmd);
    }

    fn cancel_last(&mut self) {
        let mut cmd = match self.history.pop() {
            Some(c) => c,
            None => { println!("Camarero: no hay pedidos para cancelar"); return; }
        };
        cmd.undo();
    }
}

Es verboso. Tienes que usar box en cada comando, lo que implica reservas en el heap y una pequeña penalización de rendimiento (vtable). El comando vuelve a ser un «objeto inteligente» acoplado a su receptor.

Ventajas:

  • Máxima extensibilidad: cualquiera puede implementar Command.
  • Muy cercano al patrón clásico.
  • Ideal para plugins o sistemas grandes.

Desventajas:

  • Dispatch dinámico (vtable).
  • Requiere Box y heap allocation.
  • Más código ceremonial.

Cuándo usarlo: Cuando la extensibilidad es más importante que la seguridad estática o cuando integras con código externo.

IV. ¿Cuándo rendirse ante el Command clásico?

Hay momentos donde el Box<dyn Command> (el estilo GoF) es la herramienta correcta. Pero son casos específicos:

EscenarioPor qué usar el estilo clásico
Plugins externosCuando el usuario de tu librería quiere crear acciones que tú no conoces al compilar.
Scripting EnginesCuando cargas comandos dinámicamente desde un archivo Lua o Python.
APIs de TercerosCuando expones una interfaz que otros deben implementar en sus propios crates.

V. ¿Y qué pasa con SOLID?

1. Inversión de Dependencias (DIP)

En OOP clásica, el DIP nos obliga a crear interfaces para no depender de implementaciones concretas. En Rust, invertimos la dependencia de forma más natural:

  • OOP: El Invocador depende de interface Command.
  • Rust: El Invocador depende de una abstracción de datos (un Enum) o una firma de función (fn).

Sigues desacoplado, pero sin la burocracia de crear 10 archivos para 10 clases.

2. Abierto/Cerrado (OCP)

El miedo al match sobre un Enum es que «tienes que modificar el Enum para añadir un comando». Pero aquí entra el rigor de Rust:

  • El Compilador es tu Tests Unitario: Gracias al análisis de exhaustividad, si añades una acción al Enum, el código no compila hasta que manejes ese caso.
  • Seguridad vs. Flexibilidad: En OOP, el OCP es «abierto» pero frágil (puedes olvidar implementar algo y fallar en runtime). En Rust, el OCP es explícito y seguro.

VI. El Anti-Patrón: El «Reflejo OOP»

El error más frecuente al migrar desde lenguajes con paradigma OOP (Java, C#, C++, etc) es aplicar Box<dyn Command> por inercia.

¡Alerta de Fricción!

En Rust, forzar el polimorfismo dinámico (dyn) suele llevarte a pelear con el Borrow Checker y el Ownership. Si te encuentras luchando con lifetimes al intentar implementar un Command, detente: Probablemente necesites un Enum.

  • OOP: Convierte la intención en un objeto inteligente para que encaje en su jerarquía.
  • Rust: Mantiene la intención como un mensaje puro y deja que el sistema sea el cerebro.

La forma cambia porque el lenguaje es más capaz. No es que Rust no pueda hacer Command; es que en la mayoría de los casos, Rust ya lo superó.

VII. ¿Cómo modelo mi intención en Rust?

Usa esta tabla como brújula para decidir qué herramienta de Rust usar en lugar del patrón Command clásico de la GoF.

Si necesitas…Usa en Rust…¿Por qué?
Ejecución inmediata y simple.Métodos/FuncionesNo añadas abstracción si no hay necesidad de diferir la acción.
Diferir una acción única (ej. un callback de botón).Closures (Fn, FnMut)Es ligero y captura el contexto automáticamente sin crear clases.
Una cola de acciones conocidas en tiempo de compilación.Enums con datosEs la forma más rápida (Static Dispatch) y segura (Exhaustividad).
Gestionar estados (Pendiente, Éxito, Fallo).Enums de estadoPermite usar el sistema de tipos para evitar errores lógicos (máquina de estados).
Extensibilidad total (Plugins o scripts externos).Box<dyn Trait>Es el «último recurso» cuando no conoces los tipos de antemano.

Abstrae la Intención, no la Jerarquía

El error común es pensar que si no hay una interfaz Command, no hay patrón. Pero el patrón Command no se define por sus clases, sino por su capacidad de convertir una acción en un objeto manipulable.

Rust nos enseña que:

  1. La mayoría de las veces, un Enum es superior a una jerarquía de clases.
  2. Los datos deben ser tontos y el sistema inteligente.
  3. La «ceremonia» de la OOP es a menudo una prótesis para lenguajes que no tienen tipos de datos potentes.

No busques cómo implementar el diagrama de clases de la GoF. Pregúntate: ¿Cómo puedo representar esta intención como un dato puro? Ahí es donde realmente empezarás a escribir Rust.

Si eres un arquitecto de software experimentado, estarás pensando: «Si quito el patrón Command, estoy rompiendo los principios SOLID«. La realidad es que Rust no rompe SOLID; lo simplifica.

Conclusión:

Rust nos ofrece un espectro de opciones, desde lo más simple y eficiente hasta lo más flexible, priorizando siempre la seguridad, rendimiento y claridad. El patrón Command no desaparece: se transforma en algo más natural, más seguro y mucho menos verboso. La intención sigue siendo la protagonista, pero ya no necesita una pesada maquinaria de objetos para viajar por el sistema.

benjamin
Soy Benjamín Gonzales, desarrollador de software con más de 20 años de experiencia. Mi pasión por la tecnología me lleva a estar siempre a la vanguardia, buscando soluciones innovadoras y escalables para cada desafío.

1 Comment

Leave a Comment

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

This site uses Akismet to reduce spam. Learn how your comment data is processed.