The Command pattern is one of those classic design patterns that feels both brilliant and tedious.
Its idea is simple: capture a request as an object so you can parameterise clients, queue requests, and support undoable operations.
But implementing it? That’s where things get messy. You end up with dozens of classes — one for each command. Command interface, concrete commands, receivers, invokers… the boilerplate never ends.
Over the last few Java releases, though, the language has evolved.
With records, sealed interfaces and pattern matching, modern Java (21 through 25) makes the Command pattern not just easier, but beautifully elegant.
The Problem: Traditional Command Pattern Verbosity
Let me show you what implementing the Command pattern looked like before Java 17.
The Old Way: A Text Editor with Undo/Redo
// ❌ Traditional Command Pattern (Java 17 and before) - LOTS OF BOILERPLATE
// Step 1: Command interface
public interface EditorCommand {
void execute();
void undo();
}
// Step 2: Receiver - the actual editor
public class TextEditor {
private StringBuilder text = new StringBuilder();
public void insertText(String str) {
text.append(str);
}
public void deleteText(int length) {
if (text.length() >= length) {
text.delete(text.length() - length, text.length());
}
}
public String getContent() {
return text.toString();
}
}
// Step 3: Concrete command for inserting text
public class InsertCommand implements EditorCommand {
private TextEditor editor;
private String textToInsert;
private int position;
public InsertCommand(TextEditor editor, String text, int position) {
this.editor = editor;
this.textToInsert = text;
this.position = position;
}
@Override
public void execute() {
editor.insertText(textToInsert);
}
@Override
public void undo() {
editor.deleteText(textToInsert.length());
}
}
// Step 4: Concrete command for deleting text
public class DeleteCommand implements EditorCommand {
private TextEditor editor;
private String deletedText;
private int deleteLength;
public DeleteCommand(TextEditor editor, int length) {
this.editor = editor;
this.deleteLength = length;
}
@Override
public void execute() {
// First, save what we're deleting (for undo)
deletedText = editor.getContent();
editor.deleteText(deleteLength);
}
@Override
public void undo() {
// Restore the deleted text
editor.insertText(deletedText);
}
}
// Step 5: More concrete commands...
public class FindAndReplaceCommand implements EditorCommand {
private TextEditor editor;
private String searchText;
private String replaceText;
private String previousContent;
public FindAndReplaceCommand(TextEditor editor, String search, String replace) {
this.editor = editor;
this.searchText = search;
this.replaceText = replace;
}
@Override
public void execute() {
previousContent = editor.getContent();
// Implementation...
}
@Override
public void undo() {
// Restore previous state
}
}
// Step 6: Invoker - manages command execution history
public class CommandHistory {
private Stack<EditorCommand> undoStack = new Stack<>();
private Stack<EditorCommand> redoStack = new Stack<>();
public void execute(EditorCommand command) {
command.execute();
undoStack.push(command);
redoStack.clear(); // Clear redo history on new command
}
public void undo() {
if (!undoStack.isEmpty()) {
EditorCommand command = undoStack.pop();
command.undo();
redoStack.push(command);
}
}
public void redo() {
if (!redoStack.isEmpty()) {
EditorCommand command = redoStack.pop();
command.execute();
undoStack.push(command);
}
}
}
// Usage
public static void main(String[] args) {
TextEditor editor = new TextEditor();
CommandHistory history = new CommandHistory();
history.execute(new InsertCommand(editor, "Hello", 0));
history.execute(new InsertCommand(editor, " World", 5));
System.out.println(editor.getContent()); // "Hello World"
history.undo();
System.out.println(editor.getContent()); // "Hello"
history.redo();
System.out.println(editor.getContent()); // "Hello World"
}
Problems:
One class per command — dozens in large systems
Repeated execute() / undo() boilerplate
Hard to maintain or extend
Type-unsafe, and often error-prone
🚀 The Modern Way: Records + Sealed Interfaces + Pattern Matching
By Java 25, all the language features we need for an elegant Command pattern are stable:
✅ Records — immutable data containers with auto-generated boilerplate
✅ Sealed interfaces — restrict which command types can exist
✅ Pattern matching for switch — exhaustive, type-safe command handling
Let’s rebuild the same editor example with these tools.
// Step 1: Define command as a sealed interface with record implementations
public sealed interface EditorCommand {
void execute(TextEditor editor);
void undo(TextEditor editor);
// Command to insert text
record Insert(String text, int position) implements EditorCommand {
@Override
public void execute(TextEditor editor) {
editor.insertText(text);
}
@Override
public void undo(TextEditor editor) {
editor.deleteText(text.length());
}
}
// Command to delete text
record Delete(int length, String deletedContent) implements EditorCommand {
@Override
public void execute(TextEditor editor) {
editor.deleteText(length);
}
@Override
public void undo(TextEditor editor) {
editor.insertText(deletedContent);
}
}
// Command to find and replace
record Replace(String find, String replaceWith, String previousContent) implements EditorCommand {
@Override
public void execute(TextEditor editor) {
editor.findAndReplace(find, replaceWith);
}
@Override
public void undo(TextEditor editor) {
// Restore previous state
editor.setContent(previousContent);
}
}
}
// Step 2: Simple receiver (same as before)
public class TextEditor {
private StringBuilder text = new StringBuilder();
public void insertText(String str) {
text.append(str);
}
public void deleteText(int length) {
if (text.length() >= length) {
text.delete(text.length() - length, text.length());
}
}
public String getContent() {
return text.toString();
}
public void setContent(String content) {
text = new StringBuilder(content);
}
public void findAndReplace(String find, String replaceWith) {
String content = text.toString();
text = new StringBuilder(content.replace(find, replaceWith));
}
}
// Step 3: Invoker with pattern matching
public class CommandHistory {
private Stack<EditorCommand> undoStack = new Stack<>();
private Stack<EditorCommand> redoStack = new Stack<>();
private TextEditor editor;
public CommandHistory(TextEditor editor) {
this.editor = editor;
}
public void execute(EditorCommand command) {
// Use pattern matching to capture state before execution if needed
switch (command) {
case EditorCommand.Delete(int length, _) -> {
// Before executing delete, update the deletedContent
String content = editor.getContent();
EditorCommand updated = new EditorCommand.Delete(length, content);
updated.execute(editor);
undoStack.push(updated);
}
case EditorCommand.Replace(String find, String replaceWith, _) -> {
String content = editor.getContent();
EditorCommand updated = new EditorCommand.Replace(find, replaceWith, content);
updated.execute(editor);
undoStack.push(updated);
}
case EditorCommand.Insert _ -> {
command.execute(editor);
undoStack.push(command);
}
}
redoStack.clear();
}
public void undo() {
if (!undoStack.isEmpty()) {
EditorCommand command = undoStack.pop();
command.undo(editor);
redoStack.push(command);
}
}
public void redo() {
if (!redoStack.isEmpty()) {
EditorCommand command = redoStack.pop();
command.execute(editor);
undoStack.push(command);
}
}
}
// Usage - Much cleaner!
public static void main(String[] args) {
TextEditor editor = new TextEditor();
CommandHistory history = new CommandHistory(editor);
history.execute(new EditorCommand.Insert("Hello", 0));
history.execute(new EditorCommand.Insert(" World", 5));
System.out.println(editor.getContent()); // "Hello World"
history.undo();
System.out.println(editor.getContent()); // "Hello"
history.redo();
System.out.println(editor.getContent()); // "Hello World"
}
What improved:
✅ Fewer than 60 lines
✅ Immutable, type-safe commands
✅ All logic in one file
✅ Compiler-enforced exhaustiveness
Advanced: Combining with Lambdas for Task Queues
Records and lambdas make functional-style commands possible.
// ✅ Java 21+: Functional command pattern with sealed interfaces and records
// Sealed command interface
public sealed interface Task {
void run();
// Simple task - wraps a lambda
record SimpleTask(String name, Runnable action) implements Task {
@Override
public void run() {
System.out.println("Executing: " + name);
action.run();
}
}
// Async task with timeout
record AsyncTask(
String name,
Runnable action,
long timeoutMs
) implements Task {
@Override
public void run() {
System.out.println("Executing async: " + name);
Thread task = new Thread(action);
task.start();
try {
task.join(timeoutMs);
if (task.isAlive()) {
System.out.println("Task timed out!");
task.interrupt();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
// Conditional task
record ConditionalTask(
String name,
Supplier<Boolean> condition,
Runnable ifTrue,
Runnable ifFalse
) implements Task {
@Override
public void run() {
System.out.println("Executing conditional: " + name);
if (condition.get()) {
ifTrue.run();
} else {
ifFalse.run();
}
}
}
}
// Task queue executor with pattern matching
public class TaskQueue {
private Queue<Task> tasks = new LinkedList<>();
public void enqueue(Task task) {
tasks.add(task);
}
public void executeTasks() {
while (!tasks.isEmpty()) {
Task task = tasks.poll();
// Pattern matching for different task types
switch (task) {
case Task.SimpleTask(String name, Runnable action) -> {
System.out.println("[SIMPLE] " + name);
action.run();
}
case Task.AsyncTask(String name, Runnable action, long timeout) -> {
System.out.println("[ASYNC] " + name + " (timeout: " + timeout + "ms)");
// Execute with timeout...
}
case Task.ConditionalTask(String name, var condition, var ifTrue, var ifFalse) -> {
System.out.println("[CONDITIONAL] " + name);
if (condition.get()) {
ifTrue.run();
} else {
ifFalse.run();
}
}
}
}
}
}
// Usage - Minimal code, maximum expressiveness
public static void main(String[] args) {
TaskQueue queue = new TaskQueue();
// Add simple task using lambda
queue.enqueue(new Task.SimpleTask(
"Print greeting",
() -> System.out.println("Hello from task queue!")
));
// Add async task
queue.enqueue(new Task.AsyncTask(
"Download data",
() -> System.out.println("Downloading..."),
5000 // 5 second timeout
));
// Add conditional task
queue.enqueue(new Task.ConditionalTask(
"Check network",
() -> isNetworkAvailable(),
() -> System.out.println("Connected!"),
() -> System.out.println("No connection")
));
// Execute all tasks
queue.executeTasks();
}
private static boolean isNetworkAvailable() {
return true; // Simplified
}
🏦 Real Example: Payment Commands
Let’s apply this to something closer to real-world business logic — a banking system.
// ✅ Payment command system with undo/redo
public sealed interface PaymentCommand {
void execute(Account account);
void undo(Account account);
record Deposit(double amount) implements PaymentCommand {
public Deposit {
if (amount <= 0) throw new IllegalArgumentException("Amount must be positive");
}
@Override
public void execute(Account account) {
account.addBalance(amount);
}
@Override
public void undo(Account account) {
account.subtractBalance(amount);
}
}
record Withdraw(double amount, double previousBalance) implements PaymentCommand {
public Withdraw(double amount) {
this(amount, 0);
}
@Override
public void execute(Account account) {
if (account.getBalance() < amount) {
throw new IllegalArgumentException("Insufficient funds");
}
account.subtractBalance(amount);
}
@Override
public void undo(Account account) {
account.setBalance(previousBalance);
}
}
record Transfer(
Account from,
Account to,
double amount
) implements PaymentCommand {
@Override
public void execute(Account account) {
from.subtractBalance(amount);
to.addBalance(amount);
}
@Override
public void undo(Account account) {
to.subtractBalance(amount);
from.addBalance(amount);
}
}
record FeesApplied(
double feeAmount,
String reason
) implements PaymentCommand {
@Override
public void execute(Account account) {
account.subtractBalance(feeAmount);
}
@Override
public void undo(Account account) {
account.addBalance(feeAmount);
}
}
}
// Account receiver
public class Account {
private String accountNumber;
private double balance;
private List<PaymentCommand> history = new ArrayList<>();
public Account(String number, double initialBalance) {
this.accountNumber = number;
this.balance = initialBalance;
}
public void processCommand(PaymentCommand command) {
command.execute(this);
history.add(command);
}
public void undoLastCommand() {
if (!history.isEmpty()) {
PaymentCommand last = history.remove(history.size() - 1);
last.undo(this);
}
}
// Receiver methods
public void addBalance(double amount) { balance += amount; }
public void subtractBalance(double amount) { balance -= amount; }
public void setBalance(double amount) { balance = amount; }
public double getBalance() { return balance; }
public void printStatement() {
System.out.println("Account: " + accountNumber);
System.out.println("Balance: $" + String.format("%.2f", balance));
System.out.println("Transactions: " + history.size());
}
}
// Usage
public static void main(String[] args) {
Account checking = new Account("CHK-123", 1000.0);
Account savings = new Account("SAV-456", 5000.0);
// Execute commands
checking.processCommand(new PaymentCommand.Deposit(500.0));
checking.processCommand(new PaymentCommand.Withdraw(100.0, checking.getBalance()));
checking.processCommand(new PaymentCommand.Transfer(checking, savings, 200.0));
checking.processCommand(new PaymentCommand.FeesApplied(2.5, "Monthly fee"));
checking.printStatement(); // Shows all transactions
// Undo last fee
checking.undoLastCommand();
System.out.println("After undo:");
checking.printStatement();
}
✔️ Short, type-safe, and clear.
✔️ Commands define what happens; Account executes them.
✔️ Undo/redo is trivial and explicit.
🧭 When to Use This Modern Command Pattern
✅ Perfect for:
Undo/Redo systems
Transaction logging
Task queues
Macro recording
Workflow orchestration
❌ Avoid for:
Highly dynamic plugin systems
Environments needing runtime type loading (sealed types limit extension)
🏁 Wrapping Up
The Command pattern hasn’t changed — but Java has evolved.
With records, sealed interfaces, and pattern matching, you can write clean, immutable, type-safe commands with half the code and none of the pain.
It’s not about replacing design patterns — it’s about making them effortless.
So go ahead — modernise those command hierarchies.
Top comments (0)