Skip to content
rusthacks.dev
Architecture

Command Pattern and Undo in Rust Built With Enums Explained

Implement undo and redo in Rust with the command pattern: commands as an enum, execute and undo methods, history stacks, merging, and a text editor example.

19 min read
Summarize in AI
Flat illustration for the post “Command Pattern and Undo in Rust Built With Enums Explained”, composed around architecture.

Undo looks like a feature you add at the end and turns out to be a design decision you make at the start: if edits are not values you can store, you cannot reverse them. The command pattern fixes that by turning each action into a small value that knows how to be applied and how to be undone. In an object-oriented language it is a class hierarchy. In Rust an enum is a better fit, because the set of edits is closed, match checks that every one is handled, and the values are cloneable and comparable. This guide builds a text buffer with undo, redo, typing coalescing and a bounded history, tests it with a property test and compares it with the trait-object version. All code compiled and ran on Rust 1.99, edition 2024, with proptest 1.x in the tests.

Edits as data

The first design decision is the vocabulary: what can the user do, written as a type.

src/lib.rsrust
//! A text buffer whose edits are values, so they can be applied, undone and redone.

/// What the user asked for. Plain data: easy to print, compare, store and replay.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
    Insert {
        at: usize,
        text: String,
    },
    Delete {
        at: usize,
        len: usize,
    },
    /// Several edits that succeed or fail together and undo as one step.
    Batch(Vec<Command>),
}

/// What the buffer needs to remember to reverse a command.
/// A delete cannot be undone from the command alone: the removed text is gone.
#[derive(Debug, Clone, PartialEq)]
enum Undo {
    RemoveInserted { at: usize, len: usize },
    Restore { at: usize, text: String },
    Many(Vec<Undo>),
}

#[derive(Debug, PartialEq)]
pub enum EditError {
    OutOfRange { at: usize, len: usize },
}

#[derive(Default)]
pub struct Editor {
    text: String,
    done: Vec<(Command, Undo)>,
    undone: Vec<Command>,
    limit: Option<usize>,
}

impl Editor {
    pub fn new(initial: &str) -> Editor {
        Editor {
            text: initial.to_string(),
            ..Editor::default()
        }
    }

    /// Keep at most `n` undo steps; the oldest are forgotten first.
    pub fn with_history_limit(mut self, n: usize) -> Editor {
        self.limit = Some(n);
        self
    }

    pub fn history_len(&self) -> usize {
        self.done.len()
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    /// Runs a command and records how to reverse it. A new edit invalidates redo.
    pub fn apply(&mut self, cmd: Command) -> Result<(), EditError> {
        let undo = self.run(&cmd)?;
        self.undone.clear();

        if self.try_merge(&cmd) {
            return Ok(());
        }
        self.done.push((cmd, undo));
        if self.limit.is_some_and(|n| self.done.len() > n) {
            self.done.remove(0);
        }
        Ok(())
    }

    /// Typing "abc" one key at a time should undo as one step: fold an insert that
    /// continues exactly where the previous insert ended into that insert's record.
    fn try_merge(&mut self, cmd: &Command) -> bool {
        let (
            Command::Insert { at, text },
            Some((
                Command::Insert {
                    at: prev_at,
                    text: prev_text,
                },
                Undo::RemoveInserted { len, .. },
            )),
        ) = (cmd, self.done.last_mut())
        else {
            return false;
        };
        if *at != *prev_at + prev_text.len() {
            return false;
        }
        prev_text.push_str(text);
        *len += text.len();
        true
    }

    pub fn undo(&mut self) -> bool {
        let Some((cmd, undo)) = self.done.pop() else {
            return false;
        };
        self.reverse(&undo);
        self.undone.push(cmd);
        true
    }

    fn reverse(&mut self, undo: &Undo) {
        match undo {
            Undo::RemoveInserted { at, len } => {
                self.text.replace_range(*at..*at + *len, "");
            }
            Undo::Restore { at, text } => {
                self.text.insert_str(*at, text);
            }
            // Later edits may depend on earlier ones, so reverse in the opposite order.
            Undo::Many(steps) => {
                for step in steps.iter().rev() {
                    self.reverse(step);
                }
            }
        }
    }

    pub fn redo(&mut self) -> bool {
        let Some(cmd) = self.undone.pop() else {
            return false;
        };
        let undo = self
            .run(&cmd)
            .expect("a command that applied before applies again");
        self.done.push((cmd, undo));
        true
    }

    fn run(&mut self, cmd: &Command) -> Result<Undo, EditError> {
        match cmd {
            Command::Insert { at, text } => {
                self.check(*at, 0)?;
                self.text.insert_str(*at, text);
                Ok(Undo::RemoveInserted {
                    at: *at,
                    len: text.len(),
                })
            }
            Command::Delete { at, len } => {
                self.check(*at, *len)?;
                let removed: String = self.text[*at..*at + *len].to_string();
                self.text.replace_range(*at..*at + *len, "");
                Ok(Undo::Restore {
                    at: *at,
                    text: removed,
                })
            }
            Command::Batch(cmds) => {
                let mut done = Vec::new();
                for cmd in cmds {
                    match self.run(cmd) {
                        Ok(undo) => done.push(undo),
                        Err(e) => {
                            // All or nothing: put back what the earlier commands changed.
                            self.reverse(&Undo::Many(done));
                            return Err(e);
                        }
                    }
                }
                Ok(Undo::Many(done))
            }
        }
    }

    fn check(&self, at: usize, len: usize) -> Result<(), EditError> {
        let ok = at + len <= self.text.len()
            && self.text.is_char_boundary(at)
            && self.text.is_char_boundary(at + len);
        if ok {
            Ok(())
        } else {
            Err(EditError::OutOfRange {
                at,
                len: self.text.len(),
            })
        }
    }
}

The file has three types worth looking at before the logic.

Command is what the user asked for: insert text at a byte offset, or delete a range. It is plain data. You can print it ({:?}), compare two of them with ==, clone one, store a list of them to replay later or send one over a network. That last property is why enums beat closures here: a Box<dyn Fn> can be run but not inspected, saved or compared.

Undo is what the editor needs to reverse a command, and it is not the same thing. This is the first idea to take away. For an insert, reversing it needs only the position and length, so RemoveInserted { at, len }. For a delete, the command says which range to remove but not what was in it, and once it runs the text is gone. The only way to undo is to have captured the removed text when the command ran, which is what Restore { at, text } holds. A design where Command::Delete has an invert() method that works from the command alone cannot exist. Either the command stores the previous state (as the object-oriented version does, below) or the executor returns it, as here.

EditError is the failure case. A command can name a range that does not exist, and the editor must reject it rather than panic, because commands may come from a user or from a replayed log.

Apply, undo, redo

Editor::apply runs a command and records the pair of the command and its undo record:

run is the only place that changes the text. It calls check first, which verifies that the range is inside the buffer and that both ends fall on UTF-8 character boundaries; slicing a String in the middle of a character panics, so the check turns that into an EditError. For Insert it inserts and returns RemoveInserted. For Delete it copies the doomed range out *before* removing it, then returns Restore holding the copy.

undo pops the last pair, performs the inverse and moves the command to the undone stack, so redo can run it again. redo re-runs the command through run, which regenerates the undo record, and pushes it back on done. The expect there is justified: a command that applied successfully against a given buffer state applies again against the same state, and undo restores that state exactly.

One rule of every undo system lives in the last lines of apply: after a new edit, self.undone.clear(). Redo only makes sense along the history you just walked back through. Once you type something new, the future you undid is gone, and keeping it would let redo apply a command to a buffer it was never valid for.

Drive it from main:

src/main.rsrust
use undo::{Command, Editor};

fn main() {
    let mut ed = Editor::new("hello world");
    let steps = [
        Command::Delete { at: 5, len: 6 },
        Command::Insert {
            at: 5,
            text: ", Rust".into(),
        },
        Command::Insert {
            at: 0,
            text: ">> ".into(),
        },
    ];
    for cmd in steps {
        println!("apply {:?}", cmd);
        ed.apply(cmd).unwrap();
        println!("  -> {:?}", ed.text());
    }
    while ed.undo() {
        println!("undo -> {:?}", ed.text());
    }
    println!("redo twice");
    ed.redo();
    ed.redo();
    println!("  -> {:?}", ed.text());
    ed.apply(Command::Delete { at: 0, len: 2 }).unwrap();
    println!("new edit clears redo: redo() = {}", ed.redo());
    println!("{:?}", ed.apply(Command::Delete { at: 99, len: 1 }));
}
Text
apply Delete { at: 5, len: 6 }
  -> "hello"
apply Insert { at: 5, text: ", Rust" }
  -> "hello, Rust"
apply Insert { at: 0, text: ">> " }
  -> ">> hello, Rust"
undo -> "hello, Rust"
undo -> "hello"
undo -> "hello world"
redo twice
  -> "hello, Rust"
new edit clears redo: redo() = false
Err(OutOfRange { at: 99, len: 9 })

Three edits turn "hello world" into ">> hello, Rust", three undos walk back to the start, and two redos replay the first two edits. A fresh edit then clears the redo stack (redo() is false), and the last line shows a rejected command: deleting at offset 99 of a 9-byte buffer returns Err(OutOfRange { at: 99, len: 9 }) and changes nothing.

The bug a delete hides

Everything hinges on Restore holding the removed text. Take it out. Change text: removed to an empty string, so the undo of a delete restores nothing, and write the property test that follows. A hand-written test would use an example like "delete a word, undo, compare", and it fails at once. The property test is stronger because it does not need you to think of the example:

tests/roundtrip.rsrust
use proptest::prelude::*;
use undo::{Command, Editor};

fn command(len: usize) -> impl Strategy<Value = Command> {
    prop_oneof![
        (0..=len, "[a-zé]{0,4}").prop_map(|(at, text)| Command::Insert { at, text }),
        (0..=len, 0..4usize).prop_map(|(at, len)| Command::Delete { at, len }),
    ]
}

proptest! {
    // Apply any sequence of edits, undo them all: the original text must come back.
    #[test]
    fn undoing_everything_restores_the_original(
        initial in "[a-z ]{0,12}",
        cmds in prop::collection::vec(command(12), 0..12),
    ) {
        let mut ed = Editor::new(&initial);
        for cmd in cmds {
            let _ = ed.apply(cmd); // out-of-range commands are rejected and recorded nowhere
        }
        while ed.undo() {}
        prop_assert_eq!(ed.text(), initial.as_str());
    }

    // Undo then redo everything must reproduce the edited text.
    #[test]
    fn redo_replays_the_same_result(
        initial in "[a-z ]{0,12}",
        cmds in prop::collection::vec(command(12), 0..12),
    ) {
        let mut ed = Editor::new(&initial);
        for cmd in cmds {
            let _ = ed.apply(cmd);
        }
        let edited = ed.text().to_string();
        let mut n = 0;
        while ed.undo() { n += 1; }
        for _ in 0..n { prop_assert!(ed.redo()); }
        prop_assert_eq!(ed.text(), edited.as_str());
    }
}

The first property says: for any starting text and any sequence of up to twelve edits, apply them all, undo them all, and the original text comes back. The second says undoing and redoing everything reproduces the edited text. The strategy command(len) generates inserts of up to four letters (including é, a two-byte character) and deletes of up to four bytes, at any offset up to the length. Edits that fall outside the buffer are rejected by apply and recorded nowhere, which the test deliberately ignores with let _ =.

With the broken Restore, proptest fails and shrinks the input:

Text
Test failed: assertion failed: `(left == right)`
  left: `"a"`,
 right: `" a"` at tests/roundtrip.rs:23.
minimal failing input: initial = " a", cmds = [
    Insert {
        at: 0,
        text: "é",
    },
    Insert {
        at: 2,

The left side is what undo produced, "a", and the right is the original, " a": the space was deleted by one of the commands and never came back. The listing continues with the sequence of commands that triggered it; proptest shrinks as far as it can, but a sequence of edits is not always reducible to one, and it prints what it found. A second bug appears if you remove the character-boundary checks from check:

Text
thread 'undoing_everything_restores_the_original' panicked at src/lib.rs:89:48:
assertion failed: self.is_char_boundary(idx)

The panic is inside the standard library's string slicing: a delete that started in the middle of the two-byte é was allowed to run. The generator includes é precisely so that this class of error is reachable; a test using only ASCII letters would never find it. Both are from the technique described in property testing with proptest, and the second is the reason slicing by byte offset is risky, as in strings explained. With both checks restored, the two properties pass:

Text
test undoing_everything_restores_the_original ... ok
test redo_replays_the_same_result ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

Typing is one step, not five

Users do not think of typing "hello" as five edits, and undoing one letter at a time would be maddening. The fix is coalescing: when a new insert begins exactly where the previous insert ended, extend the previous history entry instead of pushing a new one. try_merge does it, by pattern-matching on the new command and the last history entry at once, and apply calls it before pushing:

tests/behaviour.rsrust
use undo::{Command, Editor};

fn insert(at: usize, text: &str) -> Command {
    Command::Insert {
        at,
        text: text.to_string(),
    }
}

#[test]
fn typing_is_one_undo_step() {
    let mut ed = Editor::new("");
    for (i, ch) in "hello".chars().enumerate() {
        ed.apply(insert(i, &ch.to_string())).unwrap();
    }
    assert_eq!(ed.text(), "hello");
    assert_eq!(ed.history_len(), 1);
    assert!(ed.undo());
    assert_eq!(ed.text(), "");
    assert!(!ed.undo());
}

#[test]
fn a_jump_starts_a_new_step() {
    let mut ed = Editor::new("");
    ed.apply(insert(0, "ab")).unwrap();
    ed.apply(insert(0, "X")).unwrap(); // not at the end of the previous insert
    assert_eq!(ed.history_len(), 2);
    ed.undo();
    assert_eq!(ed.text(), "ab");
}

#[test]
fn history_is_bounded() {
    let mut ed = Editor::new("x").with_history_limit(2);
    ed.apply(Command::Delete { at: 0, len: 1 }).unwrap();
    ed.apply(insert(0, "a")).unwrap();
    ed.apply(Command::Delete { at: 0, len: 1 }).unwrap();
    ed.apply(insert(0, "b")).unwrap();
    assert_eq!(ed.history_len(), 2);
    while ed.undo() {}
    // The two oldest steps were forgotten, so undo stops at the state before step 3.
    assert_eq!(ed.text(), "a");
}

#[test]
fn redo_is_cleared_by_a_new_edit() {
    let mut ed = Editor::new("abc");
    ed.apply(Command::Delete { at: 0, len: 1 }).unwrap();
    ed.undo();
    ed.apply(insert(3, "!")).unwrap();
    assert!(!ed.redo());
}

#[test]
fn a_batch_is_one_undo_step() {
    let mut ed = Editor::new("hello world");
    ed.apply(Command::Batch(vec![
        Command::Delete { at: 0, len: 5 },
        insert(0, "HELLO"),
        insert(5, "!"),
    ]))
    .unwrap();
    assert_eq!(ed.text(), "HELLO! world");
    assert_eq!(ed.history_len(), 1);
    ed.undo();
    assert_eq!(ed.text(), "hello world");
    ed.redo();
    assert_eq!(ed.text(), "HELLO! world");
}

#[test]
fn a_failing_batch_changes_nothing() {
    let mut ed = Editor::new("abc");
    let result = ed.apply(Command::Batch(vec![
        Command::Delete { at: 0, len: 1 },
        insert(0, "XYZ"),
        Command::Delete { at: 50, len: 1 }, // out of range: the whole batch must be rolled back
    ]));
    assert!(result.is_err());
    assert_eq!(ed.text(), "abc");
    assert_eq!(ed.history_len(), 0);
}
Text
test a_jump_starts_a_new_step ... ok
test a_batch_is_one_undo_step ... ok
test a_failing_batch_changes_nothing ... ok
test history_is_bounded ... ok
test typing_is_one_undo_step ... ok
test redo_is_cleared_by_a_new_edit ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The two batch tests at the end of that file belong to the next section.

typing_is_one_undo_step applies five single-character inserts at consecutive offsets and finds history_len() equal to 1, and one undo empties the buffer. a_jump_starts_a_new_step shows the boundary: inserting at offset 0 after inserting "ab" is not a continuation, so it is a second step and undo brings back "ab", not the empty string. Real editors add more rules (break the group on a pause, on a space, or after a cursor move), and each rule is another condition in try_merge, which is the one function to change. Merging operates on the history entry, not on the command, so replaying the original list of commands still reproduces the exact edits.

history_is_bounded covers the other practical concern. An editor running for hours with every keystroke stored grows without limit, and each Restore copies deleted text. with_history_limit(2) drops the oldest step when a third is pushed; after four edits only two remain, and undoing everything stops at the state before the third step, here "a", because the first two edits have been forgotten. The cost of Vec::remove(0) is O(n) and fine for a history of hundreds; use a VecDeque if you keep thousands.

Grouping commands: a batch that is all or nothing

"Replace all" is many edits the user thinks of as one. The same need appears in macros, in paste-with-formatting and in any operation that touches several places. Command::Batch(Vec<Command>) handles it, and the matching Undo::Many(Vec<Undo>) records the reversal of each part. The interesting code is in run, where the Batch arm applies its commands in order and collects each undo record. Two rules make it correct.

First, undo runs in the opposite order. A batch that deletes a word and then inserts a new one at that spot cannot be reversed by restoring the word first, because the insert is still there and the offsets no longer line up. reverse walks steps.iter().rev(), so the last edit is undone first and every undo sees the buffer in the state it was written for. This is the same reasoning as popping a stack.

Second, a batch is atomic. If the third command is out of range, the first two have already changed the text, and returning the error would leave a half-applied edit. The Err arm calls self.reverse(&Undo::Many(done)) on whatever succeeded so far before returning, so the buffer is exactly as it was. Remove that line and the test shows what you would have shipped:

Text
thread 'a_failing_batch_changes_nothing' panicked at tests/behaviour.rs:81:5:
assertion `left == right` failed
  left: "XYZbc"
 right: "abc"

The batch deleted a, inserted XYZ, failed on the third command and left the buffer as XYZbc. With the rollback restored, a_failing_batch_changes_nothing passes, the history is empty and the text is abc. a_batch_is_one_undo_step shows the happy path: three commands turn hello world into HELLO! world, history_len() is 1, one undo returns hello world and redo replays the whole batch. Because Batch holds ordinary commands, it can be printed, compared and stored like any other, and batches can nest.

Because the property tests generate only inserts and deletes, they do not cover batches. Extending the generator is a five-line change (prop::collection::vec(command(len), 1..4).prop_map(Command::Batch)), and it is the right next step whenever you add a variant: the compiler tells you which match statements to update, and the test generator is the one place it cannot.

Offsets are bytes

Every at and len in this editor is a byte offset into a String, because that is what insert_str and replace_range take, and it is the cheapest to work with. It is also the source of the nastiest input: a byte offset can land inside a character. A small program makes the rule visible:

examples/offsets.rsrust
use undo::{Command, Editor};

fn main() {
    let mut ed = Editor::new("café au lait");
    println!("{} bytes, {} chars", ed.text().len(), ed.text().chars().count());
    // 'é' is bytes 3..5. Offset 4 is inside it.
    println!("{:?}", ed.apply(Command::Delete { at: 4, len: 1 }));
    println!("{:?}", ed.apply(Command::Delete { at: 3, len: 2 }));
    println!("{:?}", ed.text());
}
Text
13 bytes, 12 chars
Err(OutOfRange { at: 4, len: 13 })
Ok(())
"caf au lait"

café au lait has 12 characters and 13 bytes, because é takes two. A delete of one byte at offset 4 starts in the middle of é, and check rejects it with OutOfRange instead of letting the standard library panic. Deleting two bytes at offset 3 removes the whole character and works. A real editor would translate cursor positions from characters or grapheme clusters to byte offsets before it builds a command, so that this error never reaches a user; the engine's job is only to refuse invalid input safely. The reasoning for why strings are not indexable by position is in strings explained.

What an unbounded history costs

The history limit is not a nicety, so measure it. This program applies 300,000 edits, alternating a 64-byte delete with a 64-byte insert, to a 10 KB buffer, once with no limit and once keeping 1,000 steps:

examples/history_cost.rsrust
use undo::{Command, Editor};

fn main() {
    let limit: Option<usize> = std::env::args().nth(1).and_then(|v| v.parse().ok());
    let base = "x".repeat(10_000);
    let mut ed = Editor::new(&base);
    if let Some(n) = limit {
        ed = ed.with_history_limit(n);
    }
    for i in 0..300_000usize {
        // Alternate a 64-byte delete with an insert, anywhere in the buffer.
        let at = (i * 37) % 9_000;
        let cmd = if i % 2 == 0 {
            Command::Delete { at, len: 64 }
        } else {
            Command::Insert { at, text: "y".repeat(64) }
        };
        ed.apply(cmd).unwrap();
    }
    println!("history entries kept: {}", ed.history_len());
}
Text
limit=none      history entries kept: 300000, peak memory 40.5 MB
limit=1000      history entries kept: 1000, peak memory 1.8 MB

Without a limit the process held 300,000 history entries and peaked at 40.5 MB, nearly all of it stored command text and Restore copies of what had been deleted. With a limit of 1,000 it peaked at 1.8 MB. The growth is linear in the number of edits, so a long editing session keeps getting heavier, and an undo stack that big is also never used: nobody presses undo 300,000 times. The cost per kept step depends on how much text each edit touches, so a "delete everything" step costs as much as the document. If your edits can be large, bound the history by total bytes instead of by count. The measurement is a release build on one machine; the ratio is what carries over.

The trait-object version

The object-oriented spelling of the same pattern is a trait with one implementing type per command:

examples/dyn_version.rsrust
// The object-oriented spelling of the same idea, for comparison.
trait Command {
    fn apply(&mut self, text: &mut String);
    fn undo(&mut self, text: &mut String);
}

struct Insert {
    at: usize,
    text: String,
}
struct Delete {
    at: usize,
    len: usize,
    removed: String,
}

impl Command for Insert {
    fn apply(&mut self, text: &mut String) {
        text.insert_str(self.at, &self.text);
    }
    fn undo(&mut self, text: &mut String) {
        text.replace_range(self.at..self.at + self.text.len(), "");
    }
}
impl Command for Delete {
    fn apply(&mut self, text: &mut String) {
        self.removed = text[self.at..self.at + self.len].to_string();
        text.replace_range(self.at..self.at + self.len, "");
    }
    fn undo(&mut self, text: &mut String) {
        text.insert_str(self.at, &self.removed);
    }
}

fn main() {
    let mut text = String::from("hello world");
    let mut history: Vec<Box<dyn Command>> = Vec::new();
    for mut cmd in [
        Box::new(Delete {
            at: 5,
            len: 6,
            removed: String::new(),
        }) as Box<dyn Command>,
        Box::new(Insert {
            at: 5,
            text: "!".into(),
        }),
    ] {
        cmd.apply(&mut text);
        history.push(cmd);
    }
    println!("{text}");
    while let Some(mut cmd) = history.pop() {
        cmd.undo(&mut text);
    }
    println!("{text}");
    println!(
        "Box<dyn Command> = {} bytes + heap object",
        size_of::<Box<dyn Command>>()
    );
}
Text
hello!
hello world
Box<dyn Command> = 16 bytes + heap object

Each command struct owns what it needs to reverse itself: Delete carries a removed field it fills in when applied. The history is a Vec<Box<dyn Command>>, and undo is a method call on whatever is on top. The output is the same, hello! then hello world, and the object is a 16-byte pointer pair plus a heap allocation per command.

Choosing between the two follows the open-versus-closed rule from Rust enums and trait objects. The enum version is better when you control every kind of edit: commands are values you can print, compare, serialise and match on, they live inline in the Vec (the Command enum is 32 bytes, so a thousand of them are 32,000 bytes in one allocation), and the compiler tells you which match to update when you add a variant. The trait-object version is better when other code adds edit types, such as plugins, because it needs no change to the core, and when each command carries a lot of private state. Most applications have a fixed vocabulary, so the enum is the default.

Where to take it from here

Persisting the done list gives you crash recovery: replay the commands after loading the last snapshot, which is event sourcing with the same data shapes. Grouping commands under a name ("Replace all") turns a Vec<Command> into a composite that applies and undoes as one. Collaborative editing needs commands to be transformable against concurrent ones, and that is a research area of its own (operational transforms and CRDTs); the enum is still the right representation, but the undo logic above is not enough on its own. State that moves between a handful of named modes is a different pattern, covered in state machines.

What to take away

The design reduces to four decisions, each of which this guide made on purpose. Commands are data, so they can be logged, compared and replayed. Undo records are separate from commands, because reversing a destructive edit needs state the command never held. Merging and batching happen on the history entries, so the vocabulary the user and the log see stays simple. And limits and rollbacks are explicit, because an editor that cannot refuse bad input or bound its memory is a bug report waiting for a long enough session.

Habits

  • Make commands plain data and derive Debug, Clone and PartialEq.
  • Capture destroyed state at execution time, never reconstruct it later.
  • Clear redo on any new edit.
  • Test the round trip with a property test whose generator can produce the awkward input (multi-byte characters, empty ranges, out-of-range offsets).
  • Bound the history and decide what a merged step is.

When this pattern is too much

A form with three fields does not need command objects; saving the previous values in a struct and swapping them is enough. If the document is small and edits are rare, snapshotting the whole state before each change is simpler than computing inverses and cannot get them wrong. It stops being acceptable when the state is large (a snapshot of a 50 MB document per keystroke) or when you need an audit trail of what happened, which is where commands as data earn their keep.

Taggedpatterns
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.