How to Implement the Command Pattern in Rust

Implement the Command pattern in Rust by defining a trait for actions and using an invoker to execute them dynamically.

When actions need to travel

You are building a text editor. The user types a character, deletes a word, and then presses Ctrl+Z. The word reappears. How do you reverse an action? You cannot just "un-type." You need to capture what happened, store it, and then run a reverse operation.

Or imagine a game controller. You press a button, and the character jumps. Later, you want to record a macro. You press the button again, and the system saves that action to a list. When you play the macro, the system replays the jump. The button press is no longer just a signal; it is an object that travels from the input handler to a queue, and eventually to the game engine.

The Command pattern solves this by turning a verb into a noun. An action becomes a value you can store, pass around, queue, and undo. In Rust, this pattern maps naturally to traits and trait objects, but the language also offers closures and enums that change the calculus. You need to know which tool fits your constraints.

The ticket in the kitchen

Think of a busy restaurant. A customer orders a burger. The waiter writes the order on a ticket and drops it in the kitchen window. The cook picks up the ticket and makes the burger. The cook does not care who ordered it. The cook only cares that the ticket says "burger" and knows how to make it.

The ticket is the command. The waiter is the client that creates the command. The kitchen window is the invoker that receives and executes commands. The cook is the receiver that performs the actual work. The decoupling is key. The waiter never talks to the cook directly. The waiter just produces tickets. The kitchen can process tickets in any order, save them for later, or discard them.

In Rust, the ticket is a struct that implements a trait. The trait defines the interface, usually an execute method. The invoker holds a collection of these trait objects and calls execute when the time is right.

Minimal trait object

Start with the trait. The trait defines the contract. Every command must implement execute.

/// A trait for any action that can be executed.
trait Command {
    fn execute(&self);
}

/// A concrete command that turns on a light.
struct TurnOnLight;

impl Command for TurnOnLight {
    fn execute(&self) {
        println!("Light is ON");
    }
}

/// The invoker holds a command and runs it.
struct Invoker {
    // Box<dyn Command> erases the concrete type.
    // This allows the Invoker to hold any type that implements Command.
    command: Box<dyn Command>,
}

impl Invoker {
    fn new(command: Box<dyn Command>) -> Self {
        Invoker { command }
    }

    fn invoke(&self) {
        // Dynamic dispatch: the compiler looks up the vtable at runtime.
        self.command.execute();
    }
}

fn main() {
    // Box::new allocates on the heap.
    // The TurnOnLight struct is small, but the Box is necessary
    // because Command is a trait, not a sized type.
    let light_on = Box::new(TurnOnLight);
    let invoker = Invoker::new(light_on);
    invoker.invoke();
}

The Box<dyn Command> is the critical piece. dyn tells the compiler this is a trait object. The compiler erases the concrete type TurnOnLight and replaces it with a pointer to the data and a pointer to a virtual table (vtable). The vtable contains the function pointers for the trait methods. When you call execute, the code jumps through the vtable to find the right implementation.

Convention aside: always use Box::new(value) explicitly when creating trait objects. Writing Box::new(TurnOnLight) makes the allocation obvious. Some developers write Box(TurnOnLight) which also works, but the explicit new is the community standard for readability. It signals that you are boxing a value, not just wrapping it.

What the compiler does

If you try to store a trait directly, the compiler rejects you. Traits are interfaces, not data. They have no size.

struct BadInvoker {
    // This fails. Command is a trait, not a type with a known size.
    command: Command,
}

The compiler emits E0038: the trait Command has no size known at compile-time. You cannot put a trait in a struct field. You must use a pointer. Box<dyn Command> is the standard choice because it owns the data. If you use &dyn Command, you introduce lifetimes. The invoker must borrow the command, which means the command must live longer than the invoker. That constraint often makes the code unworkable for queues or history stacks. Box owns the command, so the invoker can store it indefinitely.

Dynamic dispatch has a cost. The vtable lookup adds a small overhead compared to a direct function call. The compiler cannot inline execute because it does not know the concrete type at compile time. For most applications, this overhead is negligible. For tight loops processing millions of commands, it matters.

Undo requires state

The real power of the Command pattern appears when you need to reverse actions. An undo operation requires the command to remember what it did. The command must store state.

/// A command that can be executed and undone.
trait Command {
    fn execute(&mut self);
    fn undo(&mut self);
}

/// A command that changes a character at a position.
/// It stores the old character to support undo.
struct ChangeChar {
    position: usize,
    new_char: char,
    old_char: Option<char>,
}

impl Command for ChangeChar {
    fn execute(&mut self) {
        // In a real editor, this would modify the document.
        // Here we just print and save the old char.
        if self.old_char.is_none() {
            // Simulate reading the old char from the document.
            self.old_char = Some('A');
            println!("Changed char at {} to '{}'", self.position, self.new_char);
        }
    }

    fn undo(&mut self) {
        if let Some(old) = self.old_char.take() {
            println!("Restored char '{}' at {}", old, self.position);
        }
    }
}

/// A macro recorder stores commands in a history stack.
struct MacroRecorder {
    history: Vec<Box<dyn Command>>,
}

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

    fn record(&mut self, command: Box<dyn Command>) {
        self.history.push(command);
    }

    fn undo(&mut self) {
        if let Some(command) = self.history.pop() {
            // We need mutable access to undo.
            // Box allows us to mutate the inner command.
            command.undo();
        }
    }
}

fn main() {
    let mut recorder = MacroRecorder::new();
    let change = Box::new(ChangeChar {
        position: 0,
        new_char: 'B',
        old_char: None,
    });
    recorder.record(change);
    
    // Execute is usually called by the invoker, but here we execute
    // before recording to simulate the flow.
    // In a real system, execute might happen automatically on record.
    recorder.undo();
}

The ChangeChar struct stores old_char. When execute runs, it captures the state before changing it. When undo runs, it restores the state. The Option<char> handles the case where undo is called without execute, or after undo has already been called.

Ah-ha reveal: you can implement Command for closures, but you cannot add methods to a closure type. Closures are anonymous structs generated by the compiler. You cannot write impl Command for |x| x + 1. If you need named methods like undo, you must use a struct. Closures work for one-shot commands, but structs win when the command has complex lifecycle methods.

Closures vs Traits

Rust developers often reach for closures instead of traits. A closure is a command in disguise. It captures environment and implements Fn, FnMut, or FnOnce.

/// A simple invoker that accepts any one-shot action.
struct SimpleInvoker {
    // Box<dyn FnOnce()> holds a closure that can be called once.
    action: Box<dyn FnOnce()>,
}

impl SimpleInvoker {
    fn new<F: FnOnce() + 'static>(action: F) -> Self {
        SimpleInvoker {
            action: Box::new(action),
        }
    }

    fn invoke(self) {
        (self.action)();
    }
}

fn main() {
    let message = String::from("Hello");
    let invoker = SimpleInvoker::new(move || {
        println!("{}", message);
    });
    invoker.invoke();
}

This code is shorter. You do not need a trait. You do not need a struct. The closure captures message and runs it. The move keyword forces the closure to take ownership of message, so the closure can outlive the scope.

The trade-off is flexibility. A closure has no name. You cannot call undo on a closure unless you encode undo logic inside the closure itself, which gets messy. A closure cannot be serialized easily. A struct command can implement serde::Serialize if you need to save the command to disk.

Convention aside: use Box<dyn FnOnce()> for one-off tasks. Use Box<dyn Command> when you need undo, retry, or serialization. The trait gives you a handle to the command's lifecycle. The closure gives you brevity. Pick based on requirements, not habit.

The enum alternative

Trait objects allocate memory. Every Box<dyn Command> requires a heap allocation. If you process millions of commands, the allocator becomes a bottleneck. You can eliminate allocations by using an enum.

/// A closed set of commands.
enum Command {
    TurnOnLight,
    TurnOffLight,
    SetVolume(u8),
}

struct FastInvoker {
    command: Command,
}

impl FastInvoker {
    fn execute(&self) {
        match self.command {
            Command::TurnOnLight => println!("Light ON"),
            Command::TurnOffLight => println!("Light OFF"),
            Command::SetVolume(v) => println!("Volume: {}", v),
        }
    }
}

The enum is a single value. No heap allocation. No vtable lookup. The compiler generates a direct jump table or a series of comparisons. This is zero-cost abstraction. The downside is extensibility. Every time you add a new command, you must update the enum and the match statement. The trait approach allows new commands without modifying the invoker.

Ah-ha reveal: enums are not just for performance. They enforce a closed world. If your domain has a fixed set of actions, an enum documents that fact. The compiler ensures you handle every case. A trait object allows unknown commands to slip in. Use the enum when the set of commands is stable and known.

Pitfalls and errors

Commands often capture state. If a command borches data, you run into lifetime issues.

struct BorrowingCommand<'a> {
    data: &'a str,
}

impl Command for BorrowingCommand<'_> {
    fn execute(&self) {
        println!("{}", self.data);
    }
}

This works if the invoker has the same lifetime. But if you store the command in a Vec that outlives the data, the compiler rejects you with E0597: borrowed value does not live long enough. The command must own its data or use a lifetime that matches the storage. Box<dyn Command> without lifetimes requires 'static. The command must own all its data. If you need to borrow, you must thread lifetimes through the invoker, which complicates the API.

Another pitfall is interior mutability. If execute takes &self, the command cannot mutate its own state. You need RefCell or Mutex inside the struct.

use std::cell::RefCell;

struct CounterCommand {
    count: RefCell<u32>,
}

impl Command for CounterCommand {
    fn execute(&self) {
        *self.count.borrow_mut() += 1;
        println!("Count: {}", *self.count.borrow());
    }
}

RefCell allows mutation through an immutable reference. It panics at runtime if you violate borrowing rules. Use RefCell when you need interior mutability in a single-threaded context. For multi-threaded commands, use Mutex.

Error check: if you try to put a non-Send type in a Box<dyn Command> and send it across threads, the compiler emits E0277: Command does not implement Send. Add + Send to the trait object if you need thread safety. Box<dyn Command + Send> ensures the command can be moved between threads.

Decision matrix

Use Box<dyn Command> when you need a heterogeneous queue of actions, undo support, or extensibility without modifying the invoker. The trait object allows any type to become a command. The allocation cost is acceptable for most applications.

Use an enum when the set of commands is fixed, performance is critical, and you want to avoid heap allocations. The enum provides zero-cost dispatch and exhaustive matching. Update the enum whenever the domain grows.

Use Box<dyn FnOnce()> when you need a simple, one-shot action and do not require named methods or undo logic. Closures are concise and capture environment automatically. They are ideal for callbacks and event handlers.

Use a generic Invoker<C: Command> when you can constrain the command type at compile time and want zero-cost abstraction. Generics monomorphize the code, eliminating dynamic dispatch. This works when the invoker handles a single command type.

Treat the trait object as a contract. If the performance numbers do not add up, reach for an enum. Closures are commands in disguise. Use them when the disguise is all you need.

Where to go next