DEV Community

Cover image for The Command Pattern Simplified: How Modern Java (21–25) Makes It Elegant
Jitin
Jitin

Posted on • Originally published at Medium

The Command Pattern Simplified: How Modern Java (21–25) Makes It Elegant

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

🏦 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();
}
Enter fullscreen mode Exit fullscreen mode

✔️ 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)