Mert Özen Further With Every Line
Software Design Design Patterns — #18

Memento Pattern: Taking Snapshots, Restoring and Keeping Encapsulation

Mert Özen Nov 7, 2026 12 min 6 views
Memento Pattern: Taking Snapshots, Restoring and Keeping Encapsulation

Memento lets you take an object's internal state out without breaking encapsulation and restore it later; in C# the cleanest way is a private nested record plus a narrow interface shown to the caretaker. With real output we look at the shallow-copy trap of the with expression, a capacity-limited undo in the checkout wizard, Utf8JsonReader's JsonReaderState, undo with Command, versioning snapshots written to JSON, and the memory difference immutable state makes.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

Bookstore's cart screen is getting an "undo" button. The developer writing the first version knows that the with expression on records produces a copy, and pushes a copy of the cart onto a stack before every change:

public sealed record CartDraft(string ShippingMethod, List<string> Isbns, string? CouponCode);

Stack<CartDraft> history = new();
CartDraft draft = new("Standard", ["978-0441013593"], CouponCode: null);

history.Push(draft with { });
draft.Isbns.Add("978-0201633610");
draft = draft with { CouponCode = "FREESHIP" };

draft = history.Pop();
Console.WriteLine($"Coupon: {draft.CouponCode ?? "-"}, items: {draft.Isbns.Count}");

After the snapshot was taken, a second book was added to the cart and a coupon was applied. When "undo" returns to the snapshot, does the coupon disappear? How many books are left in the cart?

A Copy Is Not Always a Snapshot

The real output:

Coupon: -, items: 2

The coupon was undone, the added book was not. The with expression produces a new record but copies the fields one by one: because Isbns is a List<string> reference, the snapshot and the live cart share the same list. The CouponCode change produced a new record, so it didn't touch the snapshot; Isbns.Add changed the list the two have in common. The details of shallow versus deep copy are in this series' post "Prototype Pattern: record and with, Shallow vs Deep Copy and Why ICloneable Is Discouraged". This post's question is different: what does it take to safely keep a past moment of an object, and do we have to expose that object's insides to the world to do it?

The code above has one more problem: all of CartDraft's state is public. Anyone can add to Isbns and change the snapshots on the stack. The code doing the undo has to know the cart's internal structure too.

GoF's Definition: Externalizing State Without Breaking Encapsulation

GoF (Gang of Four: the 1994 Design Patterns book by Gamma, Helm, Johnson and Vlissides) lists Memento among the behavioral patterns. Its intent is to capture an object's internal state and keep it outside, without breaking encapsulation, so that the object can be returned to that state later. The book has three roles. The Originator is the object whose state is saved; it produces the memento and only it restores from one. The Memento is the object carrying the originator's state. The Caretaker keeps mementos but never looks inside them: an undo mechanism, a draft manager or a game's save system.

The crux of the pattern is what GoF calls "two interfaces". The caretaker sees the memento's narrow interface: it holds an object but doesn't know what's in it, and can only store it and hand it back. The originator sees the wide interface and can read the whole state. In C++ this was done by declaring the originator a friend of the memento class. C# has no friend, but it has a cleaner tool: a private nested type.

Memento in C#: A Private Nested Record and a Narrow Interface

Let Bookstore's multi-step checkout wizard (cart, address, shipping, coupon) be the originator:

public sealed record OrderLine(string Isbn, int Quantity);

// The narrow interface the caretaker sees: no content, only identifying info
public interface ICheckoutMemento
{
    int Step { get; }
}

// Originator
public sealed class CheckoutWizard
{
    private readonly List<OrderLine> _lines = [];
    private string? _address;
    private string _shipping = "Standard";
    private string? _coupon;
    private int _step = 1;

    public void AddBook(string isbn, int quantity)
    {
        _lines.Add(new OrderLine(isbn, quantity));
        _step = 2;
    }

    public void SetAddress(string address)
    {
        _address = address;
        _step = 3;
    }

    public void ChooseShipping(string method) => _shipping = method;
    public void ApplyCoupon(string code) => _coupon = code;

    public ICheckoutMemento Save() => new Snapshot([.. _lines], _address, _shipping, _coupon, _step);

    public void Restore(ICheckoutMemento memento)
    {
        if (memento is not Snapshot s)
            throw new ArgumentException("Memento does not belong to CheckoutWizard.", nameof(memento));
        _lines.Clear();
        _lines.AddRange(s.Lines);
        (_address, _shipping, _coupon, _step) = (s.Address, s.Shipping, s.Coupon, s.Step);
    }

    public override string ToString() =>
        $"step {_step}: {_lines.Sum(l => l.Quantity)} books, {_shipping}, address={_address ?? "-"}, coupon={_coupon ?? "-"}";

    // Wide interface: only CheckoutWizard can read the contents
    private sealed record Snapshot(
        ImmutableArray<OrderLine> Lines, string? Address, string Shipping, string? Coupon, int Step) : ICheckoutMemento;
}

// Caretaker: stores mementos, never looks inside
public sealed class CheckoutHistory(CheckoutWizard wizard, int capacity)
{
    private readonly LinkedList<ICheckoutMemento> _undo = new();

    public bool CanUndo => _undo.Count > 0;

    public void Backup()
    {
        _undo.AddLast(wizard.Save());
        if (_undo.Count > capacity)
            _undo.RemoveFirst();
    }

    public void Undo()
    {
        if (_undo.Last is not { } last)
            return;
        _undo.RemoveLast();
        wizard.Restore(last.Value);
    }
}

There are three decisions here. The first is that Snapshot is a private nested record: no code outside CheckoutWizard can even write its name. The second is that the lines are copied as an ImmutableArray<OrderLine> and that OrderLine itself is an immutable record; this closes the shared-list problem from the opening at two levels. The third is the caretaker's capacity: we used LinkedList<T> because when the limit is exceeded we need to drop the oldest memento from the front, which Stack<T> doesn't allow. Let's try it:

CheckoutWizard wizard = new();
CheckoutHistory history = new(wizard, capacity: 3);

history.Backup();
wizard.AddBook("978-0441013593", 1);
history.Backup();
wizard.AddBook("978-0201633610", 2);
history.Backup();
wizard.SetAddress("221B Baker Street");
wizard.ApplyCoupon("FREESHIP");
Console.WriteLine(wizard);

history.Undo();
Console.WriteLine(wizard);
history.Undo();
Console.WriteLine(wizard);
Console.WriteLine($"Undo available: {history.CanUndo}");
step 3: 3 books, Standard, address=221B Baker Street, coupon=FREESHIP
step 2: 3 books, Standard, address=-, coupon=-
step 2: 1 books, Standard, address=-, coupon=-
Undo available: True

Now imagine the caretaker trying to look inside. If someone writes a cast in a file called Bad.cs to "just read the coupon from the snapshot":

namespace Bookstore.Checkout;

public sealed class SneakyHistory(CheckoutWizard wizard)
{
    public string? PeekCoupon() => ((CheckoutWizard.Snapshot)wizard.Save()).Coupon;
}
Bad.cs(5,53): error CS0122: 'CheckoutWizard.Snapshot' is inaccessible due to its protection level

The compiler protects the encapsulation. Step, the only member of ICheckoutMemento, is a deliberate concession: we show the caretaker just that much so the undo menu can say "back to step 2". What you put on the narrow interface is a design decision; it can also be an empty marker interface.

Memento in .NET: Utf8JsonReader and JsonReaderState

The cleanest example of this pattern in the BCL (Base Class Library) is System.Text.Json's low-level reader. Utf8JsonReader is a ref struct; it reads a buffer and cannot outlive that buffer. If a large JSON (JavaScript Object Notation) file arrives from the network in pieces, you have to read one piece, keep where the reader stopped, and start a new reader from that state on the next piece. What you keep is the JsonReaderState returned by reader.CurrentState:

byte[] json = Encoding.UTF8.GetBytes("""{"isbn":"978-0441013593","title":"Dune","price":18}""");
byte[] firstChunk = json[..30];
byte[] secondChunk = json[30..];

Utf8JsonReader reader = new(firstChunk, isFinalBlock: false, state: default);
while (reader.Read())
    Console.WriteLine($"chunk 1: {reader.TokenType} {Show(ref reader)}");

// The reader's internal state is taken out as an opaque struct
JsonReaderState state = reader.CurrentState;
byte[] rest = [.. firstChunk[(int)reader.BytesConsumed..], .. secondChunk];
Console.WriteLine($"consumed {reader.BytesConsumed} of {firstChunk.Length}, carried over {firstChunk.Length - reader.BytesConsumed}");

reader = new Utf8JsonReader(rest, isFinalBlock: true, state);
while (reader.Read())
    Console.WriteLine($"chunk 2: {reader.TokenType} {Show(ref reader)}");
chunk 1: StartObject
chunk 1: PropertyName isbn
chunk 1: String 978-0441013593
consumed 24 of 30, carried over 6
chunk 2: PropertyName title
chunk 2: String Dune
chunk 2: PropertyName price
chunk 2: Number 18
chunk 2: EndObject 

The first chunk ended in the middle of the "title" property name; the reader didn't read the incomplete token and stopped after consuming 24 of the 30 bytes. Prepending the remaining 6 bytes to the second chunk and creating a new reader with the old state let reading continue where it left off. Looking at the public surface of JsonReaderState through reflection shows only a constructor and an Options property; information such as the nesting depth, the type of the previous token and whether a value is expected after a property name is internal. That is GoF's narrow interface exactly: you carry the state, only the reader can open it.

Undo with Command: Inverse Operation or Snapshot?

GoF mentions Memento as a close relative of Command: an undoable command can take a memento from the originator before it runs and restore it on undo.

public interface ICheckoutCommand
{
    void Execute();
    void Undo();
}

public sealed class ApplyCoupon(CheckoutWizard wizard, string code) : ICheckoutCommand
{
    private ICheckoutMemento? _before;

    public void Execute()
    {
        _before = wizard.Save();
        wizard.ApplyCoupon(code);
    }

    public void Undo() => wizard.Restore(_before ?? throw new InvalidOperationException("Not executed."));
}

CheckoutWizard wizard = new();
wizard.AddBook("978-0441013593", 1);
List<ICheckoutCommand> done = [];

ICheckoutCommand[] commands = [new ApplyCoupon(wizard, "FREESHIP"), new ChooseShipping(wizard, "Express")];
foreach (ICheckoutCommand command in commands)
{
    command.Execute();
    done.Add(command);
    Console.WriteLine(wizard);
}
for (int i = done.Count - 1; i >= 0; i--)
{
    done[i].Undo();
    Console.WriteLine(wizard);
}
step 2: 1 books, Standard, address=-, coupon=FREESHIP
step 2: 1 books, Express, address=-, coupon=FREESHIP
step 2: 1 books, Standard, address=-, coupon=FREESHIP
step 2: 1 books, Standard, address=-, coupon=-

ChooseShipping is written the same way. The other way to undo is an inverse operation: the inverse of "apply the coupon" is "remove the coupon"; Command itself and the undo/redo stack are in this series' post "Command Pattern: Turning Requests into Objects, Undo/Redo and Queuing". Choosing between the two is a trade-off. An inverse operation costs no memory, but you have to write the correct inverse for every command, and some operations have none (a price calculation that rounds, the position of a deleted line). A memento always returns correctly, but every command carries a copy of the state. A practical rule: if the state is small and the inverse is hard to write, use a memento; if the state is large and the inverse is obvious, use an inverse operation.

A Persistent Snapshot: JSON and a Schema Version

We want to keep the checkout draft even if the user closes the browser, so the memento will be written to the database as JSON. Every persisted snapshot is a contract: when you change the code tomorrow you must still be able to read the JSON written yesterday. In v1 there was a single coupon string; in v2 we moved to a coupons list to support several coupons:

// v2 schema of the persistent snapshot: a list of coupons instead of one
public sealed record DraftSnapshotV2(
    int SchemaVersion, IReadOnlyList<DraftLine> Lines, string? Address, string Shipping,
    IReadOnlyList<string> Coupons, int Step)
{
    public const int CurrentVersion = 2;
}

string v1 = """{"schemaVersion":1,"lines":[{"isbn":"978-0441013593","quantity":1}],"address":"221B Baker Street","shipping":"Express","coupon":"FREESHIP","step":3}""";
JsonSerializerOptions web = new(JsonSerializerDefaults.Web);

DraftSnapshotV2 naive = JsonSerializer.Deserialize<DraftSnapshotV2>(v1, web)!;
Console.WriteLine($"naive:  coupons={(naive.Coupons is null ? "null" : "[" + string.Join(",", naive.Coupons) + "]")} step={naive.Step}");

JsonSerializerOptions required = new(web) { RespectRequiredConstructorParameters = true };
JsonSerializerOptions strict = new(web) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
foreach ((string name, JsonSerializerOptions options) in new[] { ("required", required), ("strict", strict) })
{
    try
    {
        JsonSerializer.Deserialize<DraftSnapshotV2>(v1, options);
    }
    catch (JsonException ex)
    {
        Console.WriteLine($"{name}: {ex.Message}");
    }
}
naive:  coupons=null step=3
required: JSON deserialization for type 'Bookstore.Checkout.DraftSnapshotV2' was missing required properties including: 'coupons'.
strict: The JSON property 'coupon' could not be mapped to any .NET member contained in type 'Bookstore.Checkout.DraftSnapshotV2'.

With the default settings the old snapshot opened without error and the coupon silently disappeared. Worse, the Coupons parameter, of non-nullable type IReadOnlyList<string>, came through as null; System.Text.Json gives a constructor parameter with no match in the JSON its default value. RespectRequiredConstructorParameters, added in .NET 9, turns the missing parameter into an exception, and JsonUnmappedMemberHandling.Disallow does the same for the unrecognised property. Both turn silent data loss into a loud error, but neither rescues the old draft. What rescues it is an explicit version number and an upgrade step:

public static DraftSnapshotV2 Read(string json)
{
    JsonObject node = JsonNode.Parse(json)!.AsObject();
    int version = node["schemaVersion"]?.GetValue<int>() ?? 1;

    // v1 -> v2: "coupon" was a single string, now it is a "coupons" list
    if (version == 1)
    {
        string? coupon = node["coupon"]?.GetValue<string>();
        node.Remove("coupon");
        node["coupons"] = coupon is null ? new JsonArray() : new JsonArray(coupon);
        node["schemaVersion"] = DraftSnapshotV2.CurrentVersion;
    }
    else if (version != DraftSnapshotV2.CurrentVersion)
    {
        throw new NotSupportedException($"Unknown snapshot version {version}");
    }

    return node.Deserialize<DraftSnapshotV2>(Options)!;
}
upgrade: coupons=[FREESHIP] step=3 version=2
{"schemaVersion":2,"lines":[{"isbn":"978-0441013593","quantity":1}],"address":"221B Baker Street","shipping":"Express","coupons":["FREESHIP"],"step":3}

We do the upgrade on JsonNode rather than a typed DTO (Data Transfer Object), because the shape of v1 no longer lives in any C# type. Let's also say that we deliberately took a step back from encapsulation here: the persistent snapshot is a public DTO because the serializer has to read it. Having only CheckoutWizard's export and import methods use that DTO, while the rest of the code carries it around as a string, keeps the spirit of the pattern.

Memory: Copy Every Snapshot, or Make the State Immutable?

The cost of Memento, which GoF also mentions, is expense: if the originator carries a large state and snapshots are taken often, memory fills up quickly. Say 1,000 books are added to the cart one at a time with a snapshot at every step. The first approach copies the list every time, as our Save() does; the second makes the state itself an ImmutableList<T> and keeps only the reference as the snapshot:

const int Steps = 1_000;

long before = GC.GetAllocatedBytesForCurrentThread();
List<OrderLine> mutable = [];
List<ImmutableArray<OrderLine>> copies = [];
for (int i = 0; i < Steps; i++)
{
    mutable.Add(new OrderLine($"isbn-{i}", 1));
    copies.Add([.. mutable]);
}
long copyBytes = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
ImmutableList<OrderLine> state = [];
List<ImmutableList<OrderLine>> shared = [];
for (int i = 0; i < Steps; i++)
{
    state = state.Add(new OrderLine($"isbn-{i}", 1));
    shared.Add(state);
}
long sharedBytes = GC.GetAllocatedBytesForCurrentThread() - before;

Console.WriteLine($"copy every snapshot:  {copyBytes / 1024} KB");
Console.WriteLine($"immutable state:      {sharedBytes / 1024} KB");
Console.WriteLine($"same content: {copies[^1].SequenceEqual(shared[^1])}, first snapshot still has {shared[0].Count} line");
copy every snapshot:  4038 KB
immutable state:      577 KB
same content: True, first snapshot still has 1 line

The copying approach grows with n²: the 1,000th snapshot is an array of 1,000 references. ImmutableList<T> is a tree; every Add only rebuilds a few nodes along the changed path, and old versions share the rest. The result is nearly seven times less allocation, with every snapshot internally consistent (the first snapshot still has a single line). The lesson: if the state is immutable, the memento is not a separate copy, it is the state itself. When records, immutable collections and the with expression come together, the Memento pattern almost disappears; all that remains is "keep the old reference".

Common Pitfalls

Shallow snapshots. The bug from the opening: the snapshot and the live object share a mutable collection. Everything that goes into a snapshot must either be copied or be immutable, including the objects inside it.

Unbounded history. An editor that takes a snapshot on every keystroke, with a caretaker that has no capacity limit, reaches hundreds of megabytes within hours. Give the caretaker a capacity and take snapshots at meaningful steps rather than on every change (when the form step changes, when typing pauses).

Things that don't belong in a snapshot. A DbContext, an open Stream, a CancellationToken or sensitive data such as a card number should not go into a memento. An in-memory memento only lives as long as the process; a memento written to JSON travels into logs, backups and the support team's screens. What must not be persisted is covered in the OWASP (Open Worldwide Application Security Project) Top 10 series post "OWASP A04: Cryptographic Failures (2) — Password Storage, Data Protection and Secret Management".

Unversioned persistent snapshots. If you didn't write a schemaVersion into the JSON, the first time the schema changes you will have to guess what shape old records are in. Write the version number from day one, keep the upgrade steps in one place, and don't leave deserialization on the default settings.

Side effects of restore. Restore brings the state back, but events that should fire when the state changes, computed fields or references in other objects don't fix themselves. Raise the "state changed" notification explicitly after a restore.

When Not to Use It

If the state is already immutable, don't build a separate Memento: keep the reference to the previous record; it already is a snapshot. If the state is small and the inverse of every operation is obvious, a Command with inverse operations uses less memory. If you need the full history of changes, that is the question "who changed what, and when", you need to store the changes themselves rather than snapshots; that is Event Sourcing's territory and the subject of the upcoming Software Architecture series, while the database-side audit approach is in the Solution Patterns series post "Audit Trail: Who Changed What, When and Why?". And a need to "roll back to here" inside a database transaction calls for a savepoint, not an application-level memento; the Entity Framework Core (EF Core) side is in the EF Core series post "Transactions in EF Core: SaveChanges, BeginTransaction and Savepoints".

A Small Experiment

★☆☆ Change the opening CartDraft to use ImmutableList<string> for Isbns. How must the draft.Isbns.Add line be written now, and how many books are left after the undo?

★★☆ Add redo to CheckoutHistory: keep undone mementos in a second list and clear the redo list when a new Backup arrives. Decide what the sequence undo, undo, new change, redo should do, and test it.

★★★ Add v3 to DraftSnapshotSerializer: the address string becomes a { "street", "city", "postalCode" } object. Write a test showing a v1 JSON being upgraded in a chain, first to v2 and then to v3; each upgrade step should know only the version before it.

Memento let an object return to its own past. The next post is about an object telling others when its state changes: beyond C#'s event, the IObservable<T> contract, IChangeToken and IOptionsMonitor in ASP.NET Core, and Observer with domain events.