On This Page
Press undo on a to-do list: the result
This is what the finished program prints. Three items are added, one is renamed, one is removed, and then I walk backwards and forwards through that history:
after 3 adds [buy milk, book dentist, send invoice] undo=3 redo=0 last=add "send invoice"
after rename [buy milk, book dentist for Friday, send invoice] undo=4 redo=0 last=rename #1 to "book dentist for Friday"
after remove #0 [book dentist for Friday, send invoice] undo=5 redo=0 last=remove #0
undo (remove) [buy milk, book dentist for Friday, send invoice] undo=4 redo=1 last=rename #1 to "book dentist for Friday"
undo (rename) [buy milk, book dentist, send invoice] undo=3 redo=2 last=add "send invoice"
redo (rename) [buy milk, book dentist for Friday, send invoice] undo=4 redo=1 last=rename #1 to "book dentist for Friday"
new add clears redo [buy milk, book dentist for Friday, send invoice, water plants] undo=5 redo=0 last=add "water plants"
Each line shows the list, how many steps can be undone, how many can be redone, and the last thing that happened. Nothing in TodoList knows what "undo" means. All of that lives in small objects that each represent one action. That's the command pattern in Java: turn an action into an object, and you can store it, replay it, reverse it, or queue it.
You'll build a to-do list with undo, redo and a macro that undoes as one step. It's for Java developers who've heard "use the command pattern" in a design review and want to see the whole thing working once, including the places it quietly goes wrong. The one real surprise while building it was a bug where undo restored the wrong text, which is Snag 1 below, and it cost me the most time.
run(cmd) undo() redo()
caller ───────────▶ History ◀────────────▶ undoStack ◀──▶ redoStack
│
cmd.execute() / cmd.undo()
▼
AddItem | RemoveItem | RenameItem | MacroCommand
▼
TodoList (the receiver: it just changes itself)
Check your setup
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| Build tool | none; plain javac |
javac -version |
| IntelliJ IDEA / Eclipse | not opened for this one; menu paths from the vendor docs | Help → About |
Main uses var (Java 10) and MacroCommand uses List.copyOf (Java 10), so you need at least Java 10. I compiled the main sources with --release 21 and again with --release 17, and both were clean. Maven Central wasn't reachable from the machine I built on, so the tests are a plain main method with a check() helper instead of JUnit. If you have JUnit 5, each check line becomes one @Test.
You should be comfortable with interfaces and Deque. If you've read the strategy pattern article, you already know the "put behaviour behind an interface" move; a command is the same move with memory.
Lay out the to-do project
Make this tree:
todo/
src/main/java/dev/ggorantala/todo/Command.java
src/main/java/dev/ggorantala/todo/TodoList.java
src/main/java/dev/ggorantala/todo/AddItem.java
src/main/java/dev/ggorantala/todo/RemoveItem.java
src/main/java/dev/ggorantala/todo/RenameItem.java
src/main/java/dev/ggorantala/todo/MacroCommand.java
src/main/java/dev/ggorantala/todo/History.java
src/main/java/dev/ggorantala/todo/Main.java
src/main/java/dev/ggorantala/todo/Traps.java
src/test/java/dev/ggorantala/todo/Checks.java
IntelliJ IDEA: File → New → Project, pick Java, set the JDK to 21, untick the sample-code box, then right-click src/main/java → Mark Directory as → Sources Root, and src/test/java as Test Sources Root. Eclipse: File → New → Java Project, choose JavaSE-21, then Java Build Path → Source → Add Folder for both directories. Terminal: mkdir -p the tree and use the commands in this article. I only ran the terminal path; the IDE steps are from the docs.
Write the commands, the list and the history
Step 1: Define what a command can do
The goal is one small interface. A command needs to run, to reverse itself, and to describe itself for logging.
src/main/java/dev/ggorantala/todo/Command.java
package dev.ggorantala.todo;
public interface Command {
void execute();
void undo();
String describe();
}
undo is what separates a command from a plain Runnable. If you never need to reverse anything, a Runnable or a lambda is enough, and I'd use that.
Step 2: Write the receiver
The receiver is the object that actually changes. It stays ignorant of commands, which is why you can reuse it elsewhere.
src/main/java/dev/ggorantala/todo/TodoList.java
package dev.ggorantala.todo;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** The receiver: knows how to change itself, knows nothing about undo. */
public class TodoList {
private final List<String> items = new ArrayList<>();
public void add(int index, String text) { items.add(index, text); }
public String remove(int index) { return items.remove(index); }
public String set(int index, String text) { return items.set(index, text); }
public int size() { return items.size(); }
public List<String> view() { return Collections.unmodifiableList(items); }
@Override public String toString() { return items.toString(); }
}
remove and set return what they replaced. That's deliberate: the commands need the old value to undo themselves.
Step 3: Write the first command
Add an item, and remember where it landed.
src/main/java/dev/ggorantala/todo/AddItem.java
package dev.ggorantala.todo;
public class AddItem implements Command {
private final TodoList list;
private final String text;
private int index = -1;
public AddItem(TodoList list, String text) { this.list = list; this.text = text; }
@Override public void execute() {
index = list.size(); // remember where it landed
list.add(index, text);
}
@Override public void undo() { list.remove(index); }
@Override public String describe() { return "add \"" + text + "\""; }
}
The index is stored inside execute, not the constructor. That distinction is the whole of Snag 1.
Step 4: Write remove and rename
Both capture the old state at the moment they run.
src/main/java/dev/ggorantala/todo/RemoveItem.java
package dev.ggorantala.todo;
public class RemoveItem implements Command {
private final TodoList list;
private final int index;
private String removed; // captured at execute time, needed for undo
public RemoveItem(TodoList list, int index) { this.list = list; this.index = index; }
@Override public void execute() { removed = list.remove(index); }
@Override public void undo() { list.add(index, removed); }
@Override public String describe() { return "remove #" + index; }
}
src/main/java/dev/ggorantala/todo/RenameItem.java
package dev.ggorantala.todo;
public class RenameItem implements Command {
private final TodoList list;
private final int index;
private final String newText;
private String oldText;
public RenameItem(TodoList list, int index, String newText) {
this.list = list; this.index = index; this.newText = newText;
}
@Override public void execute() { oldText = list.set(index, newText); }
@Override public void undo() { list.set(index, oldText); }
@Override public String describe() { return "rename #" + index + " to \"" + newText + "\""; }
}
Step 5: Build the invoker with undo and redo stacks
The goal is one class that runs commands and remembers them. History is the invoker: it never looks inside a command.
src/main/java/dev/ggorantala/todo/History.java
package dev.ggorantala.todo;
import java.util.ArrayDeque;
import java.util.Deque;
/** The invoker: runs commands and keeps the undo and redo stacks. */
public class History {
private final Deque<Command> undoStack = new ArrayDeque<>();
private final Deque<Command> redoStack = new ArrayDeque<>();
public void run(Command c) {
c.execute(); // if this throws, nothing is recorded
undoStack.push(c);
redoStack.clear(); // a new action invalidates the redo branch
}
public boolean undo() {
if (undoStack.isEmpty()) return false;
Command c = undoStack.pop();
c.undo();
redoStack.push(c);
return true;
}
public boolean redo() {
if (redoStack.isEmpty()) return false;
Command c = redoStack.pop();
c.execute();
undoStack.push(c);
return true;
}
public int undoDepth() { return undoStack.size(); }
public int redoDepth() { return redoStack.size(); }
public String lastDescription() { return undoStack.isEmpty() ? "-" : undoStack.peek().describe(); }
}
Two details to notice. c.execute() comes before push, so a command that throws is never recorded. And redoStack.clear() runs on every new command; once you've done something new, the old "future" no longer makes sense.
Step 6: Add a macro that undoes as one step
Sometimes one user gesture is several commands. A macro wraps them in a single command.
src/main/java/dev/ggorantala/todo/MacroCommand.java
package dev.ggorantala.todo;
import java.util.ArrayList;
import java.util.List;
/** Several commands that undo as one step. */
public class MacroCommand implements Command {
private final String name;
private final List<Command> steps;
private int done = 0;
public MacroCommand(String name, List<Command> steps) {
this.name = name; this.steps = List.copyOf(steps);
}
@Override public void execute() {
done = 0;
try {
for (Command c : steps) { c.execute(); done++; }
} catch (RuntimeException e) {
undoDone(); // all-or-nothing
throw e;
}
}
@Override public void undo() { undoDone(); }
private void undoDone() {
for (int i = done - 1; i >= 0; i--) steps.get(i).undo();
done = 0;
}
@Override public String describe() { return name + " (" + steps.size() + " steps)"; }
}
If step three of five throws, the macro undoes the two that succeeded and rethrows. That's the all-or-nothing behaviour people expect from "apply this batch".
Step 7: Run it
src/main/java/dev/ggorantala/todo/Main.java
package dev.ggorantala.todo;
import java.util.List;
public class Main {
public static void main(String[] args) {
var list = new TodoList();
var history = new History();
history.run(new AddItem(list, "buy milk"));
history.run(new AddItem(list, "book dentist"));
history.run(new AddItem(list, "send invoice"));
show("after 3 adds", list, history);
history.run(new RenameItem(list, 1, "book dentist for Friday"));
show("after rename", list, history);
history.run(new RemoveItem(list, 0));
show("after remove #0", list, history);
history.undo();
show("undo (remove)", list, history);
history.undo();
show("undo (rename)", list, history);
history.redo();
show("redo (rename)", list, history);
history.run(new AddItem(list, "water plants"));
show("new add clears redo", list, history);
System.out.println();
System.out.println("-- macro: one undo step --");
history.run(new MacroCommand("weekly reset", List.of(
new AddItem(list, "plan week"),
new AddItem(list, "clear inbox"),
new RenameItem(list, 0, "buy oat milk"))));
show("after macro", list, history);
history.undo();
show("undo macro", list, history);
System.out.println();
System.out.println("-- failing macro rolls back --");
try {
history.run(new MacroCommand("bad batch", List.of(
new AddItem(list, "ok item"),
new RemoveItem(list, 99))));
} catch (IndexOutOfBoundsException e) {
System.out.println("caught: " + e.getClass().getSimpleName());
}
show("after failed macro", list, history);
}
static void show(String label, TodoList list, History h) {
System.out.printf("%-22s %s undo=%d redo=%d last=%s%n",
label, list, h.undoDepth(), h.redoDepth(), h.lastDescription());
}
}
mkdir -p out
javac -Xlint:all --release 21 -d out $(find src/main -name '*.java')
java -cp out dev.ggorantala.todo.Main
Checkpoint: javac prints nothing (I used -Xlint:all and got no warnings), and the program prints the block at the top plus the macro part below.
See it working
The first seven lines are at the top of the article. The macro and the failing macro are the interesting rest:
-- macro: one undo step --
after macro [buy oat milk, book dentist for Friday, send invoice, water plants, plan week, clear inbox] undo=6 redo=0 last=weekly reset (3 steps)
undo macro [buy milk, book dentist for Friday, send invoice, water plants] undo=5 redo=1 last=add "water plants"
-- failing macro rolls back --
caught: IndexOutOfBoundsException
after failed macro [buy milk, book dentist for Friday, send invoice, water plants] undo=5 redo=1 last=add "water plants"
Read the "new add clears redo" line against the one above it: redo went from 1 to 0 the moment water plants was added. And the failing macro leaves the list exactly as it was, with undo=5 redo=1 unchanged, because nothing was recorded.
Lock it in with tests
src/test/java/dev/ggorantala/todo/Checks.java
package dev.ggorantala.todo;
import java.util.List;
public class Checks {
static int run, failed;
static void check(String name, boolean ok) {
run++;
if (!ok) failed++;
System.out.println((ok ? "PASS " : "FAIL ") + name);
}
public static void main(String[] args) {
var list = new TodoList();
var h = new History();
h.run(new AddItem(list, "a"));
h.run(new AddItem(list, "b"));
check("two adds give [a, b]", list.view().equals(List.of("a", "b")));
check("undo removes last add", h.undo() && list.view().equals(List.of("a")));
check("redo puts it back", h.redo() && list.view().equals(List.of("a", "b")));
h.run(new RenameItem(list, 0, "A"));
h.undo();
check("undo rename restores text", list.view().get(0).equals("a"));
h.run(new RemoveItem(list, 0));
check("remove deletes", list.view().equals(List.of("b")));
h.undo();
check("undo remove restores position", list.view().equals(List.of("a", "b")));
h.undo(); // stack is now [add a, add b]; this undoes 'add b'
check("redo stack grows on undo", h.redoDepth() >= 1);
h.run(new AddItem(list, "z"));
check("new command clears redo", h.redoDepth() == 0);
var h2 = new History();
check("undo on empty returns false", !h2.undo());
check("redo on empty returns false", !h2.redo());
var l3 = new TodoList();
var h3 = new History();
boolean threw = false;
try { h3.run(new RemoveItem(l3, 5)); } catch (IndexOutOfBoundsException e) { threw = true; }
check("failed command throws", threw);
check("failed command is not recorded", h3.undoDepth() == 0);
var l4 = new TodoList();
var h4 = new History();
h4.run(new MacroCommand("m", List.of(new AddItem(l4, "x"), new AddItem(l4, "y"), new AddItem(l4, "z"))));
check("macro runs all steps", l4.size() == 3);
check("macro is one undo step", h4.undoDepth() == 1);
h4.undo();
check("macro undo reverses all", l4.size() == 0);
h4.redo();
check("macro redo replays all", l4.view().equals(List.of("x", "y", "z")));
var l5 = new TodoList();
var h5 = new History();
try { h5.run(new MacroCommand("bad", List.of(new AddItem(l5, "ok"), new RemoveItem(l5, 9)))); }
catch (IndexOutOfBoundsException e) { /* expected */ }
check("failed macro rolls back", l5.size() == 0);
check("failed macro not recorded", h5.undoDepth() == 0);
System.out.println(run - failed + "/" + run + " checks passed");
if (failed > 0) System.exit(1);
}
}
javac -Xlint:all --release 21 -cp out -d out src/test/java/dev/ggorantala/todo/Checks.java
java -cp out dev.ggorantala.todo.Checks
PASS two adds give [a, b]
PASS undo removes last add
PASS redo puts it back
PASS undo rename restores text
PASS remove deletes
PASS undo remove restores position
PASS redo stack grows on undo
PASS new command clears redo
PASS undo on empty returns false
PASS redo on empty returns false
PASS failed command throws
PASS failed command is not recorded
PASS macro runs all steps
PASS macro is one undo step
PASS macro undo reverses all
PASS macro redo replays all
PASS failed macro rolls back
PASS failed macro not recorded
18/18 checks passed
To be sure the checks can fail, I deleted the redoStack.clear() line from History and re-ran. Exactly one check went red:
FAIL new command clears redo
17/18 checks passed
Put the line back before moving on.
What each piece is for
There are four roles. The command is an object that holds everything needed to do one action and undo it. The receiver (TodoList) does the real work. The invoker (History) decides when a command runs and keeps the stacks. The client (Main) builds commands and hands them over.
Why bother? Because once actions are objects, a lot of features cost almost nothing: undo and redo are two stacks, a macro is a list, an audit log is describe() on each command, and a job queue is a BlockingQueue<Command>. Compare that with the observer pattern, which tells many listeners that something happened, whereas a command says "do this one thing" and keeps the receipt. The decorator pattern is a neat way to wrap commands with logging or timing without touching them.
The cost is more classes. Three actions became three files. For two or three throwaway actions with no undo, a lambda is the better tool.
Snags, forced on purpose
Snag 1: undo restores the wrong text. I first captured the old text in the constructor. That works until you build two commands before running either, which is exactly what a queue or a script does. Traps.java reproduces it:
src/main/java/dev/ggorantala/todo/Traps.java
package dev.ggorantala.todo;
public class Traps {
/** Wrong: captures the old text when the command is built, not when it runs. */
static class EagerRename implements Command {
private final TodoList list; private final int index; private final String newText;
private final String oldText; // <- captured too early
EagerRename(TodoList list, int index, String newText) {
this.list = list; this.index = index; this.newText = newText;
this.oldText = list.view().get(index);
}
public void execute() { list.set(index, newText); }
public void undo() { list.set(index, oldText); }
public String describe() { return "eager rename"; }
}
public static void main(String[] args) {
var list = new TodoList();
list.add(0, "draft report");
// Build two commands up front, as a queue or a script would.
Command first = new EagerRename(list, 0, "report v1");
Command second = new EagerRename(list, 0, "report v2");
first.execute();
second.execute();
System.out.println("after both : " + list);
second.undo();
System.out.println("undo second : " + list + " (should be [report v1])");
// Right: RenameItem captures at execute time.
var list2 = new TodoList();
list2.add(0, "draft report");
Command a = new RenameItem(list2, 0, "report v1");
Command b = new RenameItem(list2, 0, "report v2");
a.execute(); b.execute(); b.undo();
System.out.println("fixed undo : " + list2);
}
}
after both : [report v2]
undo second : [draft report] (should be [report v1])
fixed undo : [report v1]
The second line should say [report v1]. Both commands read "draft report" at build time, so undoing the second one jumps back two versions. Capture state in execute, as RenameItem does.
Snag 2: a failed command ends up in the history. If you push before executing, a command that throws is on the undo stack and the next undo tries to reverse something that never happened. Execute first. My failed command is not recorded check covers it.
Snag 3: redo survives a new action. Without redoStack.clear(), you can undo, make a new change, then redo into a state that no longer fits the list. The mutation run above caught it.
Snag 4: compile error when you forget undo. This one's not subtle, but it's the message you'll see the first time you add a command and forget half of it:
src/main/java/dev/ggorantala/todo/AddItem.java:3: error: AddItem is not abstract and does not override abstract method undo() in Command
public class AddItem implements Command {
^
1 error
Add the method. If many of your commands can't be undone, split the interface in two, one with execute and one extending it with undo.
Taking it further
- Cap the history.
ArrayDequegrows forever. UseaddFirstand drop fromremoveLastonce the stack passes, say, 100 entries. - Don't keep references you can't afford. A command holding a copy of a 50 MB document per edit will eat memory. Store a diff instead.
- Thread safety.
Historyisn't thread-safe. If several threads submit commands, put them on aBlockingQueueand have one thread run them; the producer-consumer article shows the shape. - Persist the log. If every command can be written as data (a record of its type and arguments), you've got the start of event sourcing, which is plan item #81 on this site and not written yet.
- Records and sealed types. A command's fields are good record material; see the Java records article and the sealed classes article if you want the compiler to know every command type.
- Version note. Nothing in this article changed between Java 17 and 21.
Related: factory and abstract factory is a natural way to build commands from user input, and the state machine pattern (not published yet) is the next pattern worth reading.
Command pattern questions, answered
What is the command pattern in Java?
It's a design pattern where each action is wrapped in an object with an execute method, and usually an undo. A separate invoker runs those objects, so the caller doesn't need to know what the action does.
What's the difference between the command and strategy patterns?
A strategy is a swappable algorithm that you call and forget. A command is a specific request that carries its own data and can be stored, queued or reversed. If you need a receipt, use a command.
How do I implement undo and redo in Java?
Keep two Deque<Command> stacks. Running a command pushes it on the undo stack and clears the redo stack. undo pops from the first, calls undo(), and pushes onto the second. redo does the reverse.
Can I use a lambda as a command?
Yes, if the command only needs execute. Runnable already is that. Once you need undo, you have two methods, so a lambda no longer fits and a small class is clearer.
What you did
- Defined a
Commandwithexecute,undoanddescribe. - Kept the receiver (
TodoList) unaware of commands. - Captured old state inside
execute, not the constructor. - Built
Historywith an undo stack and a redo stack, executing before recording and clearing redo on new input. - Grouped steps in a
MacroCommandthat rolls back if any step fails. - Broke the history on purpose and watched a check go red.
References
- Java SE 21 API:
Deque,ArrayDeque,Runnable - dev.java: Learn Java