Command Pattern: Transactional Undo/Redo in Java 21

The Command pattern decouples an invoker from a receiver by encapsulating a request as a standalone object. While textbook examples often stop at simple execution, production-grade implementations must handle state snapshots, rollback integrity, and history management.

In Java 21, we leverage Sealed Interfaces, Records, and Pattern Matching to build a type-safe, transactional command system.

1. The Command Interface

We define a Command as a sealed interface to restrict the implementation hierarchy and enable exhaustive pattern matching in the history manager if needed.

public sealed interface Command permits TextInsertCommand, StyleChangeCommand {
    void execute();
    void undo();
}

2. Concrete Implementation: Text Editor Example

To implement an undoable command, the object must capture the Receiver's state before the mutation occurs.

public record TextInsertCommand(
    Document receiver,
    int offset,
    String content,
    // Captured state for undo
    AtomicReference<String> previousState
) implements Command {

    public TextInsertCommand(Document receiver, int offset, String content) {
        this(receiver, offset, content, new AtomicReference<>());
    }

    @Override
    public void execute() {
        // Capture state before mutation
        previousState.set(receiver.getTextRange(offset, content.length()));
        receiver.insert(offset, content);
    }

    @Override
    public void undo() {
        String prev = previousState.get();
        if (prev != null) {
            receiver.replace(offset, content.length(), prev);
        }
    }
}

3. The Command History Manager

A robust history manager uses two stacks (implemented via Deque) to manage the LIFO nature of undo/redo.

import java.util.ArrayDeque;
import java.util.Deque;

public class HistoryManager {
    private final Deque<Command> undoStack = new ArrayDeque<>();
    private final Deque<Command> redoStack = new ArrayDeque<>();

    public void execute(Command command) {
        command.execute();
        undoStack.push(command);
        redoStack.clear(); // Mandatory: New actions invalidate forward history
    }

    public void undo() {
        if (!undoStack.isEmpty()) {
            Command command = undoStack.pop();
            command.undo();
            redoStack.push(command);
        }
    }

    public void redo() {
        if (!redoStack.isEmpty()) {
            Command command = redoStack.pop();
            command.execute();
            undoStack.push(command);
        }
    }
}

4. Key Practitioner Insights

4.1 State Capture: Memento vs. Inverse

4.2 Handling Exceptions

Commands should be transactional. If execute() fails halfway through a MacroCommand (a composite of multiple commands), you must iterate backwards through the successfully executed sub-commands and call undo() on each.

4.3 Serialization

For persistent history, commands should be serializable (e.g., via Jackson or Protobuf). Store the command parameters and use a factory to reconstitute the Receiver references upon hydration.

4.4 Thread Safety

In concurrent environments, the HistoryManager must synchronize access to the stacks. However, the Command objects themselves should ideally be immutable (except for the captured state) to prevent race conditions during execution.