Implementing Undo/Redo with the Command Pattern
One of the most powerful features of the Command pattern is built-in support for undo and redo operations. In this lesson, we'll explore different strategies for implementing reversible operations and understand their trade-offs.
Two Approaches to Undo
There are two main strategies for implementing undo functionality:
1. Memento-Based Undo (State Snapshot)
Store the complete state before making changes, then restore it on undo.
Pros:
- Simple to implement
- Guaranteed consistency
- Works for any operation
Cons:
- High memory usage for large objects
- May be slow for complex state
- Can be wasteful if only small changes are made
2. Compensating Transactions (Inverse Operations)
Store only the information needed to reverse the operation, then execute the inverse operation on undo.
Pros:
- Memory efficient
- Fast for simple operations
- More elegant for mathematical operations
Cons:
- Requires careful implementation
- Not all operations have clean inverses
- May be complex for interdependent changes
Memento-Based Undo Implementation
typescript1// Domain model2interface InventoryItem {3 sku: string;4 warehouse: string;5 quantity: number;6 reservedQuantity: number;7}89class InventorySystem {10 private inventory: Map<string, InventoryItem> = new Map();1112 getItem(sku: string, warehouse: string): InventoryItem | undefined {13 return this.inventory.get(`${sku}:${warehouse}`);14 }1516 setItem(item: InventoryItem): void {17 this.inventory.set(`${item.sku}:${item.warehouse}`, item);18 }1920 removeItem(sku: string, warehouse: string): void {21 this.inventory.delete(`${sku}:${warehouse}`);22 }23}2425// Memento: State snapshot26interface InventoryMemento {27 item: InventoryItem | undefined;28 existed: boolean;29}3031// Command using Memento pattern32class AdjustInventoryCommand implements Command {33 private memento?: InventoryMemento;3435 constructor(36 private inventory: InventorySystem,37 private sku: string,38 private warehouse: string,39 private adjustment: number40 ) {}4142 execute(): void {43 const key = `${this.sku}:${this.warehouse}`;44 const item = this.inventory.getItem(this.sku, this.warehouse);4546 // Save state before making changes (Memento)47 this.memento = {48 item: item ? { ...item } : undefined,49 existed: item !== undefined50 };5152 if (item) {53 // Update existing item54 const newQuantity = item.quantity + this.adjustment;55 if (newQuantity < 0) {56 throw new Error(`Insufficient inventory: ${this.sku} at ${this.warehouse}`);57 }58 this.inventory.setItem({ ...item, quantity: newQuantity });59 console.log(`✓ Adjusted ${this.sku} at ${this.warehouse}: ${item.quantity} → ${newQuantity}`);60 } else {61 // Create new item if adjustment is positive62 if (this.adjustment < 0) {63 throw new Error(`Cannot adjust non-existent inventory: ${this.sku} at ${this.warehouse}`);64 }65 const newItem: InventoryItem = {66 sku: this.sku,67 warehouse: this.warehouse,68 quantity: this.adjustment,69 reservedQuantity: 070 };71 this.inventory.setItem(newItem);72 console.log(`✓ Created ${this.sku} at ${this.warehouse} with quantity ${this.adjustment}`);73 }74 }7576 undo(): void {77 if (!this.memento) {78 throw new Error('Cannot undo: command was not executed');79 }8081 // Restore previous state from memento82 if (this.memento.existed && this.memento.item) {83 this.inventory.setItem(this.memento.item);84 console.log(`✗ Restored ${this.sku} at ${this.warehouse} to quantity ${this.memento.item.quantity}`);85 } else {86 this.inventory.removeItem(this.sku, this.warehouse);87 console.log(`✗ Removed ${this.sku} from ${this.warehouse}`);88 }89 }9091 canUndo(): boolean {92 return this.memento !== undefined;93 }9495 getDescription(): string {96 return `Adjust inventory ${this.sku} at ${this.warehouse} by ${this.adjustment}`;97 }9899 getId(): string {100 return crypto.randomUUID();101 }102103 getTimestamp(): Date {104 return new Date();105 }106}
Compensating Transaction Undo Implementation
typescript1// Command using compensating transactions2class TransferInventoryCommand implements Command {3 private executed: boolean = false;4 private transferredQuantity?: number;56 constructor(7 private inventory: InventorySystem,8 private sku: string,9 private fromWarehouse: string,10 private toWarehouse: string,11 private quantity: number12 ) {}1314 execute(): void {15 const sourceItem = this.inventory.getItem(this.sku, this.fromWarehouse);1617 if (!sourceItem) {18 throw new Error(`No inventory found: ${this.sku} at ${this.fromWarehouse}`);19 }2021 if (sourceItem.quantity < this.quantity) {22 throw new Error(23 `Insufficient quantity: need ${this.quantity}, have ${sourceItem.quantity}`24 );25 }2627 // Deduct from source28 const newSourceQuantity = sourceItem.quantity - this.quantity;29 this.inventory.setItem({ ...sourceItem, quantity: newSourceQuantity });3031 // Add to destination32 const destItem = this.inventory.getItem(this.sku, this.toWarehouse);33 if (destItem) {34 const newDestQuantity = destItem.quantity + this.quantity;35 this.inventory.setItem({ ...destItem, quantity: newDestQuantity });36 } else {37 this.inventory.setItem({38 sku: this.sku,39 warehouse: this.toWarehouse,40 quantity: this.quantity,41 reservedQuantity: 042 });43 }4445 this.transferredQuantity = this.quantity;46 this.executed = true;4748 console.log(49 `✓ Transferred ${this.quantity} units of ${this.sku}: ${this.fromWarehouse} → ${this.toWarehouse}`50 );51 }5253 undo(): void {54 if (!this.executed || !this.transferredQuantity) {55 throw new Error('Cannot undo: command was not executed successfully');56 }5758 // Execute inverse operation (compensating transaction)59 // Add back to source60 const sourceItem = this.inventory.getItem(this.sku, this.fromWarehouse)!;61 this.inventory.setItem({62 ...sourceItem,63 quantity: sourceItem.quantity + this.transferredQuantity64 });6566 // Deduct from destination67 const destItem = this.inventory.getItem(this.sku, this.toWarehouse)!;68 const newDestQuantity = destItem.quantity - this.transferredQuantity;6970 if (newDestQuantity === 0) {71 this.inventory.removeItem(this.sku, this.toWarehouse);72 } else {73 this.inventory.setItem({74 ...destItem,75 quantity: newDestQuantity76 });77 }7879 console.log(80 `✗ Reversed transfer of ${this.transferredQuantity} units of ${this.sku}: ${this.toWarehouse} → ${this.fromWarehouse}`81 );82 }8384 canUndo(): boolean {85 return this.executed;86 }8788 getDescription(): string {89 return `Transfer ${this.quantity} units of ${this.sku} from ${this.fromWarehouse} to ${this.toWarehouse}`;90 }9192 getId(): string {93 return crypto.randomUUID();94 }9596 getTimestamp(): Date {97 return new Date();98 }99}
Advanced Undo/Redo Stack Management
Undo/Redo Stack with Branching Support
typescript1interface CommandNode {2 command: Command;3 children: CommandNode[];4 parent?: CommandNode;5}67class BranchingCommandHistory {8 private root: CommandNode | null = null;9 private current: CommandNode | null = null;10 private maxBranches: number;1112 constructor(maxBranches: number = 10) {13 this.maxBranches = maxBranches;14 }1516 execute(command: Command): void {17 command.execute();1819 const newNode: CommandNode = {20 command,21 children: [],22 parent: this.current || undefined23 };2425 if (this.current) {26 // Add as child of current node27 this.current.children.push(newNode);2829 // Limit number of branches30 if (this.current.children.length > this.maxBranches) {31 this.current.children.shift();32 }33 } else {34 this.root = newNode;35 }3637 this.current = newNode;38 }3940 undo(): boolean {41 if (!this.current) {42 console.log('Nothing to undo');43 return false;44 }4546 if (!this.current.command.canUndo()) {47 console.log(`Cannot undo: ${this.current.command.getDescription()}`);48 return false;49 }5051 this.current.command.undo();52 this.current = this.current.parent || null;53 return true;54 }5556 redo(branchIndex: number = 0): boolean {57 if (!this.current) {58 if (this.root) {59 this.current = this.root;60 this.current.command.execute();61 return true;62 }63 console.log('Nothing to redo');64 return false;65 }6667 if (this.current.children.length === 0) {68 console.log('Nothing to redo');69 return false;70 }7172 if (branchIndex >= this.current.children.length) {73 console.log(`Invalid branch index: ${branchIndex}`);74 return false;75 }7677 this.current = this.current.children[branchIndex];78 this.current.command.execute();79 return true;80 }8182 getAvailableBranches(): string[] {83 if (!this.current) return [];8485 return this.current.children.map((node, idx) =>86 `${idx}: ${node.command.getDescription()}`87 );88 }89}
Undo/Redo with Time-Based Expiration
typescript1class TimeLimitedCommandHistory {2 private undoStack: Array<{ command: Command; timestamp: number }> = [];3 private redoStack: Array<{ command: Command; timestamp: number }> = [];4 private maxAge: number; // milliseconds56 constructor(maxAgeMinutes: number = 60) {7 this.maxAge = maxAgeMinutes * 60 * 1000;8 }910 execute(command: Command): void {11 command.execute();1213 this.undoStack.push({14 command,15 timestamp: Date.now()16 });1718 this.redoStack = [];19 this.pruneExpiredCommands();20 }2122 undo(): boolean {23 this.pruneExpiredCommands();2425 if (this.undoStack.length === 0) {26 console.log('Nothing to undo');27 return false;28 }2930 const entry = this.undoStack.pop()!;3132 if (!entry.command.canUndo()) {33 console.log(`Cannot undo: ${entry.command.getDescription()}`);34 this.undoStack.push(entry);35 return false;36 }3738 entry.command.undo();39 this.redoStack.push(entry);40 return true;41 }4243 redo(): boolean {44 this.pruneExpiredCommands();4546 if (this.redoStack.length === 0) {47 console.log('Nothing to redo');48 return false;49 }5051 const entry = this.redoStack.pop()!;52 entry.command.execute();53 this.undoStack.push(entry);54 return true;55 }5657 private pruneExpiredCommands(): void {58 const now = Date.now();5960 this.undoStack = this.undoStack.filter(entry =>61 (now - entry.timestamp) < this.maxAge62 );6364 this.redoStack = this.redoStack.filter(entry =>65 (now - entry.timestamp) < this.maxAge66 );67 }6869 getUndoCount(): number {70 this.pruneExpiredCommands();71 return this.undoStack.length;72 }7374 getRedoCount(): number {75 this.pruneExpiredCommands();76 return this.redoStack.length;77 }78}
Handling Non-Undoable Operations
Some operations cannot or should not be undone:
typescript1// Non-undoable command example: Send email notification2class SendNotificationCommand implements Command {3 constructor(4 private emailService: any,5 private recipient: string,6 private message: string7 ) {}89 execute(): void {10 this.emailService.send(this.recipient, this.message);11 console.log(`✓ Sent notification to ${this.recipient}`);12 }1314 undo(): void {15 console.log('⚠ Cannot unsend email notification');16 // Don't actually do anything - emails can't be unsent17 }1819 canUndo(): boolean {20 return false; // Explicitly mark as non-undoable21 }2223 getDescription(): string {24 return `Send notification to ${this.recipient}`;25 }2627 getId(): string {28 return crypto.randomUUID();29 }3031 getTimestamp(): Date {32 return new Date();33 }34}3536// Macro command that handles non-undoable commands37class SmartMacroCommand implements Command {38 private commands: Command[] = [];39 private executedCommands: Command[] = [];4041 constructor(private name: string, commands: Command[] = []) {42 this.commands = commands;43 }4445 execute(): void {46 console.log(`▶ Executing macro: ${this.name}`);4748 for (const command of this.commands) {49 try {50 command.execute();51 this.executedCommands.push(command);52 } catch (error) {53 console.error(`Failed to execute ${command.getDescription()}:`, error);54 // Rollback executed commands55 this.undo();56 throw error;57 }58 }5960 console.log(`✓ Macro completed: ${this.name}`);61 }6263 undo(): void {64 console.log(`◀ Undoing macro: ${this.name}`);6566 // Undo in reverse order, but only undoable commands67 for (let i = this.executedCommands.length - 1; i >= 0; i--) {68 const command = this.executedCommands[i];69 if (command.canUndo()) {70 command.undo();71 } else {72 console.log(`⚠ Skipping non-undoable: ${command.getDescription()}`);73 }74 }7576 this.executedCommands = [];77 console.log(`✗ Macro undone: ${this.name}`);78 }7980 canUndo(): boolean {81 // Can undo if at least one command is undoable82 return this.executedCommands.some(cmd => cmd.canUndo());83 }8485 getDescription(): string {86 return `Macro: ${this.name} (${this.commands.length} commands)`;87 }8889 getId(): string {90 return crypto.randomUUID();91 }9293 getTimestamp(): Date {94 return new Date();95 }96}
Undo/Redo Limitations and Considerations
1. External System Changes
typescript1// Commands that interact with external systems need special handling2class UpdateCarrierAPICommand implements Command {3 private rollbackData?: any;45 async execute(): Promise<void> {6 // Save rollback data7 this.rollbackData = await this.carrierAPI.getShipment(this.trackingNumber);89 // Make API call10 await this.carrierAPI.updateShipment(this.trackingNumber, this.updates);11 }1213 async undo(): Promise<void> {14 if (this.rollbackData) {15 // Attempt to restore, but external state may have changed16 try {17 await this.carrierAPI.updateShipment(this.trackingNumber, this.rollbackData);18 } catch (error) {19 console.error('Failed to undo external API change:', error);20 // Log to audit trail, alert admin, etc.21 }22 }23 }2425 canUndo(): boolean {26 return this.rollbackData !== undefined;27 }2829 // ... other methods30}
2. Memory Management
typescript1// Implement memory limits for command history2class MemoryAwareCommandHistory {3 private commands: Command[] = [];4 private maxMemoryMB: number;56 constructor(maxMemoryMB: number = 100) {7 this.maxMemoryMB = maxMemoryMB;8 }910 execute(command: Command): void {11 command.execute();12 this.commands.push(command);1314 // Prune old commands if memory limit exceeded15 this.enforceMemoryLimit();16 }1718 private enforceMemoryLimit(): void {19 // Rough estimation - would need proper memory profiling in production20 const estimatedSize = this.commands.length * 0.1; // 0.1 MB per command (rough estimate)2122 while (estimatedSize > this.maxMemoryMB && this.commands.length > 1) {23 this.commands.shift(); // Remove oldest command24 }25 }26}
3. Concurrent Modifications
typescript1// Version-based undo to detect concurrent modifications2class VersionedCommand implements Command {3 private executionVersion?: number;45 constructor(6 private versionedSystem: { getVersion(): number },7 // ... other params8 ) {}910 execute(): void {11 this.executionVersion = this.versionedSystem.getVersion();12 // ... perform operation13 }1415 canUndo(): boolean {16 // Can only undo if state hasn't changed since execution17 return this.executionVersion === this.versionedSystem.getVersion();18 }1920 // ... other methods21}
Best Practices
- Store minimal state - Only store what's needed to undo
- Handle failures gracefully - Implement rollback for partial failures
- Mark non-undoable commands - Use
canUndo()to indicate limitations - Limit history size - Prevent memory issues with bounded stacks
- Consider timing - Old undo operations may no longer be valid
- Document side effects - Clearly indicate what can't be undone
- Test edge cases - Verify undo works after various state changes
Key Takeaways
- Two undo strategies: Memento (state snapshot) vs Compensating Transactions (inverse operations)
- Memento is simpler but uses more memory
- Compensating transactions are efficient but require careful design
- Not all operations are undoable - use
canUndo()to indicate this - Advanced features: branching history, time-based expiration, memory limits
- Always consider external systems, concurrent modifications, and memory usage
- Undo/redo makes applications more user-friendly and safer
In the next workshop, you'll implement a complete order command system with undo/redo support.