Ad-hoc "remember the old values" code smeared through an object:
def risky_update(self, changes):
old_workers = self.workers # hand-rolled, per-field,
old_timeout = self.timeout # and always one field short
try:
...
except Exception:
self.workers = old_workers # partial restore, subtle drift
self.timeout = old_timeoutEvery new field must remember to join the backup ritual. A memento replaces the ritual with one move: keep the whole old state.
- Freeze the state. Move the object's data into a
@dataclass(frozen=True). The identity (the editor, the service) stays mutable; its state doesn't. - Give the originator a
History.History[YourState]()— the type parameter is the whole caretaker contract: it stores and returns, nothing else. - Snapshot before commit. Each mutation builds a candidate with
dataclasses.replace, validates it, thenhistory.save(self.state)and adopt the candidate. Order matters: save only what was valid and live. - Choose your restore vocabulary. LIFO
undo()for editing flows; namedcheckpoint(name)/rollback_to(name)for operational flows ("before-upgrade"). Decide whether a rollback is itself undoable. - Bound the history if edits are unbounded — a deque with
maxlen, or checkpoint-only retention. Unbounded undo is a slow memory leak.
from patterns.behavioral.memento import History
class ConfigEditor:
def __init__(self) -> None:
self.config = ServiceConfig()
self._history: History[ServiceConfig] = History()
def apply(self, changes: Mapping[str, Any]) -> ServiceConfig:
candidate = replace(self.config, **changes)
validate(candidate) # reject BEFORE touching history
self._history.save(self.config)
self.config = candidate
return self.configdataclasses.replaceis the snapshot-friendly mutation: it forces "new value, old value intact" as the default motion.- Frozen dataclasses with
frozenset/tuplefields keep immutability deep — a frozen shell over a mutablelistis a snapshot that lies. - For state you genuinely cannot freeze,
copy.deepcopyat the snapshot point is the honest fallback; pay the cost visibly, at one call site.
- The shallow snapshot. Freezing the top object while a field is a mutable list shares that list across "snapshots" — undo silently undoes nothing. Freeze all the way down.
- Saving the invalid candidate. Snapshot the last good state, then validate the candidate — the demo's rejected batch leaves both the live config and the history untouched.
- A caretaker that peeks. The moment history code reads snapshot fields,
restore semantics couple to state internals.
Historyis generic precisely so it can't. - Unpickling as restore. Restoring from bytes means
pickle.loads, and that executes code during deserialization (CWE-502): only unpickle snapshots your own process produced; use JSON for anything that crosses a trust boundary.
examples/config_checkpoints/ applies
every step: atomic validate-or-reject batches, LIFO undo, and a named
"before-upgrade" checkpoint. Run it with:
uv run python -m patterns.behavioral.memento.examples.config_checkpoints.main