Behavioral Intermediate 7 min read

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
  1. What problem does the Memento pattern solve in C#?
  2. How does the textbook Memento look?
  3. How does record with give you Memento for free?
  4. When should you persist Memento snapshots beyond the process?
  5. When does the Memento pattern misfire?
  6. How does Memento compare to Command and Prototype?
  7. What does a real .NET 10 example look like?
  8. 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:

  1. Optimistic UI. Show the result of an action; offer "cancel"; restore on cancel.
  2. Transactional rollback. Multi-step operation fails midway; restore to the state at the start.
  3. 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:

When does the Memento pattern misfire?

Three traps:

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.

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?
Prototype clones an object to create a new instance you will use going forward. Memento clones an object to create a snapshot you may discard or use to restore the original later. Same mechanism (deep or shallow copy); different intent. Memento is paired with a Restore(memento) method on the originator; Prototype usually is not.
Should I use records or JSON for Memento snapshots?
Use a 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?
Command handles what to do; Memento handles what state to remember. A 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?
Properly designed, no. The Memento is a Memento, not the originator's internals. The originator is the only class that can read the Memento's contents; outside callers only carry the opaque token around. In modern C# you achieve that with internal constructors, private properties on the snapshot record, or a small wrapper class that exposes nothing but type identity.