Memento Pattern in C#: Snapshots for Undo and Rollback
Memento pattern in C# / .NET 10: capture state into snapshots for undo, transactional rollback, and time-travel debugging using record and with.
Table of contents
- What problem does the Memento pattern solve in C#?
- How does the textbook Memento look?
- How does record with give you Memento for free?
- When should you persist Memento snapshots beyond the process?
- When does the Memento pattern misfire?
- How does Memento compare to Command and Prototype?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
The cart preview feature: a user clicks "apply discount", the discount engine modifies the cart in place to show the post-discount total, and the UI shows a "Confirm" or "Cancel" button. Cancel must undo every mutation the discount engine made. The naive way — each discount module recording its own diff — does not survive nested discounts and stacked coupons. The clean way is to take a snapshot before the operation and restore it on cancel.
The Memento pattern is the answer. Capture the originator's
state into an opaque snapshot; let the originator restore from the
snapshot later. In modern C# the snapshot is almost always a
record and the restore is one line. The pattern is the
foundation of the broader undo/redo, transactional rollback, and
time-travel-debugging stories.
What problem does the Memento pattern solve in C#?
The pattern earns its place when state must survive an operation in a way that lets the operation be reversed or discarded. Three concrete shapes:
- Optimistic UI. Show the result of an action; offer "cancel"; restore on cancel.
- Transactional rollback. Multi-step operation fails midway; restore to the state at the start.
- Undo / redo. Each user action takes a snapshot; the undo stack stores them; redo replays them or reapplies from a later memento.
What is not a Memento problem: "I want a clone to use going forward" — that is Prototype. "I want to record the action itself" — that is Command. Memento is specifically about saving state.
How does the textbook Memento look?
Three parties: the originator (the object whose state matters), the memento (the snapshot type), and the caretaker (the object holding mementos but not reading them).
public sealed class Cart
{
private readonly List<LineItem> _items = new();
private decimal _discountFactor = 1.0m;
public IReadOnlyList<LineItem> Items => _items;
public decimal DiscountFactor => _discountFactor;
public CartMemento Save()
=> new(_items.ToArray(), _discountFactor);
public void Restore(CartMemento m)
{
_items.Clear();
_items.AddRange(m.Items);
_discountFactor = m.DiscountFactor;
}
public void ApplyDiscount(decimal factor) => _discountFactor *= factor;
public void Add(LineItem item) => _items.Add(item);
}
public sealed record CartMemento(
IReadOnlyList<LineItem> Items,
decimal DiscountFactor);
The caretaker holds the memento; the discount engine restores on cancel:
var snapshot = cart.Save();
try
{
cart.ApplyDiscount(0.9m);
cart.Add(new LineItem("FREE_GIFT", 0m, 1));
if (!await ConfirmAsync()) cart.Restore(snapshot);
}
catch
{
cart.Restore(snapshot);
throw;
}
The structural picture:
sequenceDiagram
participant U as User
participant C as Caretaker (preview UI)
participant Cart as Originator (Cart)
participant M as Memento
C->>Cart: Save()
Cart-->>M: new CartMemento(...)
Cart-->>C: memento token
C->>Cart: ApplyDiscount(0.9)
C->>Cart: Add(FreeGift)
U->>C: Cancel
C->>Cart: Restore(memento)
Cart-->>Cart: state reverted
The caretaker never reads the memento; it just keeps the token and hands it back when needed.
How does record with give you Memento for free?
For immutable cart shapes, the originator is itself a record,
and the memento is just the original instance:
public sealed record CartState(
IReadOnlyList<LineItem> Items,
decimal DiscountFactor);
public sealed class Cart
{
public CartState State { get; private set; }
= new CartState(Array.Empty<LineItem>(), 1.0m);
public CartState Save() => State; // memento *is* the state
public void Restore(CartState s) => State = s;
public void ApplyDiscount(decimal factor)
=> State = State with { DiscountFactor = State.DiscountFactor * factor };
public void Add(LineItem item)
=> State = State with { Items = State.Items.Append(item).ToArray() };
}
Snapshots become free in this design — every mutation already produces a new state record. Save just returns the current one; Restore overwrites the current one. The whole pattern reduces to two one-line methods.
When should you persist Memento snapshots beyond the process?
In-process snapshots are records; cross-process snapshots are serialised data. JSON is the safe default:
public sealed class PersistentCart
{
private readonly IDistributedCache _cache;
private CartState _state = new(Array.Empty<LineItem>(), 1.0m);
public PersistentCart(IDistributedCache cache) => _cache = cache;
public async Task SaveAsync(string key, CancellationToken ct)
{
var json = JsonSerializer.Serialize(_state);
await _cache.SetStringAsync(key, json, ct);
}
public async Task RestoreAsync(string key, CancellationToken ct)
{
var json = await _cache.GetStringAsync(key, ct);
if (json is null) return;
_state = JsonSerializer.Deserialize<CartState>(json)!;
}
}
Three things to think about when serialising:
- Schema evolution. Today's snapshot must deserialise after a
type change. Prefer
[JsonInclude]on properties, document[JsonPropertyName]attributes, and version the snapshot envelope. - Size. A snapshot of a 10K-line cart is large; consider storing a diff against a known-good baseline, or use a binary-stable format like Protobuf for hot paths.
- PII. Snapshots persist user data. Apply the same retention rules as your primary store; do not let mementos outlive the privacy policy.
When does the Memento pattern misfire?
Three traps:
- Expensive snapshots on every action. Saving a deep clone of a 10K-row state on every keystroke kills CPU and memory. Save transactionally (around the operation that needs rollback), not on every change.
- Stale references inside snapshots. A shallow snapshot stores
a reference to a
List<>the originator continues to mutate. Restoring from such a snapshot is undefined. Prefer immutable state (records,IReadOnlyList<>arrays) or deep-clone before storing. - Misuse for inter-service communication. Mementos are a local concept. Do not pass them across service boundaries as a data-transfer format; design proper DTOs for that.
How does Memento compare to Command and Prototype?
| Pattern | What it captures | Used for | Modern .NET |
|---|---|---|---|
| Memento | A state snapshot of an object | Undo, rollback | record + with |
| Command | An action with inputs | Queue, replay, audit | record + handler |
| Prototype | A clone you use going forward | Variant creation | record + with |
The cleanest split: Memento is what the state was; Command is what we did; Prototype is a copy we will use. Undo/redo combines Memento (state) with Command (timeline) — neither is sufficient alone.
What does a real .NET 10 example look like?
A complete cart undo/redo using records and a stack of mementos plus the Command pattern for the timeline:
public sealed record CartState(IReadOnlyList<LineItem> Items, decimal DiscountFactor);
public sealed class Cart
{
public CartState State { get; private set; } =
new(Array.Empty<LineItem>(), 1.0m);
public CartState Save() => State;
public void Restore(CartState s) => State = s;
public void Add(LineItem item)
=> State = State with { Items = State.Items.Append(item).ToArray() };
public void ApplyDiscount(decimal factor)
=> State = State with { DiscountFactor = State.DiscountFactor * factor };
public decimal Total() => State.Items.Sum(i => i.UnitPrice * i.Quantity) * State.DiscountFactor;
}
public sealed class CartHistory
{
private readonly Stack<CartState> _undo = new();
private readonly Stack<CartState> _redo = new();
private readonly Cart _cart;
public CartHistory(Cart cart) => _cart = cart;
public void Execute(Action<Cart> action)
{
_undo.Push(_cart.Save());
action(_cart);
_redo.Clear();
}
public void Undo()
{
if (!_undo.TryPop(out var prev)) return;
_redo.Push(_cart.Save());
_cart.Restore(prev);
}
public void Redo()
{
if (!_redo.TryPop(out var next)) return;
_undo.Push(_cart.Save());
_cart.Restore(next);
}
}
// Caller
var cart = new Cart();
var history = new CartHistory(cart);
history.Execute(c => c.Add(new LineItem("BOOK", 9.99m, 2)));
history.Execute(c => c.Add(new LineItem("MUG", 12m, 1)));
history.Execute(c => c.ApplyDiscount(0.9m)); // 10% off
Console.WriteLine(cart.Total()); // 28.78
history.Undo(); // remove the discount
Console.WriteLine(cart.Total()); // 31.98
history.Undo(); // remove the mug
history.Redo(); // bring it back
What is missing from the code: any explicit "memento" class. The
CartState record does both jobs — value type and snapshot — and
the history does the work of the caretaker. The pattern is
preserved; the boilerplate isn't.
Where should you read next in this series?
- Previous: Mediator — when peers publish to a hub instead of calling each other.
- Next: Observer — when a publisher notifies subscribers directly, without a hub.
- Cross-reference: Command — paired with Memento for undo: Command is the timeline, Memento is the state at each point.
- Cross-reference: Prototype — same cloning mechanism, different intent.
- Decision tree: How to choose the right design pattern.
A practical note: Memento is one of the cleanest examples of
modern C# making a GoF pattern almost invisible. A record
plus the with expression collapses originator + memento +
caretaker into "save the state, restore the state". The pattern's
intent — save state opaquely so it can be restored — survives;
the textbook three-class hierarchy does not.
Frequently asked questions
What is the difference between Memento and Prototype?
Restore(memento) method on the originator; Prototype usually is not.Should I use records or JSON for Memento snapshots?
record snapshot when state stays in-process and is small to medium. The with expression makes shallow copies free; deep copies need recursive with. Use JSON serialisation when the snapshot is persisted (Redis, DB, file), travels between services, or contains nested mutable graphs. JSON round-trip is slower but bulletproof.How does Memento combine with the Command pattern for undo?
ReversibleCommand captures a Memento before executing, then Undo() restores from the Memento. Together they implement undo/redo cleanly: Commands give the timeline, Mementos give the state at each tick.Does Memento break encapsulation by exposing state?
internal constructors, private properties on the snapshot record, or a small wrapper class that exposes nothing but type identity.