Mastering the Command Pattern: Implementing Undo, Redo, and Batch Transactions in Python
Overview: Encapsulating Intent
The
Prerequisites
To get the most out of this tutorial, you should be comfortable with

Key Libraries & Tools
- Python3.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
Transactioninterface, 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
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 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()andredo()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.

Fancy watching it?
Watch the full video and context