Wzorzec Memento przydaje się wtedy, gdy aplikacja musi bezpiecznie cofnąć stan obiektu, nie rozbijając jego enkapsulacji. Najczęściej chodzi o undo, ale równie dobrze o rollback po błędzie, przywracanie formularza do wcześniejszej wersji albo cofanie zmian w edytorze. Poniżej pokazuję, jak ten wzorzec działa, kiedy ma sens i jak wdrożyć go w aplikacji webowej bez nadmiernego komplikowania kodu.
Najważniejsze rzeczy do zapamiętania o wzorcu Memento
- To wzorzec behawioralny służący do zapisywania i odtwarzania stanu obiektu bez ujawniania jego szczegółów implementacyjnych.
- Opiera się na trzech rolach: originator tworzy migawkę, memento ją przechowuje, a caretaker zarządza historią.
- Najlepiej sprawdza się przy funkcjach typu undo/redo, rollback i pracy na pojedynczym złożonym obiekcie.
- Nie jest darmowy, bo każda migawka kosztuje pamięć i czas kopiowania.
- Najbezpieczniej działa wtedy, gdy zapisujesz tylko taki stan, który naprawdę da się później odtworzyć.
Czym jest ten wzorzec i kiedy naprawdę się przydaje
Najkrócej: to technika, w której obiekt sam przygotowuje swoją migawkę, a reszta systemu tylko ją przechowuje i w odpowiednim momencie odtwarza. Ja lubię myśleć o niej jak o kontrolowanym punkcie przywracania, a nie o zwykłej kopii całego obiektu. Różnica jest ważna, bo w Memento nie chodzi o przypadkowe serializowanie wszystkiego, tylko o zapis tego, co ma znaczenie z perspektywy odzyskania stanu.
W praktyce wzorzec sprawdza się tam, gdzie użytkownik oczekuje możliwości powrotu do wcześniejszego kroku. Edytor tekstu, kreator wieloetapowego formularza, aplikacja do rysowania, konfigurator produktu, a nawet mechanizm rollback po nieudanej operacji w backendzie, to naturalne miejsca dla tego rozwiązania. Jeśli zmiana ma być odwracalna, a obiekt ma ukryte pola, wzorzec Memento daje czystszą odpowiedź niż ręczne wystawianie getterów do wszystkich danych.
W tym podejściu najważniejsze jest zachowanie granicy: obiekt wie, co trzeba zapisać, ale historyjka zmian nie musi wiedzieć, jak ten stan jest zbudowany. To właśnie trzyma architekturę w ryzach. Żeby zobaczyć, jak to działa od środka, trzeba rozdzielić trzy role i ich odpowiedzialności.
Jak działa zapis i odtwarzanie stanu
W klasycznej wersji masz trzy elementy. Originator to obiekt, którego stan chcesz chronić, na przykład edytor tekstu. Memento to zamknięta migawka tego stanu. Caretaker to magazyn historii, który wie, kiedy migawkę zapisać i kiedy ją przywrócić, ale nie zagląda do środka.
Przepływ jest prosty: przed operacją, którą da się cofnąć, originator tworzy snapshot. Caretaker odkłada go do stosu historii. Jeśli użytkownik wybierze cofnięcie, caretaker podaje migawkę z powrotem originatorowi, a ten odtwarza własne pola. W dobrze zaprojektowanej implementacji memento jest niemodyfikowalne, bo inaczej historia szybko zamienia się w przypadkową bazę danych do ręcznych poprawek.
Ja zwykle tłumaczę to tak: originator jest jedynym źródłem prawdy o swoim stanie, memento jest bezpieczną kopertą z kopią, a caretaker jest tylko listonoszem i archiwistą. Taki podział eliminuje pokusę, żeby z zewnątrz grzebać w prywatnych polach. Kiedy już wiesz, kto za co odpowiada, łatwiej przełożyć to na prosty kod w aplikacji webowej.
Praktyczny przykład w aplikacji webowej
W web development najczęściej spotkasz ten wzorzec w edytorach, formularzach i narzędziach typu canvas. Poniżej pokazuję uproszczony przykład w TypeScript. To nie jest najkrótszy możliwy kod, tylko taki, który dobrze pokazuje ideę i da się go odnieść do prawdziwej aplikacji.
type EditorState = {
text: string;
cursor: number;
selectionStart: number;
selectionEnd: number;
};
class EditorSnapshot {
constructor(private readonly state: EditorState) {}
restore(editor: Editor): void {
editor.applyState(structuredClone(this.state));
}
}
class Editor {
private state: EditorState = {
text: "",
cursor: 0,
selectionStart: 0,
selectionEnd: 0,
};
setText(text: string): void {
this.state = {
...this.state,
text,
cursor: text.length,
};
}
setSelection(selectionStart: number, selectionEnd: number): void {
this.state = {
...this.state,
selectionStart,
selectionEnd,
};
}
createSnapshot(): EditorSnapshot {
return new EditorSnapshot(structuredClone(this.state));
}
applyState(nextState: EditorState): void {
this.state = nextState;
}
getState(): EditorState {
return structuredClone(this.state);
}
}
class History {
private undoStack: EditorSnapshot[] = [];
private redoStack: EditorSnapshot[] = [];
save(snapshot: EditorSnapshot): void {
this.undoStack.push(snapshot);
this.redoStack = [];
}
undo(editor: Editor): void {
const previous = this.undoStack.pop();
if (!previous) return;
this.redoStack.push(editor.createSnapshot());
previous.restore(editor);
}
redo(editor: Editor): void {
const next = this.redoStack.pop();
if (!next) return;
this.undoStack.push(editor.createSnapshot());
next.restore(editor);
}
}Najważniejsze są tu dwie rzeczy. Po pierwsze, migawka jest tworzona z kopii stanu, a nie z referencji do tego samego obiektu. Po drugie, po nowej zmianie czyścimy stos redo, bo inaczej użytkownik mógłby wrócić do gałęzi historii, która już nie istnieje. To właśnie ten drobiazg często odróżnia działające undo od prowizorki.
W praktyce możesz ten sam schemat zastosować do formularza wieloetapowego, rysunku na canvasie albo konfiguratora filtrów. Jeśli stan jest zagnieżdżony, sięgnij po kopię głęboką albo projektuj go jako zestaw wartości, które da się bezbłędnie odtworzyć. Sama implementacja nie wystarczy jednak w każdym projekcie, bo koszt pamięci i złożoność szybko rosną.
Kiedy wybrać to rozwiązanie, a kiedy odpuścić
Nie każdy przypadek cofania zmian wymaga Memento. Czasem wystarczy prosty store stanu, czasem lepiej sprawdza się Command, a czasem zwykła kopia danych będzie wystarczająca. Kluczowe jest to, czy naprawdę potrzebujesz odtworzyć konkretny stan obiektu, czy raczej zapisać historię akcji.
| Podejście | Co zapisujesz | Największy plus | Typowe ograniczenie | Kiedy ma sens |
|---|---|---|---|---|
| Memento | Stan obiektu w danym momencie | Chroni enkapsulację i pozwala wrócić do dokładnej migawki | Zużywa pamięć przy częstych snapshotach | Edytory, formularze, pojedyncze obiekty z historią |
| Command | Akcję i dane potrzebne do jej cofnięcia | Dobrze wspiera kolejkę operacji i redo | Nie zawsze odtwarza stan tak precyzyjnie jak snapshot | Systemy z komendami użytkownika i złożonym przepływem akcji |
| Serializacja całego obiektu | Całą strukturę danych | Szybko się ją wdraża | Kruche przy zmianach schematu i łatwo narusza ukrywanie detali | Prosty backup, eksport, prototyp |
| Zewnętrzny store z time travel | Globalny stan aplikacji | Centralizacja i łatwiejsze debugowanie | Większa złożoność synchronizacji | Aplikacje z jednym źródłem prawdy i rozbudowanym UI |
Jeśli jedna migawka ma 100 KB, a zapisujesz ich 50, to zużywasz około 5 MB. Przy 1 MB na snapshot robi się już 50 MB i wtedy problem przestaje być akademicki. W aplikacjach webowych to potrafi wyjść bardzo szybko, szczególnie gdy zapisujesz stan po każdym znaku wpisanym przez użytkownika.
Właśnie dlatego nie traktuję tego wzorca jako domyślnego rozwiązania na wszystko. Gdy bardziej zależy ci na historii działań niż na stanie końcowym, Command zwykle daje lepszą strukturę. Po takim wyborze zostaje już tylko uniknięcie błędów wdrożeniowych, a tych widuję zaskakująco często.
Najczęstsze błędy przy wdrożeniu
Największy błąd to zapisywanie referencji zamiast kopii. Na papierze wygląda to poprawnie, ale po kilku zmianach wszystkie migawki pokazują ten sam, już zmodyfikowany obiekt. Drugi klasyk to upychanie w memento zbyt dużej ilości danych, także takich, które można obliczyć ponownie. To nie jest historia, tylko bałagan w przebraniu.
- Udostępnianie wnętrza migawki - jeśli caretaker może czytać pola snapshotu, bardzo szybko tracisz sens całego wzorca.
- Brak głębokiej kopii - przy obiektach zagnieżdżonych płytka kopia zwykle nie wystarcza.
- Zapominanie o redo - po nowej edycji trzeba wyczyścić gałąź przyszłości, inaczej historia staje się sprzeczna.
- Łączenie kilku niezależnych obiektów w jedną migawkę - działa tylko wtedy, gdy ich stan zmienia się razem i w tej samej logice.
- Brak limitu historii - bez ograniczenia liczby snapshotów pamięć rośnie liniowo, a czas przeglądania historii też zaczyna boleć.
W praktyce warto też uważać na stan pochodny, czyli taki, który można wyliczyć z innych pól. Jeśli zapiszesz go osobno, a potem zmienisz logikę obliczeń, odtwarzanie zacznie zwracać niespójne wyniki. Po nazwaniu tych pułapek zostaje już ostatni krok, czyli ustawienie wzorca tak, żeby pomagał projektowi, a nie dokładał mu długu technicznego.
Jak wdrożyć go tak, żeby historia zmian nie zamieniła się w chaos
Gdy projektuję taki mechanizm, zaczynam od trzech pytań: co dokładnie odwracam, kto tworzy migawkę i jak długo historia ma żyć. To wystarcza, żeby odsiać większość niepotrzebnej złożoności. W aplikacjach webowych często najlepiej działa historyczny stos ograniczony do 20, 50 albo 100 kroków, zależnie od wielkości stanu i częstotliwości zmian. Nie ma tu jednej magicznej liczby, ale brak limitu prawie zawsze kończy się gorzej niż rozsądne ograniczenie.
- Trzymaj historię blisko warstwy UI albo aplikacyjnej, a nie w samym modelu domenowym, jeśli zaczyna to mieszać odpowiedzialności.
- Projektuj migawki jako obiekty niemutowalne, nawet jeśli oznacza to dodatkową kopię danych przy zapisie.
- Jeśli stan jest duży, rozważ checkpointy co kilka kroków zamiast pełnego snapshotu po każdej drobnej zmianie.
- Jeżeli liczysz na audyt, nie myl go z undo. Audyt chce wiedzieć, co się zmieniło, a undo chce po prostu wrócić do poprzedniego stanu.
- Gdy operacje są ważniejsze niż stan, połącz Memento z Command, zamiast zmuszać jeden wzorzec do wszystkiego.
To podejście dobrze skaluje się w praktyce, bo daje jasny podział ról i nie zmusza całej aplikacji do znajomości szczegółów obiektu. Jeśli dobrze ustawisz granice odpowiedzialności, wzorzec Memento pozostaje prosty, a użytkownik dostaje dokładnie to, czego oczekuje: pewny powrót do poprzedniego stanu bez chaosu w kodzie.