Mastering the Command Pattern: Implementing Undo, Redo, and Batch Transactions in Python

Overview: Encapsulating Intent

The

is a behavioral powerhouse that transforms a request into a stand-alone object. This shift matters because it decouples the object that invokes the operation from the one that knows how to perform it. Instead of calling a method directly on a data object, you wrap that action in a "command" object. This provides immense control over the execution lifecycle, allowing you to queue operations, log them, or pass them around like any other piece of data. In a banking context, where every cent counts, this pattern provides the rigorous structure needed to manage complex
financial transactions
.

Prerequisites

To get the most out of this tutorial, you should be comfortable with

fundamentals, specifically Object-Oriented Programming (OOP) concepts like classes and inheritance. Familiarity with
Protocols
(structural subtyping) is helpful, as we use them to define our command interface. You should also understand basic data structures like lists and dictionaries, which act as our "stacks" for undo and redo history.

Mastering the Command Pattern: Implementing Undo, Redo, and Batch Transactions in Python
Real-Life Case of the Command Design Pattern

Key Libraries & Tools

  • Python
    3.8+
    : The primary language used for implementation.
  • dataclasses
    : A standard library module used to reduce boilerplate code when creating data-heavy objects like bank accounts and commands.
  • typing
    : Used to define the Transaction interface, ensuring that any command we create adheres to the required method signatures.

Building the Foundation: The Command Protocol

We start by defining what a transaction looks like. Instead of a concrete class, we use a Protocol. This allows for flexible implementation across different types of banking actions.

from typing import Protocol

class Transaction(Protocol):
    def execute(self) -> None:
        ...
    def undo(self) -> None:
        ...
    def redo(self) -> None:
        ...

This interface forces every command—whether it is a deposit, withdrawal, or transfer—to know how to perform its action, reverse it, and repeat it. By defining these methods upfront, we prepare our system for non-destructive editing and history management.

Implementing Concrete Commands

Each banking operation becomes a concrete class. Take the Deposit command: it holds a reference to the Account and the amount. It doesn't just perform the math; it stores the state necessary to undo that math later.

@dataclass
class Deposit:
    account: Account
    amount: int

    def execute(self) -> None:
        self.account.deposit(self.amount)
        print(f"Deposited ${self.amount/100:.2f}")

    def undo(self) -> None:
        self.account.withdraw(self.amount)
        print(f"Undid deposit of ${self.amount/100:.2f}")

    def redo(self) -> None:
        self.execute()

The logic for a Transfer is slightly more complex as it involves two accounts, but the pattern remains identical. The execute method withdraws from one and deposits into another, while undo simply swaps those roles.

The Bank Controller: Managing the Stack

To handle undo and redo, we need a central manager. The BankController maintains two stacks: undo_stack and redo_stack. When you execute a command through the controller, it clears the redo history and pushes the new command onto the undo stack. This is the same logic used in professional

and
audio software
.

class BankController:
    undo_stack: list[Transaction] = field(default_factory=list)
    redo_stack: list[Transaction] = field(default_factory=list)

    def execute(self, transaction: Transaction):
        transaction.execute()
        self.redo_stack.clear()
        self.undo_stack.append(transaction)

    def undo(self):
        if not self.undo_stack: return
        transaction = self.undo_stack.pop()
        transaction.undo()
        self.redo_stack.append(transaction)

This architecture ensures that the user can never "redo" an old action after they have performed a brand-new operation, preventing state corruption.

Practical Examples: Batch Processing and Rollbacks

A major advantage of the

is the ability to group commands into a Batch. In banking, you might want to perform five transfers as a single unit. If the third transfer fails due to insufficient funds, you must roll back the first two to maintain data integrity. Our Batch command handles this by iterating through its internal list of commands and using a try...except block to trigger undo() on completed steps if an error occurs.

Tips & Gotchas

  • Assumption of Success: In our basic implementation, we assume undo() and redo() will always succeed. In production systems, you must handle errors during these phases. If an undo fails, the system state is potentially compromised.
  • Granularity: Keep your commands small. Instead of a "Pay Bills" command that does everything, create a Batch of individual "Withdrawal" commands. This makes debugging and reversing specific parts of the process much easier.
  • Memory Management: If a user performs thousands of operations, your undo stack will grow indefinitely. Consider implementing a maximum stack size to prevent excessive memory consumption.
Mastering the Command Pattern: Implementing Undo, Redo, and Batch Transactions in Python

Fancy watching it?

Watch the full video and context

4 min read