Loading content...
The Memento Pattern isn't just an academic exercise—it's a workhorse in production software across industries. From the Ctrl+Z you use hundreds of times daily to game save systems that preserve hours of progress, the pattern enables some of the most fundamental user experiences in software.
In this page, we'll explore comprehensive, production-quality implementations across diverse domains. Each example demonstrates not just the pattern's structure, but the real-world considerations that make implementations robust, efficient, and maintainable.
By the end of this page, you will understand: • How to implement undo/redo with the Memento Pattern • State preservation in games, forms, and transactions • Memory-efficient strategies for large state • Combining Memento with other patterns • When to use Memento vs alternatives
A text editor's undo/redo system is the canonical Memento Pattern example. Let's build a complete, production-quality implementation with:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
// ============================================================// COMPLETE TEXT EDITOR WITH MEMENTO PATTERN// Production-quality implementation// ============================================================ // === TYPES ===interface Selection { readonly start: number; readonly end: number;} interface TextFormat { readonly bold?: boolean; readonly italic?: boolean; readonly fontSize?: number; readonly color?: string;} interface FormatRange { readonly start: number; readonly end: number; readonly format: TextFormat;} // === MEMENTO ===interface IEditorSnapshot { readonly id: string; readonly timestamp: Date; readonly description: string;} class EditorSnapshot implements IEditorSnapshot { readonly id: string; readonly timestamp: Date; readonly description: string; constructor( private readonly text: string, private readonly cursorPosition: number, private readonly selection: Selection | null, private readonly formatRanges: readonly FormatRange[], description?: string ) { this.id = crypto.randomUUID(); this.timestamp = new Date(); this.description = description || `Snapshot: "${text.slice(0, 20)}${text.length > 20 ? '...' : ''}"`; } // Wide interface - Originator access getText(): string { return this.text; } getCursorPosition(): number { return this.cursorPosition; } getSelection(): Selection | null { return this.selection; } getFormatRanges(): readonly FormatRange[] { return this.formatRanges; } // Memory estimation for smart history management getMemorySize(): number { return this.text.length * 2 + // UTF-16 JSON.stringify(this.formatRanges).length; }} // === ORIGINATOR ===class RichTextEditor { private text: string = ''; private cursorPosition: number = 0; private selection: Selection | null = null; private formatRanges: FormatRange[] = []; // === Business Operations === type(input: string): void { if (this.selection) { // Replace selection this.text = this.text.slice(0, this.selection.start) + input + this.text.slice(this.selection.end); this.cursorPosition = this.selection.start + input.length; this.adjustFormattingAfterEdit( this.selection.start, input.length - (this.selection.end - this.selection.start) ); this.selection = null; } else { // Insert at cursor this.text = this.text.slice(0, this.cursorPosition) + input + this.text.slice(this.cursorPosition); this.adjustFormattingAfterEdit(this.cursorPosition, input.length); this.cursorPosition += input.length; } } delete(): void { if (this.selection) { this.text = this.text.slice(0, this.selection.start) + this.text.slice(this.selection.end); this.cursorPosition = this.selection.start; this.adjustFormattingAfterEdit( this.selection.start, -(this.selection.end - this.selection.start) ); this.selection = null; } else if (this.cursorPosition > 0) { this.text = this.text.slice(0, this.cursorPosition - 1) + this.text.slice(this.cursorPosition); this.cursorPosition--; this.adjustFormattingAfterEdit(this.cursorPosition, -1); } } applyFormat(start: number, end: number, format: TextFormat): void { // Remove overlapping formats, add new this.formatRanges = this.formatRanges.filter( r => r.end <= start || r.start >= end ); this.formatRanges.push({ start, end, format }); this.formatRanges.sort((a, b) => a.start - b.start); } private adjustFormattingAfterEdit(position: number, delta: number): void { this.formatRanges = this.formatRanges .map(range => { if (range.end <= position) return range; if (range.start >= position) { return { ...range, start: range.start + delta, end: range.end + delta }; } return { ...range, end: Math.max(range.start, range.end + delta) }; }) .filter(range => range.start < range.end); } // === Selection & Navigation === select(start: number, end: number): void { this.selection = { start: Math.max(0, Math.min(start, this.text.length)), end: Math.max(0, Math.min(end, this.text.length)) }; } moveCursor(position: number): void { this.cursorPosition = Math.max(0, Math.min(position, this.text.length)); this.selection = null; } // === Memento Operations === createSnapshot(description?: string): EditorSnapshot { return new EditorSnapshot( this.text, this.cursorPosition, this.selection ? { ...this.selection } : null, this.formatRanges.map(r => ({ ...r, format: { ...r.format } })), description ); } restore(snapshot: EditorSnapshot): void { this.text = snapshot.getText(); this.cursorPosition = snapshot.getCursorPosition(); const sel = snapshot.getSelection(); this.selection = sel ? { ...sel } : null; this.formatRanges = [...snapshot.getFormatRanges()] .map(r => ({ ...r, format: { ...r.format } })); } // === Display === getContent(): string { return this.text; } getCursor(): number { return this.cursorPosition; } getDisplayState(): string { return [ `Text: "${this.text}"`, `Cursor: ${this.cursorPosition}`, `Selection: ${this.selection ? `[${this.selection.start}-${this.selection.end}]` : 'none'}`, `Formats: ${this.formatRanges.length}` ].join(' | '); }}Now let's implement the Caretaker with advanced features:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
// === CARETAKER: Advanced Undo Manager === interface UndoManagerConfig { maxHistorySize?: number; // Max snapshots to keep maxMemoryBytes?: number; // Memory limit groupingWindowMs?: number; // Time window for grouping edits} class AdvancedUndoManager { private undoStack: IEditorSnapshot[] = []; private redoStack: IEditorSnapshot[] = []; private lastSaveTime: number = 0; private config: Required<UndoManagerConfig>; constructor(config: UndoManagerConfig = {}) { this.config = { maxHistorySize: config.maxHistorySize ?? 100, maxMemoryBytes: config.maxMemoryBytes ?? 10 * 1024 * 1024, // 10MB groupingWindowMs: config.groupingWindowMs ?? 500 }; } /** * Smart save: groups rapid edits together. * Call before each modification. */ saveSnapshot( editor: RichTextEditor, forceNew: boolean = false, description?: string ): void { const now = Date.now(); const shouldGroup = !forceNew && this.undoStack.length > 0 && (now - this.lastSaveTime) < this.config.groupingWindowMs; if (shouldGroup) { // Skip - will group with previous return; } const snapshot = editor.createSnapshot(description); this.undoStack.push(snapshot); this.redoStack = []; this.lastSaveTime = now; this.enforceMemoryLimits(); } /** * Force a new undo point regardless of grouping. * Use for significant operations like formatting. */ saveCheckpoint(editor: RichTextEditor, description: string): void { this.saveSnapshot(editor, true, description); } /** * Undo to previous state. */ undo(editor: RichTextEditor): boolean { if (this.undoStack.length === 0) return false; // Save current state for redo this.redoStack.push(editor.createSnapshot('Before undo')); // Restore previous const previous = this.undoStack.pop()!; editor.restore(previous as EditorSnapshot); console.log(`Undo: ${previous.description}`); return true; } /** * Redo previously undone change. */ redo(editor: RichTextEditor): boolean { if (this.redoStack.length === 0) return false; // Save current for undo this.undoStack.push(editor.createSnapshot('Before redo')); // Restore redo state const redoSnapshot = this.redoStack.pop()!; editor.restore(redoSnapshot as EditorSnapshot); console.log(`Redo: ${redoSnapshot.description}`); return true; } /** * Enforce memory limits by pruning old history. */ private enforceMemoryLimits(): void { // Count-based limit while (this.undoStack.length > this.config.maxHistorySize) { this.undoStack.shift(); } // Memory-based limit let totalMemory = this.undoStack.reduce( (sum, snap) => sum + (snap as EditorSnapshot).getMemorySize(), 0 ); while (totalMemory > this.config.maxMemoryBytes && this.undoStack.length > 1) { const removed = this.undoStack.shift() as EditorSnapshot; totalMemory -= removed.getMemorySize(); } } // === Query Methods === canUndo(): boolean { return this.undoStack.length > 0; } canRedo(): boolean { return this.redoStack.length > 0; } undoCount(): number { return this.undoStack.length; } redoCount(): number { return this.redoStack.length; } getHistory(): string[] { return this.undoStack.map(s => s.description); }} // === USAGE EXAMPLE === function demonstrateTextEditor(): void { const editor = new RichTextEditor(); const undoManager = new AdvancedUndoManager({ maxHistorySize: 50, groupingWindowMs: 300 }); console.log('=== Rich Text Editor Demo ===\n'); // Typing with automatic grouping undoManager.saveSnapshot(editor); editor.type('Hello'); console.log(editor.getDisplayState()); // Rapid typing groups together undoManager.saveSnapshot(editor); editor.type(' '); undoManager.saveSnapshot(editor); editor.type('World'); console.log(editor.getDisplayState()); // Force checkpoint for significant action undoManager.saveCheckpoint(editor, 'Apply bold formatting'); editor.applyFormat(0, 5, { bold: true }); // Undo sequence console.log('\n--- Undo Operations ---'); undoManager.undo(editor); console.log(`After undo: ${editor.getDisplayState()}`); undoManager.undo(editor); console.log(`After undo: ${editor.getDisplayState()}`); // Redo console.log('\n--- Redo ---'); undoManager.redo(editor); console.log(`After redo: ${editor.getDisplayState()}`);}Games present unique challenges for state preservation: large state, complex object graphs, and the need for both quick saves and persistent storage. Let's build a comprehensive game save system.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
// ============================================================// GAME SAVE SYSTEM WITH MEMENTO PATTERN// Handles player state, inventory, world state, and quests// ============================================================ // === GAME STATE TYPES ===interface Vector3 { readonly x: number; readonly y: number; readonly z: number;} interface PlayerStats { readonly health: number; readonly maxHealth: number; readonly mana: number; readonly maxMana: number; readonly experience: number; readonly level: number;} interface InventoryItem { readonly id: string; readonly name: string; readonly quantity: number; readonly equipped: boolean;} interface QuestProgress { readonly questId: string; readonly status: 'not_started' | 'in_progress' | 'completed'; readonly objectives: Record<string, number>;} interface WorldStateData { readonly unlockedAreas: readonly string[]; readonly discoveredLocations: readonly string[]; readonly defeatedBosses: readonly string[]; readonly environmentChanges: Record<string, unknown>;} // === MEMENTO: Game Save ===interface IGameSave { readonly id: string; readonly timestamp: Date; readonly playTime: number; // seconds readonly saveSlot: number; readonly description: string; readonly thumbnail?: string; // Base64 screenshot} class GameSave implements IGameSave { readonly id: string; readonly timestamp: Date; constructor( // Narrow interface data readonly playTime: number, readonly saveSlot: number, readonly description: string, readonly thumbnail: string | undefined, // Wide interface data (private) private readonly playerPosition: Vector3, private readonly playerStats: PlayerStats, private readonly inventory: readonly InventoryItem[], private readonly quests: readonly QuestProgress[], private readonly worldState: WorldStateData ) { this.id = crypto.randomUUID(); this.timestamp = new Date(); } // === Wide Interface (Game access) === getPlayerPosition(): Vector3 { return this.playerPosition; } getPlayerStats(): PlayerStats { return this.playerStats; } getInventory(): readonly InventoryItem[] { return this.inventory; } getQuests(): readonly QuestProgress[] { return this.quests; } getWorldState(): WorldStateData { return this.worldState; } // === Serialization for Persistence === serialize(): string { return JSON.stringify({ id: this.id, timestamp: this.timestamp.toISOString(), playTime: this.playTime, saveSlot: this.saveSlot, description: this.description, thumbnail: this.thumbnail, playerPosition: this.playerPosition, playerStats: this.playerStats, inventory: this.inventory, quests: this.quests, worldState: this.worldState }); } static deserialize(json: string): GameSave { const data = JSON.parse(json); const save = new GameSave( data.playTime, data.saveSlot, data.description, data.thumbnail, data.playerPosition, data.playerStats, data.inventory, data.quests, data.worldState ); // Restore readonly properties (save as any).id = data.id; (save as any).timestamp = new Date(data.timestamp); return save; }} // === ORIGINATOR: Game State ===class GameState { private playTimeSeconds: number = 0; private playerPosition: Vector3 = { x: 0, y: 0, z: 0 }; private playerStats: PlayerStats = { health: 100, maxHealth: 100, mana: 50, maxMana: 50, experience: 0, level: 1 }; private inventory: InventoryItem[] = []; private quests: QuestProgress[] = []; private worldState: WorldStateData = { unlockedAreas: ['starting_zone'], discoveredLocations: ['spawn_point'], defeatedBosses: [], environmentChanges: {} }; // === Game Logic Methods === movePlayer(delta: Vector3): void { this.playerPosition = { x: this.playerPosition.x + delta.x, y: this.playerPosition.y + delta.y, z: this.playerPosition.z + delta.z }; } takeDamage(amount: number): void { this.playerStats = { ...this.playerStats, health: Math.max(0, this.playerStats.health - amount) }; } gainExperience(amount: number): void { const newExp = this.playerStats.experience + amount; const newLevel = Math.floor(newExp / 1000) + 1; this.playerStats = { ...this.playerStats, experience: newExp, level: Math.max(this.playerStats.level, newLevel), maxHealth: 100 + (newLevel - 1) * 20 }; } addItem(item: Omit<InventoryItem, 'equipped'>): void { const existing = this.inventory.find(i => i.id === item.id); if (existing) { this.inventory = this.inventory.map(i => i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i ); } else { this.inventory.push({ ...item, equipped: false }); } } completeObjective(questId: string, objectiveId: string): void { this.quests = this.quests.map(q => { if (q.questId !== questId) return q; return { ...q, objectives: { ...q.objectives, [objectiveId]: (q.objectives[objectiveId] || 0) + 1 } }; }); } updatePlayTime(seconds: number): void { this.playTimeSeconds += seconds; } // === Memento Operations === createSave(slot: number, description: string, thumbnail?: string): GameSave { return new GameSave( this.playTimeSeconds, slot, description, thumbnail, { ...this.playerPosition }, { ...this.playerStats }, this.inventory.map(i => ({ ...i })), this.quests.map(q => ({ ...q, objectives: { ...q.objectives } })), { ...this.worldState, unlockedAreas: [...this.worldState.unlockedAreas], discoveredLocations: [...this.worldState.discoveredLocations], defeatedBosses: [...this.worldState.defeatedBosses], environmentChanges: { ...this.worldState.environmentChanges } } ); } loadSave(save: GameSave): void { this.playTimeSeconds = save.playTime; this.playerPosition = { ...save.getPlayerPosition() }; this.playerStats = { ...save.getPlayerStats() }; this.inventory = [...save.getInventory()].map(i => ({ ...i })); this.quests = [...save.getQuests()].map(q => ({ ...q, objectives: { ...q.objectives } })); const ws = save.getWorldState(); this.worldState = { ...ws, unlockedAreas: [...ws.unlockedAreas], discoveredLocations: [...ws.discoveredLocations], defeatedBosses: [...ws.defeatedBosses], environmentChanges: { ...ws.environmentChanges } }; console.log(`Game loaded: ${save.description}`); } getStatus(): string { return [ `Level ${this.playerStats.level}`, `HP: ${this.playerStats.health}/${this.playerStats.maxHealth}`, `Pos: (${this.playerPosition.x}, ${this.playerPosition.y})`, `Items: ${this.inventory.length}`, `Play time: ${Math.floor(this.playTimeSeconds / 60)}m` ].join(' | '); }}Now the Caretaker with multiple save slots and auto-save:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
// === CARETAKER: Save Manager === class SaveManager { private saves: Map<number, IGameSave> = new Map(); private autoSaves: IGameSave[] = []; private readonly maxAutoSaves = 3; private readonly maxSlots = 10; // Simulated persistent storage private storage: Map<string, string> = new Map(); /** * Manual save to a slot. */ saveToSlot( game: GameState, slot: number, description: string, thumbnail?: string ): boolean { if (slot < 0 || slot >= this.maxSlots) { console.error(`Invalid slot: ${slot}`); return false; } const save = game.createSave(slot, description, thumbnail); this.saves.set(slot, save); // Persist to storage this.storage.set(`save_slot_${slot}`, save.serialize()); console.log(`Saved to slot ${slot}: ${description}`); return true; } /** * Load from a slot. */ loadFromSlot(game: GameState, slot: number): boolean { const save = this.saves.get(slot); if (!save) { // Try loading from storage const serialized = this.storage.get(`save_slot_${slot}`); if (serialized) { const loadedSave = GameSave.deserialize(serialized); this.saves.set(slot, loadedSave); game.loadSave(loadedSave); return true; } console.error(`No save in slot ${slot}`); return false; } game.loadSave(save as GameSave); return true; } /** * Auto-save (rotating buffer). */ autoSave(game: GameState): void { const save = game.createSave( -1, // Auto-save uses -1 `Auto-save at ${new Date().toLocaleTimeString()}` ); this.autoSaves.push(save); // Keep only recent auto-saves while (this.autoSaves.length > this.maxAutoSaves) { this.autoSaves.shift(); } console.log('Auto-saved'); } /** * Load most recent auto-save. */ loadLatestAutoSave(game: GameState): boolean { if (this.autoSaves.length === 0) { console.error('No auto-saves available'); return false; } const latest = this.autoSaves[this.autoSaves.length - 1]; game.loadSave(latest as GameSave); return true; } /** * Quick save (single rotating slot). */ private quickSave: IGameSave | null = null; quickSaveGame(game: GameState): void { this.quickSave = game.createSave(-2, 'Quick Save'); console.log('Quick saved'); } quickLoadGame(game: GameState): boolean { if (!this.quickSave) { console.error('No quick save'); return false; } game.loadSave(this.quickSave as GameSave); return true; } /** * List available saves. */ listSaves(): void { console.log('\n=== Save Slots ==='); for (let i = 0; i < this.maxSlots; i++) { const save = this.saves.get(i); if (save) { const mins = Math.floor(save.playTime / 60); console.log(` Slot ${i}: ${save.description} (${mins}m played)`); } else { console.log(` Slot ${i}: [Empty]`); } } console.log(` Auto-saves: ${this.autoSaves.length}`); console.log(` Quick save: ${this.quickSave ? 'Yes' : 'No'}`); }} // === USAGE ===function demonstrateGameSaveSystem(): void { const game = new GameState(); const saveManager = new SaveManager(); console.log('=== Game Save System Demo ===\n'); // Play the game game.updatePlayTime(120); game.movePlayer({ x: 10, y: 0, z: 5 }); game.gainExperience(500); game.addItem({ id: 'sword_01', name: 'Iron Sword', quantity: 1 }); console.log(`Playing: ${game.getStatus()}`); // Manual save saveManager.saveToSlot(game, 0, 'Start of adventure'); // Auto-save triggers saveManager.autoSave(game); // More gameplay game.updatePlayTime(300); game.takeDamage(30); game.gainExperience(800); console.log(`After combat: ${game.getStatus()}`); // Quick save before boss saveManager.quickSaveGame(game); // Die to boss game.takeDamage(100); console.log(`Dead: ${game.getStatus()}`); // Quick load console.log('\n--- Quick Loading ---'); saveManager.quickLoadGame(game); console.log(`Restored: ${game.getStatus()}`); saveManager.listSaves();}Financial systems and databases require atomic operations—either all changes succeed, or none do. The Memento Pattern provides a clean implementation for transaction rollback.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
// ============================================================// TRANSACTION SYSTEM WITH MEMENTO PATTERN// Implements atomic operations with rollback support// ============================================================ // === BANK ACCOUNT MEMENTO ===interface IAccountSnapshot { readonly transactionId: string; readonly timestamp: Date;} class AccountSnapshot implements IAccountSnapshot { readonly transactionId: string; readonly timestamp: Date; constructor( private readonly balance: number, private readonly transactionHistory: readonly TransactionRecord[], private readonly holds: readonly Hold[] ) { this.transactionId = crypto.randomUUID(); this.timestamp = new Date(); } getBalance(): number { return this.balance; } getTransactionHistory(): readonly TransactionRecord[] { return this.transactionHistory; } getHolds(): readonly Hold[] { return this.holds; }} interface TransactionRecord { readonly id: string; readonly type: 'credit' | 'debit'; readonly amount: number; readonly description: string; readonly timestamp: Date;} interface Hold { readonly id: string; readonly amount: number; readonly reason: string; readonly expiresAt: Date;} // === ORIGINATOR: Bank Account ===class BankAccount { private accountNumber: string; private balance: number; private transactionHistory: TransactionRecord[] = []; private holds: Hold[] = []; constructor(accountNumber: string, initialBalance: number = 0) { this.accountNumber = accountNumber; this.balance = initialBalance; } // === Account Operations === getAvailableBalance(): number { const totalHolds = this.holds.reduce((sum, h) => sum + h.amount, 0); return Math.max(0, this.balance - totalHolds); } credit(amount: number, description: string): void { if (amount <= 0) throw new Error('Amount must be positive'); this.balance += amount; this.addTransaction('credit', amount, description); } debit(amount: number, description: string): void { if (amount <= 0) throw new Error('Amount must be positive'); if (amount > this.getAvailableBalance()) { throw new Error('Insufficient funds'); } this.balance -= amount; this.addTransaction('debit', amount, description); } placeHold(amount: number, reason: string, durationHours: number): string { if (amount > this.getAvailableBalance()) { throw new Error('Insufficient funds for hold'); } const hold: Hold = { id: crypto.randomUUID(), amount, reason, expiresAt: new Date(Date.now() + durationHours * 3600000) }; this.holds.push(hold); return hold.id; } releaseHold(holdId: string): void { const index = this.holds.findIndex(h => h.id === holdId); if (index >= 0) { this.holds.splice(index, 1); } } private addTransaction( type: 'credit' | 'debit', amount: number, description: string ): void { this.transactionHistory.push({ id: crypto.randomUUID(), type, amount, description, timestamp: new Date() }); } // === Memento Operations === createSnapshot(): AccountSnapshot { return new AccountSnapshot( this.balance, this.transactionHistory.map(t => ({ ...t })), this.holds.map(h => ({ ...h })) ); } restore(snapshot: AccountSnapshot): void { this.balance = snapshot.getBalance(); this.transactionHistory = [...snapshot.getTransactionHistory()] .map(t => ({ ...t })); this.holds = [...snapshot.getHolds()].map(h => ({ ...h })); } getStatus(): string { return `Account ${this.accountNumber}: $${this.balance.toFixed(2)} ` + `(Available: $${this.getAvailableBalance().toFixed(2)})`; }} // === CARETAKER: Transaction Manager ===class TransactionManager { private pendingSnapshots: Map<string, IAccountSnapshot[]> = new Map(); /** * Begin a transaction across multiple accounts. */ beginTransaction(): string { const txId = crypto.randomUUID(); this.pendingSnapshots.set(txId, []); console.log(`Transaction ${txId.slice(0, 8)} started`); return txId; } /** * Enlist an account in the transaction. * Saves its current state for potential rollback. */ enlist(txId: string, account: BankAccount): void { const snapshots = this.pendingSnapshots.get(txId); if (!snapshots) throw new Error('Transaction not found'); snapshots.push(account.createSnapshot()); } /** * Commit the transaction - clear snapshots. */ commit(txId: string): void { if (!this.pendingSnapshots.has(txId)) { throw new Error('Transaction not found'); } this.pendingSnapshots.delete(txId); console.log(`Transaction ${txId.slice(0, 8)} committed`); } /** * Rollback all accounts to their pre-transaction state. */ rollback(txId: string, accounts: BankAccount[]): void { const snapshots = this.pendingSnapshots.get(txId); if (!snapshots) throw new Error('Transaction not found'); // Restore each account from its snapshot // Note: In real implementation, you'd need to match accounts to snapshots for (let i = 0; i < accounts.length && i < snapshots.length; i++) { accounts[i].restore(snapshots[i] as AccountSnapshot); } this.pendingSnapshots.delete(txId); console.log(`Transaction ${txId.slice(0, 8)} rolled back`); }} // === USAGE: Transfer with Rollback ===function demonstrateTransactionRollback(): void { const txManager = new TransactionManager(); const accountA = new BankAccount('ACC-001', 1000); const accountB = new BankAccount('ACC-002', 500); console.log('=== Transaction Rollback Demo ===\n'); console.log(`Before: ${accountA.getStatus()}`); console.log(`Before: ${accountB.getStatus()}`); // === Successful Transfer === console.log('\n--- Successful Transfer ---'); const tx1 = txManager.beginTransaction(); try { txManager.enlist(tx1, accountA); txManager.enlist(tx1, accountB); accountA.debit(200, 'Transfer to ACC-002'); accountB.credit(200, 'Transfer from ACC-001'); txManager.commit(tx1); console.log(`After: ${accountA.getStatus()}`); console.log(`After: ${accountB.getStatus()}`); } catch (error) { txManager.rollback(tx1, [accountA, accountB]); } // === Failed Transfer (Rollback) === console.log('\n--- Failed Transfer (Rollback) ---'); const tx2 = txManager.beginTransaction(); try { txManager.enlist(tx2, accountA); txManager.enlist(tx2, accountB); accountA.debit(100, 'Transfer to ACC-002'); // Simulate failure - try to debit more than available accountB.debit(1000, 'Some other operation'); // Will throw! txManager.commit(tx2); } catch (error) { console.log(`Error: ${(error as Error).message}`); txManager.rollback(tx2, [accountA, accountB]); console.log(`Rolled back: ${accountA.getStatus()}`); console.log(`Rolled back: ${accountB.getStatus()}`); }}Multi-step forms need to preserve state across steps and allow users to navigate back without losing data. The Memento Pattern provides clean navigation with per-step snapshots.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
// ============================================================// FORM WIZARD WITH MEMENTO PATTERN// Multi-step forms with back navigation and validation// ============================================================ // === FORM DATA TYPES ===interface PersonalInfo { firstName: string; lastName: string; email: string; phone: string;} interface AddressInfo { street: string; city: string; state: string; zipCode: string; country: string;} interface PaymentInfo { cardNumber: string; expiryMonth: number; expiryYear: number; cvv: string; billingAddress: AddressInfo | 'same';} interface OrderSummary { items: Array<{ name: string; price: number; quantity: number }>; subtotal: number; tax: number; shipping: number; total: number;} // === MEMENTO ===interface IFormStepSnapshot { readonly stepNumber: number; readonly stepName: string; readonly timestamp: Date; readonly isValid: boolean;} class FormStepSnapshot implements IFormStepSnapshot { readonly timestamp = new Date(); constructor( readonly stepNumber: number, readonly stepName: string, readonly isValid: boolean, private readonly personalInfo: PersonalInfo | null, private readonly shippingAddress: AddressInfo | null, private readonly paymentInfo: PaymentInfo | null, private readonly validationErrors: Record<string, string> ) {} // Wide interface getPersonalInfo(): PersonalInfo | null { return this.personalInfo; } getShippingAddress(): AddressInfo | null { return this.shippingAddress; } getPaymentInfo(): PaymentInfo | null { return this.paymentInfo; } getValidationErrors(): Record<string, string> { return { ...this.validationErrors }; }} // === ORIGINATOR: Checkout Form ===class CheckoutForm { private currentStep: number = 1; private personalInfo: PersonalInfo = { firstName: '', lastName: '', email: '', phone: '' }; private shippingAddress: AddressInfo = { street: '', city: '', state: '', zipCode: '', country: '' }; private paymentInfo: PaymentInfo = { cardNumber: '', expiryMonth: 0, expiryYear: 0, cvv: '', billingAddress: 'same' }; private validationErrors: Record<string, string> = {}; private readonly stepNames = [ '', 'Personal Info', 'Shipping Address', 'Payment', 'Review' ]; // === Form Updates === updatePersonalInfo(data: Partial<PersonalInfo>): void { this.personalInfo = { ...this.personalInfo, ...data }; this.validateCurrentStep(); } updateShippingAddress(data: Partial<AddressInfo>): void { this.shippingAddress = { ...this.shippingAddress, ...data }; this.validateCurrentStep(); } updatePaymentInfo(data: Partial<PaymentInfo>): void { this.paymentInfo = { ...this.paymentInfo, ...data }; this.validateCurrentStep(); } // === Validation === private validateCurrentStep(): boolean { this.validationErrors = {}; switch (this.currentStep) { case 1: if (!this.personalInfo.firstName) { this.validationErrors['firstName'] = 'Required'; } if (!this.personalInfo.email?.includes('@')) { this.validationErrors['email'] = 'Invalid email'; } break; case 2: if (!this.shippingAddress.street) { this.validationErrors['street'] = 'Required'; } if (!this.shippingAddress.zipCode) { this.validationErrors['zipCode'] = 'Required'; } break; case 3: if (this.paymentInfo.cardNumber.length < 16) { this.validationErrors['cardNumber'] = 'Invalid card'; } break; } return Object.keys(this.validationErrors).length === 0; } isCurrentStepValid(): boolean { return Object.keys(this.validationErrors).length === 0; } // === Navigation === nextStep(): boolean { if (!this.isCurrentStepValid()) return false; if (this.currentStep < 4) { this.currentStep++; return true; } return false; } previousStep(): boolean { if (this.currentStep > 1) { this.currentStep--; return true; } return false; } getCurrentStep(): number { return this.currentStep; } // === Memento Operations === createSnapshot(): FormStepSnapshot { return new FormStepSnapshot( this.currentStep, this.stepNames[this.currentStep], this.isCurrentStepValid(), { ...this.personalInfo }, { ...this.shippingAddress }, { ...this.paymentInfo, billingAddress: this.paymentInfo.billingAddress === 'same' ? 'same' : { ...this.paymentInfo.billingAddress } }, { ...this.validationErrors } ); } restore(snapshot: FormStepSnapshot): void { this.currentStep = snapshot.stepNumber; const personal = snapshot.getPersonalInfo(); if (personal) this.personalInfo = { ...personal }; const shipping = snapshot.getShippingAddress(); if (shipping) this.shippingAddress = { ...shipping }; const payment = snapshot.getPaymentInfo(); if (payment) this.paymentInfo = { ...payment }; this.validationErrors = snapshot.getValidationErrors(); } getDisplayState(): string { const stepName = this.stepNames[this.currentStep]; const valid = this.isCurrentStepValid() ? '✓' : '✗'; return `Step ${this.currentStep}/4: ${stepName} [${valid}]`; }} // === CARETAKER: Form Navigator ===class FormNavigator { private stepHistory: IFormStepSnapshot[] = []; /** * Save state before moving to next step. */ beforeNextStep(form: CheckoutForm): void { if (form.isCurrentStepValid()) { this.stepHistory.push(form.createSnapshot()); } } /** * Go back to previous step, restoring its state. */ goBack(form: CheckoutForm): boolean { if (this.stepHistory.length === 0) return false; // Current step's state is lost (user explicitly went back) const previousSnapshot = this.stepHistory.pop()!; form.restore(previousSnapshot as FormStepSnapshot); return true; } /** * Jump to specific step (if already visited). */ jumpToStep(form: CheckoutForm, stepNumber: number): boolean { const snapshotIndex = this.stepHistory.findIndex( s => s.stepNumber === stepNumber ); if (snapshotIndex < 0) return false; // Trim history to that point const snapshot = this.stepHistory[snapshotIndex]; this.stepHistory = this.stepHistory.slice(0, snapshotIndex); form.restore(snapshot as FormStepSnapshot); return true; } /** * Get breadcrumb of visited steps. */ getBreadcrumbs(): Array<{ step: number; name: string; valid: boolean }> { return this.stepHistory.map(s => ({ step: s.stepNumber, name: s.stepName, valid: s.isValid })); }} // === USAGE ===function demonstrateFormWizard(): void { const form = new CheckoutForm(); const navigator = new FormNavigator(); console.log('=== Form Wizard Demo ===\n'); // Step 1: Personal Info console.log(form.getDisplayState()); form.updatePersonalInfo({ firstName: 'John', lastName: 'Doe', email: 'john@example.com' }); console.log(form.getDisplayState()); // Move to step 2 navigator.beforeNextStep(form); form.nextStep(); console.log(form.getDisplayState()); // Step 2: Shipping Address form.updateShippingAddress({ street: '123 Main St', city: 'Boston', zipCode: '02101' }); // Move to step 3 navigator.beforeNextStep(form); form.nextStep(); console.log(form.getDisplayState()); // Oops, need to go back console.log('\n--- Going Back ---'); navigator.goBack(form); console.log(`Restored: ${form.getDisplayState()}`); // Breadcrumbs console.log('\nBreadcrumbs:', navigator.getBreadcrumbs());}The Memento Pattern is powerful but not always the right choice. Understanding when to use it—and when to consider alternatives—is essential for good design.
Alternative approaches:
Command Pattern for Undo: If changes are operations (not states), store inverse commands instead of state snapshots. More memory-efficient for small, frequent changes.
Event Sourcing: Store the sequence of events that led to current state. Reconstruct any point by replaying events. Great for audit logs but slower restoration.
Copy-on-Write: For immutable data structures, state IS the memento. No copying needed—just keep references to old versions.
Delta/Diff Storage: Store only what changed between snapshots. More complex but much more memory-efficient for large state.
Memento: State is the focus, changes are varied Command: Operations are the focus, changes are uniform Event Sourcing: History is as important as current state Immutable Data: State is already immutable or copyable
We've explored the Memento Pattern across diverse real-world scenarios—from text editors to games to financial transactions. Let's consolidate the key practical insights:
| Domain | Originator | Memento Content | Caretaker Strategy |
|---|---|---|---|
| Text Editor | Document | Text, cursor, formatting | Undo/redo stacks with grouping |
| Games | Game State | Player, world, inventory | Save slots, auto-save, quick save |
| Transactions | Account(s) | Balances, pending operations | Per-transaction snapshots |
| Forms | Form Data | Field values, validation state | Per-step history |
| Graphics | Canvas/Layers | Pixel data, layer hierarchy | Action-based with memory limits |
The Memento Pattern is a foundational tool for state preservation, enabling some of the most essential user experiences in software. By understanding its implementation across different domains, you can apply it effectively to your own applications while avoiding common pitfalls.
Congratulations! You've mastered the Memento Pattern—from the fundamental problem of state preservation through the elegant solution of opaque snapshot objects, the three participants' roles, and comprehensive real-world implementations. You're now equipped to implement robust undo/redo, save systems, transaction rollback, and more.