Back to Blog
design-patternscsharptypescriptbehavioral-patterns

Memento Pattern — FixIt Pro Series #17

Saving and restoring job card state without exposing internals. Learn how the Memento pattern enables undo, snapshots, and state rollback, in C# and TypeScript.

Series: Design Patterns with FixIt Pro  ·  Episode 17 / 22  ·  Behavioral Pattern
Previous: #16 — Mediator Pattern


The Scenario

In Episode 14, the Command pattern gave FixIt Pro's operations team undo capability by storing previous state inside each command object. That works well when you control the commands, but what about undoing state that changed through multiple code paths? What about snapshots taken at arbitrary points, before a batch update, before a risky status transition, before a supervisor review?

FixIt Pro needs a way to:

  1. Save a job card's full state at any point in time
  2. Restore that state later, without the restorer needing to know what's inside

The challenge: you want to save state without exposing the job card's internal fields to the outside world. Encapsulation must stay intact.

That's the Memento pattern.


What Is the Memento Pattern?

Without violating encapsulation, capture and externalise an object's internal state so that the object can be restored to this state later.

Three roles. One key rule: only the originator can read the memento's contents.

The three participants

Role FixIt Pro equivalent
Originator JobCard — creates and restores mementos of its own state
Memento JobCardMemento — holds a snapshot of the job card's state
Caretaker JobCardHistory — stores mementos, never reads their contents

The caretaker holds mementos but is intentionally blind to their contents. It asks the originator to save and restore — it never inspects the state directly.


C# Implementation

// ── Memento ────────────────────────────────────────────────
// Sealed and nested inside JobCard — only JobCard can create
// or read a memento. The caretaker holds it as an opaque token.

public class JobCard
{
    // ── Internal state ─────────────────────────────────────
    public string  JobId    { get; init; } = Guid.NewGuid().ToString()[..8];
    public string  Title    { get; init; } = string.Empty;
    public string  Category { get; init; } = string.Empty;
    private string _status   = "Draft";
    private string? _assignee;
    private string? _notes;
    private decimal _cost;

    public string  Status   => _status;
    public string? Assignee => _assignee;
    public string? Notes    => _notes;
    public decimal Cost     => _cost;

    public JobCard(string title, string category, decimal cost)
    {
        Title    = title;
        Category = category;
        _cost    = cost;
    }

    // Mutators — business operations that change state
    public void Assign(string handyman)
    {
        _assignee = handyman;
        _status   = "Assigned";
    }

    public void StartWork()  => _status = "InProgress";
    public void PauseWork(string reason)
    {
        _status = "Paused";
        _notes  = reason;
    }

    public void Complete()
    {
        _status = "Completed";
        _notes  = null;
    }

    public void UpdateCost(decimal newCost) => _cost = newCost;

    // ── Memento operations ─────────────────────────────────

    // Save — creates a snapshot of current state
    public JobCardMemento Save(string label = "") =>
        new JobCardMemento(label, _status, _assignee, _notes, _cost);

    // Restore — applies a snapshot back to this object
    public void Restore(JobCardMemento memento)
    {
        _status   = memento.Status;
        _assignee = memento.Assignee;
        _notes    = memento.Notes;
        _cost     = memento.Cost;
    }

    public override string ToString() =>
        $"Job #{JobId} '{Title}' [{Category}]" +
        $"\n  Status  : {_status}" +
        $"\n  Assignee: {_assignee ?? "none"}" +
        $"\n  Notes   : {_notes ?? "none"}" +
        $"\n  Cost    : N${_cost:N2}";

    // ── Nested Memento ─────────────────────────────────────
    // Sealed so nothing outside JobCard can read its contents.
    // The caretaker holds it but cannot inspect it.

    public sealed class JobCardMemento
    {
        internal string  Status   { get; }
        internal string? Assignee { get; }
        internal string? Notes    { get; }
        internal decimal Cost     { get; }

        public string Label     { get; }
        public DateTime SavedAt { get; } = DateTime.Now;

        internal JobCardMemento(
            string label, string status,
            string? assignee, string? notes, decimal cost)
        {
            Label    = label;
            Status   = status;
            Assignee = assignee;
            Notes    = notes;
            Cost     = cost;
        }

        public override string ToString() =>
            $"[{SavedAt:HH:mm:ss}] Snapshot: '{Label}' — Status: {Status}";
    }
}

// ── Caretaker ──────────────────────────────────────────────
// Stores snapshots. Never reads their contents.

public class JobCardHistory
{
    private readonly Stack<JobCard.JobCardMemento> _history = new();

    public void Push(JobCard.JobCardMemento memento)
    {
        _history.Push(memento);
        Console.WriteLine($"  [History] Saved: {memento}");
    }

    public JobCard.JobCardMemento? Pop()
    {
        if (_history.Count == 0)
        {
            Console.WriteLine("  [History] Nothing to restore.");
            return null;
        }

        var memento = _history.Pop();
        Console.WriteLine($"  [History] Restoring: {memento}");
        return memento;
    }

    public void PrintAll()
    {
        Console.WriteLine($"  [History] {_history.Count} snapshot(s):");
        foreach (var m in _history)
            Console.WriteLine($"    {m}");
    }
}

// ── Client Code ────────────────────────────────────────────

class Program
{
    static void Main()
    {
        var history = new JobCardHistory();
        var job     = new JobCard("Replace burst pipe under sink", "Plumbing", 850m);

        Console.WriteLine("=== Initial State ===");
        Console.WriteLine(job);

        // Save snapshot before any changes
        history.Push(job.Save("initial"));

        // Assign and start
        job.Assign("James Nghipandua");
        job.StartWork();
        Console.WriteLine("\n=== After Assign + Start ===");
        Console.WriteLine(job);

        // Save snapshot before cost update
        history.Push(job.Save("before-cost-update"));

        // Cost revised upward
        job.UpdateCost(1_250m);
        Console.WriteLine("\n=== After Cost Update ===");
        Console.WriteLine(job);

        // Save snapshot before pause
        history.Push(job.Save("before-pause"));

        // Job paused — parts unavailable
        job.PauseWork("Waiting for replacement valve");
        Console.WriteLine("\n=== After Pause ===");
        Console.WriteLine(job);

        // ── Undo pause — restore last snapshot ────────────
        Console.WriteLine("\n=== Undoing Pause ===");
        var snapshot = history.Pop();
        if (snapshot is not null) job.Restore(snapshot);

        Console.WriteLine("\n=== Restored State ===");
        Console.WriteLine(job);

        Console.WriteLine("\n=== Remaining Snapshots ===");
        history.PrintAll();
    }
}

Output:

=== Initial State ===
Job #f3a12b 'Replace burst pipe under sink' [Plumbing]
  Status  : Draft
  Assignee: none
  Notes   : none
  Cost    : N$850.00

  [History] Saved: [09:15:01] Snapshot: 'initial' — Status: Draft

=== After Assign + Start ===
Job #f3a12b 'Replace burst pipe under sink' [Plumbing]
  Status  : InProgress
  Assignee: James Nghipandua
  Notes   : none
  Cost    : N$850.00

  [History] Saved: [09:15:01] Snapshot: 'before-cost-update' — Status: InProgress

=== After Cost Update ===
Job #f3a12b 'Replace burst pipe under sink' [Plumbing]
  Status  : InProgress
  Assignee: James Nghipandua
  Notes   : none
  Cost    : N$1,250.00

  [History] Saved: [09:15:01] Snapshot: 'before-pause' — Status: InProgress

=== After Pause ===
Job #f3a12b 'Replace burst pipe under sink' [Plumbing]
  Status  : Paused
  Assignee: James Nghipandua
  Notes   : Waiting for replacement valve
  Cost    : N$1,250.00

=== Undoing Pause ===
  [History] Restoring: [09:15:01] Snapshot: 'before-pause' — Status: InProgress

=== Restored State ===
Job #f3a12b 'Replace burst pipe under sink' [Plumbing]
  Status  : InProgress
  Assignee: James Nghipandua
  Notes   : none
  Cost    : N$1,250.00

=== Remaining Snapshots ===
  [History] 2 snapshot(s):
    [09:15:01] Snapshot: 'before-cost-update' — Status: InProgress
    [09:15:01] Snapshot: 'initial' — Status: Draft

TypeScript Implementation

// ── Memento/JobCardMemento.ts ──────────────────────────────
// The memento is intentionally opaque — its fields are
// only accessible within this module via a symbol key,
// not from the caretaker or any other external code.

const STATE_KEY = Symbol("JobCardMementoState");

export interface JobCardMementoState {
  status:   string;
  assignee: string | undefined;
  notes:    string | undefined;
  cost:     number;
}

export class JobCardMemento {
  readonly label:   string;
  readonly savedAt: Date = new Date();

  // Internal state hidden behind a Symbol key
  private readonly [STATE_KEY]: JobCardMementoState;

  constructor(label: string, state: JobCardMementoState) {
    this.label      = label;
    this[STATE_KEY] = { ...state }; // copy — not a reference
  }

  // Only JobCard (which imports STATE_KEY) can read this
  getState(): JobCardMementoState {
    return { ...this[STATE_KEY] };
  }

  toString(): string {
    return `[${this.savedAt.toTimeString().slice(0, 8)}] Snapshot: '${this.label}' — Status: ${this[STATE_KEY].status}`;
  }
}

// ── Originator/JobCard.ts ──────────────────────────────────

import { JobCardMemento, JobCardMementoState } from "../Memento/JobCardMemento";

export class JobCard {
  readonly jobId: string = Math.random().toString(36).slice(2, 10);

  private _status:   string           = "Draft";
  private _assignee: string | undefined;
  private _notes:    string | undefined;
  private _cost:     number;

  get status():   string            { return this._status;   }
  get assignee(): string | undefined { return this._assignee; }
  get notes():    string | undefined { return this._notes;    }
  get cost():     number             { return this._cost;     }

  constructor(
    readonly title:    string,
    readonly category: string,
    cost:              number
  ) {
    this._cost = cost;
  }

  assign(handyman: string): void {
    this._assignee = handyman;
    this._status   = "Assigned";
  }

  startWork():  void { this._status = "InProgress"; }

  pauseWork(reason: string): void {
    this._status = "Paused";
    this._notes  = reason;
  }

  complete(): void {
    this._status = "Completed";
    this._notes  = undefined;
  }

  updateCost(newCost: number): void { this._cost = newCost; }

  // Save — creates a snapshot of current private state
  save(label: string = ""): JobCardMemento {
    return new JobCardMemento(label, {
      status:   this._status,
      assignee: this._assignee,
      notes:    this._notes,
      cost:     this._cost,
    });
  }

  // Restore — reads the memento's state back into private fields
  restore(memento: JobCardMemento): void {
    const state     = memento.getState();
    this._status    = state.status;
    this._assignee  = state.assignee;
    this._notes     = state.notes;
    this._cost      = state.cost;
  }

  toString(): string {
    return [
      `Job #${this.jobId} '${this.title}' [${this.category}]`,
      `  Status  : ${this._status}`,
      `  Assignee: ${this._assignee ?? "none"}`,
      `  Notes   : ${this._notes   ?? "none"}`,
      `  Cost    : N$${this._cost.toFixed(2)}`,
    ].join("\n");
  }
}

// ── Caretaker/JobCardHistory.ts ────────────────────────────

import { JobCardMemento } from "../Memento/JobCardMemento";

export class JobCardHistory {
  private history: JobCardMemento[] = [];

  push(memento: JobCardMemento): void {
    this.history.push(memento);
    console.log(`  [History] Saved: ${memento}`);
  }

  pop(): JobCardMemento | undefined {
    if (this.history.length === 0) {
      console.log("  [History] Nothing to restore.");
      return undefined;
    }

    const memento = this.history.pop()!;
    console.log(`  [History] Restoring: ${memento}`);
    return memento;
  }

  printAll(): void {
    console.log(`  [History] ${this.history.length} snapshot(s):`);
    for (const m of [...this.history].reverse())
      console.log(`    ${m}`);
  }
}

// ── App.ts ─────────────────────────────────────────────────

import { JobCard        } from "./Originator/JobCard";
import { JobCardHistory } from "./Caretaker/JobCardHistory";

const history = new JobCardHistory();
const job     = new JobCard("Replace burst pipe under sink", "Plumbing", 850);

console.log("=== Initial State ===");
console.log(job.toString());

history.push(job.save("initial"));

job.assign("James Nghipandua");
job.startWork();
console.log("\n=== After Assign + Start ===");
console.log(job.toString());

history.push(job.save("before-cost-update"));

job.updateCost(1_250);
console.log("\n=== After Cost Update ===");
console.log(job.toString());

history.push(job.save("before-pause"));

job.pauseWork("Waiting for replacement valve");
console.log("\n=== After Pause ===");
console.log(job.toString());

console.log("\n=== Undoing Pause ===");
const snapshot = history.pop();
if (snapshot) job.restore(snapshot);

console.log("\n=== Restored State ===");
console.log(job.toString());

console.log("\n=== Remaining Snapshots ===");
history.printAll();

C# vs TypeScript — Key Differences

Aspect C# TypeScript
Encapsulation mechanism internal access modifier on memento fields, only JobCard in same assembly reads them Symbol key, only code that imports the symbol can access the field
Nested class public sealed class JobCardMemento inside JobCard Separate file, TypeScript doesn't support nested classes
Immutable snapshot init properties on memento Spread copy { ...state } in constructor
Stack Stack<T>, built-in LIFO T[] array with push/pop
Null fields string? _assignee nullable reference `string

The encapsulation approach differs significantly. In C#, nesting the memento inside JobCard and using internal accessors is the cleanest solution, the memento's fields are invisible to the caretaker. In TypeScript, a Symbol key achieves similar opacity, only code that imports STATE_KEY can access the hidden state.


Memento vs Command — What's the Difference?

Both enable undo. The distinction is responsibility:

Command Memento
Where state is stored Inside the command object Inside the memento object
Who knows about state The command, it captures what it needs Only the originator, the caretaker is blind
Granularity Per-action Per-snapshot
FixIt Pro Undo a specific AssignHandymanCommand Restore full job card state to any saved point
Best for Fine-grained, action-specific undo Full state rollback, checkpointing

Use Command when each action should be individually undoable. Use Memento when you need full state snapshots, before a batch operation, before a risky transition, or as a checkpoint system.


When to Use the Memento

Use it when:

  • You need to implement undo/redo without exposing internal state
  • You want to snapshot object state before a risky operation
  • You need a checkpoint system to save progress, restore on failure

Avoid it when:

  • The originator's state is large and snapshots become expensive to store
  • Frequent saves are needed, consider incremental deltas instead of full copies
  • A simpler approach (copying a plain data object) achieves the same result with less ceremony

Real-World Takeaway

The Memento is the pattern behind every text editor's undo history, each keystroke saves a memento; Ctrl+Z pops the stack. Browser history is a Memento chain, each page visit is a snapshot of navigation state. In game development, save files are Mementos, the full game state serialised to disk, restorable at any point. In .NET, TransactionScope is a Memento-style mechanism, it captures database state before a transaction and rolls back on failure. In version control, every Git commit is a Memento of the repository's file tree at a point in time.

In FixIt Pro, a supervisor can take a snapshot before reviewing a high-value job card, make changes during review, and restore the original state if the review is inconclusive, without any of the internal fields of JobCard being exposed to the supervisor's code. Encapsulation stays intact. The history stack stays blind. The originator stays in control.


Repo Structure for This Episode

github.com/antonlungameni/fixit-pro-design-patterns

fixit-pro-design-patterns/
├── csharp/Behavioral/17-Memento/
│   ├── Originator/JobCard.cs          (includes nested JobCardMemento)
│   ├── Caretaker/JobCardHistory.cs
│   └── Program.cs
└── typescript/Behavioral/17-Memento/
    ├── Memento/JobCardMemento.ts
    ├── Originator/JobCard.ts
    ├── Caretaker/JobCardHistory.ts
    └── App.ts

Previous: #16 — Mediator Pattern
Next up: #18 — Observer Pattern
Notifying homeowner, supervisor, and billing system automatically when a job card status changes, without the job card knowing who's listening.