15 minlesson

Implementing Undo/Redo with the Command Pattern

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

typescript
1// Domain model
2interface InventoryItem {
3 sku: string;
4 warehouse: string;
5 quantity: number;
6 reservedQuantity: number;
7}
8
9class InventorySystem {
10 private inventory: Map<string, InventoryItem> = new Map();
11
12 getItem(sku: string, warehouse: string): InventoryItem | undefined {
13 return this.inventory.get(`${sku}:${warehouse}`);
14 }
15
16 setItem(item: InventoryItem): void {
17 this.inventory.set(`${item.sku}:${item.warehouse}`, item);
18 }
19
20 removeItem(sku: string, warehouse: string): void {
21 this.inventory.delete(`${sku}:${warehouse}`);
22 }
23}
24
25// Memento: State snapshot
26interface InventoryMemento {
27 item: InventoryItem | undefined;
28 existed: boolean;
29}
30
31// Command using Memento pattern
32class AdjustInventoryCommand implements Command {
33 private memento?: InventoryMemento;
34
35 constructor(
36 private inventory: InventorySystem,
37 private sku: string,
38 private warehouse: string,
39 private adjustment: number
40 ) {}
41
42 execute(): void {
43 const key = `${this.sku}:${this.warehouse}`;
44 const item = this.inventory.getItem(this.sku, this.warehouse);
45
46 // Save state before making changes (Memento)
47 this.memento = {
48 item: item ? { ...item } : undefined,
49 existed: item !== undefined
50 };
51
52 if (item) {
53 // Update existing item
54 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 positive
62 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: 0
70 };
71 this.inventory.setItem(newItem);
72 console.log(`✓ Created ${this.sku} at ${this.warehouse} with quantity ${this.adjustment}`);
73 }
74 }
75
76 undo(): void {
77 if (!this.memento) {
78 throw new Error('Cannot undo: command was not executed');
79 }
80
81 // Restore previous state from memento
82 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 }
90
91 canUndo(): boolean {
92 return this.memento !== undefined;
93 }
94
95 getDescription(): string {
96 return `Adjust inventory ${this.sku} at ${this.warehouse} by ${this.adjustment}`;
97 }
98
99 getId(): string {
100 return crypto.randomUUID();
101 }
102
103 getTimestamp(): Date {
104 return new Date();
105 }
106}

Compensating Transaction Undo Implementation

typescript
1// Command using compensating transactions
2class TransferInventoryCommand implements Command {
3 private executed: boolean = false;
4 private transferredQuantity?: number;
5
6 constructor(
7 private inventory: InventorySystem,
8 private sku: string,
9 private fromWarehouse: string,
10 private toWarehouse: string,
11 private quantity: number
12 ) {}
13
14 execute(): void {
15 const sourceItem = this.inventory.getItem(this.sku, this.fromWarehouse);
16
17 if (!sourceItem) {
18 throw new Error(`No inventory found: ${this.sku} at ${this.fromWarehouse}`);
19 }
20
21 if (sourceItem.quantity < this.quantity) {
22 throw new Error(
23 `Insufficient quantity: need ${this.quantity}, have ${sourceItem.quantity}`
24 );
25 }
26
27 // Deduct from source
28 const newSourceQuantity = sourceItem.quantity - this.quantity;
29 this.inventory.setItem({ ...sourceItem, quantity: newSourceQuantity });
30
31 // Add to destination
32 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: 0
42 });
43 }
44
45 this.transferredQuantity = this.quantity;
46 this.executed = true;
47
48 console.log(
49 `✓ Transferred ${this.quantity} units of ${this.sku}: ${this.fromWarehouse}${this.toWarehouse}`
50 );
51 }
52
53 undo(): void {
54 if (!this.executed || !this.transferredQuantity) {
55 throw new Error('Cannot undo: command was not executed successfully');
56 }
57
58 // Execute inverse operation (compensating transaction)
59 // Add back to source
60 const sourceItem = this.inventory.getItem(this.sku, this.fromWarehouse)!;
61 this.inventory.setItem({
62 ...sourceItem,
63 quantity: sourceItem.quantity + this.transferredQuantity
64 });
65
66 // Deduct from destination
67 const destItem = this.inventory.getItem(this.sku, this.toWarehouse)!;
68 const newDestQuantity = destItem.quantity - this.transferredQuantity;
69
70 if (newDestQuantity === 0) {
71 this.inventory.removeItem(this.sku, this.toWarehouse);
72 } else {
73 this.inventory.setItem({
74 ...destItem,
75 quantity: newDestQuantity
76 });
77 }
78
79 console.log(
80 `✗ Reversed transfer of ${this.transferredQuantity} units of ${this.sku}: ${this.toWarehouse}${this.fromWarehouse}`
81 );
82 }
83
84 canUndo(): boolean {
85 return this.executed;
86 }
87
88 getDescription(): string {
89 return `Transfer ${this.quantity} units of ${this.sku} from ${this.fromWarehouse} to ${this.toWarehouse}`;
90 }
91
92 getId(): string {
93 return crypto.randomUUID();
94 }
95
96 getTimestamp(): Date {
97 return new Date();
98 }
99}

Advanced Undo/Redo Stack Management

Undo/Redo Stack with Branching Support

typescript
1interface CommandNode {
2 command: Command;
3 children: CommandNode[];
4 parent?: CommandNode;
5}
6
7class BranchingCommandHistory {
8 private root: CommandNode | null = null;
9 private current: CommandNode | null = null;
10 private maxBranches: number;
11
12 constructor(maxBranches: number = 10) {
13 this.maxBranches = maxBranches;
14 }
15
16 execute(command: Command): void {
17 command.execute();
18
19 const newNode: CommandNode = {
20 command,
21 children: [],
22 parent: this.current || undefined
23 };
24
25 if (this.current) {
26 // Add as child of current node
27 this.current.children.push(newNode);
28
29 // Limit number of branches
30 if (this.current.children.length > this.maxBranches) {
31 this.current.children.shift();
32 }
33 } else {
34 this.root = newNode;
35 }
36
37 this.current = newNode;
38 }
39
40 undo(): boolean {
41 if (!this.current) {
42 console.log('Nothing to undo');
43 return false;
44 }
45
46 if (!this.current.command.canUndo()) {
47 console.log(`Cannot undo: ${this.current.command.getDescription()}`);
48 return false;
49 }
50
51 this.current.command.undo();
52 this.current = this.current.parent || null;
53 return true;
54 }
55
56 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 }
66
67 if (this.current.children.length === 0) {
68 console.log('Nothing to redo');
69 return false;
70 }
71
72 if (branchIndex >= this.current.children.length) {
73 console.log(`Invalid branch index: ${branchIndex}`);
74 return false;
75 }
76
77 this.current = this.current.children[branchIndex];
78 this.current.command.execute();
79 return true;
80 }
81
82 getAvailableBranches(): string[] {
83 if (!this.current) return [];
84
85 return this.current.children.map((node, idx) =>
86 `${idx}: ${node.command.getDescription()}`
87 );
88 }
89}

Undo/Redo with Time-Based Expiration

typescript
1class TimeLimitedCommandHistory {
2 private undoStack: Array<{ command: Command; timestamp: number }> = [];
3 private redoStack: Array<{ command: Command; timestamp: number }> = [];
4 private maxAge: number; // milliseconds
5
6 constructor(maxAgeMinutes: number = 60) {
7 this.maxAge = maxAgeMinutes * 60 * 1000;
8 }
9
10 execute(command: Command): void {
11 command.execute();
12
13 this.undoStack.push({
14 command,
15 timestamp: Date.now()
16 });
17
18 this.redoStack = [];
19 this.pruneExpiredCommands();
20 }
21
22 undo(): boolean {
23 this.pruneExpiredCommands();
24
25 if (this.undoStack.length === 0) {
26 console.log('Nothing to undo');
27 return false;
28 }
29
30 const entry = this.undoStack.pop()!;
31
32 if (!entry.command.canUndo()) {
33 console.log(`Cannot undo: ${entry.command.getDescription()}`);
34 this.undoStack.push(entry);
35 return false;
36 }
37
38 entry.command.undo();
39 this.redoStack.push(entry);
40 return true;
41 }
42
43 redo(): boolean {
44 this.pruneExpiredCommands();
45
46 if (this.redoStack.length === 0) {
47 console.log('Nothing to redo');
48 return false;
49 }
50
51 const entry = this.redoStack.pop()!;
52 entry.command.execute();
53 this.undoStack.push(entry);
54 return true;
55 }
56
57 private pruneExpiredCommands(): void {
58 const now = Date.now();
59
60 this.undoStack = this.undoStack.filter(entry =>
61 (now - entry.timestamp) < this.maxAge
62 );
63
64 this.redoStack = this.redoStack.filter(entry =>
65 (now - entry.timestamp) < this.maxAge
66 );
67 }
68
69 getUndoCount(): number {
70 this.pruneExpiredCommands();
71 return this.undoStack.length;
72 }
73
74 getRedoCount(): number {
75 this.pruneExpiredCommands();
76 return this.redoStack.length;
77 }
78}

Handling Non-Undoable Operations

Some operations cannot or should not be undone:

typescript
1// Non-undoable command example: Send email notification
2class SendNotificationCommand implements Command {
3 constructor(
4 private emailService: any,
5 private recipient: string,
6 private message: string
7 ) {}
8
9 execute(): void {
10 this.emailService.send(this.recipient, this.message);
11 console.log(`✓ Sent notification to ${this.recipient}`);
12 }
13
14 undo(): void {
15 console.log('⚠ Cannot unsend email notification');
16 // Don't actually do anything - emails can't be unsent
17 }
18
19 canUndo(): boolean {
20 return false; // Explicitly mark as non-undoable
21 }
22
23 getDescription(): string {
24 return `Send notification to ${this.recipient}`;
25 }
26
27 getId(): string {
28 return crypto.randomUUID();
29 }
30
31 getTimestamp(): Date {
32 return new Date();
33 }
34}
35
36// Macro command that handles non-undoable commands
37class SmartMacroCommand implements Command {
38 private commands: Command[] = [];
39 private executedCommands: Command[] = [];
40
41 constructor(private name: string, commands: Command[] = []) {
42 this.commands = commands;
43 }
44
45 execute(): void {
46 console.log(`▶ Executing macro: ${this.name}`);
47
48 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 commands
55 this.undo();
56 throw error;
57 }
58 }
59
60 console.log(`✓ Macro completed: ${this.name}`);
61 }
62
63 undo(): void {
64 console.log(`◀ Undoing macro: ${this.name}`);
65
66 // Undo in reverse order, but only undoable commands
67 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 }
75
76 this.executedCommands = [];
77 console.log(`✗ Macro undone: ${this.name}`);
78 }
79
80 canUndo(): boolean {
81 // Can undo if at least one command is undoable
82 return this.executedCommands.some(cmd => cmd.canUndo());
83 }
84
85 getDescription(): string {
86 return `Macro: ${this.name} (${this.commands.length} commands)`;
87 }
88
89 getId(): string {
90 return crypto.randomUUID();
91 }
92
93 getTimestamp(): Date {
94 return new Date();
95 }
96}

Undo/Redo Limitations and Considerations

1. External System Changes

typescript
1// Commands that interact with external systems need special handling
2class UpdateCarrierAPICommand implements Command {
3 private rollbackData?: any;
4
5 async execute(): Promise<void> {
6 // Save rollback data
7 this.rollbackData = await this.carrierAPI.getShipment(this.trackingNumber);
8
9 // Make API call
10 await this.carrierAPI.updateShipment(this.trackingNumber, this.updates);
11 }
12
13 async undo(): Promise<void> {
14 if (this.rollbackData) {
15 // Attempt to restore, but external state may have changed
16 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 }
24
25 canUndo(): boolean {
26 return this.rollbackData !== undefined;
27 }
28
29 // ... other methods
30}

2. Memory Management

typescript
1// Implement memory limits for command history
2class MemoryAwareCommandHistory {
3 private commands: Command[] = [];
4 private maxMemoryMB: number;
5
6 constructor(maxMemoryMB: number = 100) {
7 this.maxMemoryMB = maxMemoryMB;
8 }
9
10 execute(command: Command): void {
11 command.execute();
12 this.commands.push(command);
13
14 // Prune old commands if memory limit exceeded
15 this.enforceMemoryLimit();
16 }
17
18 private enforceMemoryLimit(): void {
19 // Rough estimation - would need proper memory profiling in production
20 const estimatedSize = this.commands.length * 0.1; // 0.1 MB per command (rough estimate)
21
22 while (estimatedSize > this.maxMemoryMB && this.commands.length > 1) {
23 this.commands.shift(); // Remove oldest command
24 }
25 }
26}

3. Concurrent Modifications

typescript
1// Version-based undo to detect concurrent modifications
2class VersionedCommand implements Command {
3 private executionVersion?: number;
4
5 constructor(
6 private versionedSystem: { getVersion(): number },
7 // ... other params
8 ) {}
9
10 execute(): void {
11 this.executionVersion = this.versionedSystem.getVersion();
12 // ... perform operation
13 }
14
15 canUndo(): boolean {
16 // Can only undo if state hasn't changed since execution
17 return this.executionVersion === this.versionedSystem.getVersion();
18 }
19
20 // ... other methods
21}

Best Practices

  1. Store minimal state - Only store what's needed to undo
  2. Handle failures gracefully - Implement rollback for partial failures
  3. Mark non-undoable commands - Use canUndo() to indicate limitations
  4. Limit history size - Prevent memory issues with bounded stacks
  5. Consider timing - Old undo operations may no longer be valid
  6. Document side effects - Clearly indicate what can't be undone
  7. 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.