Skip to content

How to Implement the Command Pattern in Java (Java 21)

Implement the command pattern in Java 21 on a to-do list with undo, redo and macro commands, backed by 18 checks and the traps that quietly break undo.

how-to-tutorials design-patterns java-tutorials
Gopi Gorantala
Reading Progress

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. ArrayDeque grows forever. Use addFirst and drop from removeLast once 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. History isn't thread-safe. If several threads submit commands, put them on a BlockingQueue and 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 Command with execute, undo and describe.
  • Kept the receiver (TodoList) unaware of commands.
  • Captured old state inside execute, not the constructor.
  • Built History with an undo stack and a redo stack, executing before recording and clearing redo on new input.
  • Grouped steps in a MacroCommand that rolls back if any step fails.
  • Broke the history on purpose and watched a check go red.

References

how-to-tutorialsdesign-patternsjava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments