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.

```java

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.

```java

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.

```java

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

* **Memento:** Store a full or partial snapshot (like `previousState` above). Use this when the action is destructive or non-deterministic.

* **Inverse Operation:** Calculate the reverse (e.g., `execute: add(5)`, `undo: subtract(5)`). This saves memory but risks drift if state transitions aren't perfectly commutative.

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.