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.
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();
}
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);
}
}
}
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);
}
}
}
previousState above). Use this when the action is destructive or non-deterministic.execute: add(5), undo: subtract(5)). This saves memory but risks drift if state transitions aren't perfectly commutative.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.
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.
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.