The ticket says “back on the review step restores the draft Order — promo, shipping, gift wrap — as it was before they confirmed.” You open Order and add getters for every private field so the controller can copy them into a Map. Or you serialize OrderProcessor because it already holds the order, the gateway, and the policy. Undo works. You also just published the internals of checkout, and the snapshot now contains a live PaymentGateway.

That is Memento’s entire complaint. From the Design Patterns Roadmap: a snapshot should restore private state without the holder reading the fields.

This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We snapshot a draft because the shopper needs undo inside an object they are not allowed to pick apart — not because a review asked for a pattern name.

The draft you published to undo it

Here is a checkout session after the review-step ticket. Order grew public fields so a controller could remember them:

public class Order {

    public String id;
    public String customerEmail;
    public List<LineItem> items;
    public BigDecimal total;
    public String promoCode;
    public String shippingMethod;
    public boolean giftWrap;
    public BigDecimal estimatedPayable;

    public void applyPromo(DiscountPolicy discount) {
        this.estimatedPayable = discount.payable(this);
    }
}

The controller now is the undo stack. It knows every field, including ones that were private last week:

public void checkpoint(Order order) {
    backups.push(copyAllFields(order));
}

public void undo(Order order) {
    Order previous = backups.pop();
    order.promoCode = previous.promoCode;
    order.shippingMethod = previous.shippingMethod;
    order.giftWrap = previous.giftWrap;
    order.estimatedPayable = previous.estimatedPayable;
    order.items = previous.items;
}

The other shortcut is worse. It snapshots the processor because “that is where checkout lives”:

byte[] snapshot = serialize(processor);

Now price restore — and the next two:

Undo last review step          -> controller names every private field
Add a hold-until timestamp     -> edit Order *and* every copyAllFields
Serialize OrderProcessor       -> snapshot holds gateway, secrets, repository
Test restore in isolation      -> construct a processor, or leak another getter

Four costs, and none of them are about charging a card. Undo became a second public API for fields checkout was not supposed to show.

Note: The problem is not “the shopper wants back.” Back is a product feature. The problem is who is allowed to see the bits. If the caretaker can read estimatedPayable and promoCode, encapsulation already lost. A record the caller already holds is a different story — skip to when this is the wrong move.

What Memento actually is

Three parts, one promise:

PartIn this labJob
OriginatorOrder (the draft)Owns the private fields. Creates and consumes the snapshot.
MementoOrder.SnapshotOpaque token. Wide for the originator, narrow for everyone else.
CaretakerDraftUndoStores snapshots. Does not read them. Hands them back.

The caretaker holds the snapshot. Only the originator may open it. If DraftUndo starts calling snapshot.promoCode(), you did not hide state; you moved the getters onto a second type.

You do not need a class named Memento. Nested types in Java are the usual way to keep the wide interface actually wide — Snapshot lives inside Order, fields stay private, and DraftUndo never sees them.

That is the seam. Everything else is save and restore.

The originator writes the snapshot

Order stays the owner. Promo, shipping, wrap, and the estimated payable are private again. snapshot() copies them. restore puts them back. The nested class has no public getters:

public final class Order {

    private final String id;
    private final String customerEmail;
    private List<LineItem> items;
    private BigDecimal total;
    private String promoCode;
    private String shippingMethod;
    private boolean giftWrap;
    private BigDecimal estimatedPayable;

    public Snapshot snapshot() {
        return new Snapshot(
                promoCode, shippingMethod, giftWrap, estimatedPayable, List.copyOf(items), total);
    }

    public void restore(Snapshot snapshot) {
        this.promoCode = snapshot.promoCode;
        this.shippingMethod = snapshot.shippingMethod;
        this.giftWrap = snapshot.giftWrap;
        this.estimatedPayable = snapshot.estimatedPayable;
        this.items = new ArrayList<>(snapshot.items);
        this.total = snapshot.total;
    }

    public void applyPromo(String code, DiscountPolicy discount) {
        this.promoCode = code;
        this.estimatedPayable = discount.payable(this);
    }

    public String promoCode() {
        return promoCode;
    }

    public BigDecimal estimatedPayable() {
        return estimatedPayable;
    }

    public static final class Snapshot {

        private final String promoCode;
        private final String shippingMethod;
        private final boolean giftWrap;
        private final BigDecimal estimatedPayable;
        private final List<LineItem> items;
        private final BigDecimal total;

        private Snapshot(
                String promoCode,
                String shippingMethod,
                boolean giftWrap,
                BigDecimal estimatedPayable,
                List<LineItem> items,
                BigDecimal total) {
            this.promoCode = promoCode;
            this.shippingMethod = shippingMethod;
            this.giftWrap = giftWrap;
            this.estimatedPayable = estimatedPayable;
            this.items = items;
            this.total = total;
        }
    }
}

List.copyOf on the way in matters. A snapshot that aliases the live items list is not a snapshot; the next add mutates history. Identity fields (id, customerEmail) stay on Order because undo does not change which order this is.

OrderProcessor does not snapshot. It still asks discount.payable(order) and gateway.charge. The draft undo is a checkout-step concern, not a paid-order concern:

public void process(Order order) {
    BigDecimal payable = discount.payable(order);
    PaymentResult result = gateway.charge(order, payable);
    if (!result.approved()) {
        throw new PaymentDeclinedException(order.id(), result.failureReason());
    }
    orders.markPaid(order.id(), result.reference());
}

Note: Keep the memento dumb. The moment Snapshot starts calling PaymentGateway, applying DiscountPolicy, or exposing promoCode() “for the admin UI,” you have rebuilt the public field list under a Gang of Four name. Copy. Restore. Stop.

The caretaker never opens the box

DraftUndo is a stack of tokens. It can checkpoint and undo. It cannot print a promo code:

public final class DraftUndo {

    private final Deque<Order.Snapshot> history = new ArrayDeque<>();

    public void checkpoint(Order order) {
        history.push(order.snapshot());
    }

    public void undo(Order order) {
        if (history.isEmpty()) {
            return;
        }
        order.restore(history.pop());
    }
}

The edge — a checkout controller, a wizard — calls checkpoint before a step that might need back, then undo if the shopper hits it. Adding holdUntil to the draft is an edit inside Order.snapshot / restore. DraftUndo does not change. OrderProcessor does not change.

What the diff looks like now

Same feature request, both designs:

Before — undo by copying fields
  M Order.java              every draft field public
  M CheckoutController.java copyAllFields grows with every knob
  M OrderProcessor.java     sometimes serialized "because it has the order"

After — undo by opaque snapshot
  M Order.java              snapshot() / restore(); Snapshot nested, no getters
  A DraftUndo.java          stack of tokens; does not read fields
  CheckoutController        checkpoint / undo only

The caretaker does not grow a line per field. Order is closed against “a new holder wants to peek” and still fully open to editing when the draft itself grows a field — that field belongs in snapshot / restore, because those methods are the originator.

Proving restore without a gateway

The seam pays a second dividend: you can assert undo with an Order and a stack. No OrderProcessor, no card.

@Test
void undoRestoresPromoAndPayable() {
    Order order = Order.draft("o-1", "a@b.com", List.of(), new BigDecimal("40.00"));
    DraftUndo undo = new DraftUndo();
    undo.checkpoint(order);

    order.applyPromo("PERCENT10", new PercentOff(new BigDecimal("10")));
    undo.undo(order);

    assertEquals(new BigDecimal("40.00"), order.estimatedPayable());
    assertNull(order.promoCode());
}

And you can prove the caretaker cannot read the token without turning package access into an API. If a test for DraftUndo asserts snapshot.promoCode(), the test has become a second originator. Test restore through Order. Test the stack with two checkpoints and a pop count — not with field dumps.

If a declined-charge test needs Order.Snapshot, you have mixed two reasons to change. Charging still belongs on OrderProcessor with a FakePaymentGateway. Snapshots belong to the draft.

Memento is not Command

Both show up next to the word undo. Review comments that say “this is a Command” after you introduced DraftUndo are naming the stack, not what sits on it.

MementoCommand
What you storePrivate state of one objectThe request (charge, refund, capture)
Undo meansPut the fields backReverse an action (often a gateway call)
Who may read itOnly the originatorThe command already holds its own arguments
Typical lab typeOrder.Snapshot on this draftChargeOrderCommand on a queue

Command undoes a charge by talking to PaymentGateway.refund. Memento undoes a form step by writing private fields back. Do not snapshot OrderProcessor to undo a card capture. Nothing in the processor’s private fields will refund Stripe. Do not wrap snapshot() in UndoDraftCommand unless that request must sit on the same queue as ChargeOrderCommand — and even then the memento is still the payload, not the pattern you reached for.

State is a third neighbor. State changes which verbs are legal as the order moves DRAFT → PAID. Memento restores values inside one status. order.become(PaidStatus.INSTANCE) is not a snapshot. restore is not a status table.

When Memento is the wrong move

Wave 3 is easy to over-apply. Skip the snapshot type when:

  • The caller already owns the previous copy. Order as a record, Order previous = order, then order = order.withShipping("EXPRESS"), then undo is order = previous. There is nothing private to hide. A nested Snapshot on a four-field record is two types for an assignment.
  • The “state” is a DTO you mapped from JSON. The browser already sent the last payload. Keep it. Do not invent Order.Snapshot so the server can restore fields the client still has.
  • You are about to serialize OrderProcessor. That object holds a gateway, a repository, and a DiscountPolicy. A snapshot of the processor is a snapshot of infrastructure. Persist an Order row, or a memento of the draft, never the workflow engine.
  • Undo must talk to a vendor. Support’s “undo last charge” is Command (or a refund method). Restoring estimatedPayable will not reverse gateway.charge.
  • You need a history table, versions, or audit for compliance. A Deque<Snapshot> in heap is not your source of truth. Write a draft row. Memento is an in-process token, not an event store.
  • The caretaker must display what it stored. An undo UI that shows “promo PERCENT10, ground shipping” needs those values on a read model the originator publishes on purpose — order.reviewSummary() — not getters on Snapshot. If the token grows promoCode(), encapsulation is gone.
  • You checkpoint every keystroke. Snapshot before a step the shopper might abandon. A memento per character in the gift-message box is a memory leak with a pattern name.

The healthy trigger is private mutable state inside an object, plus a holder that must not see it. A wizard that mutates Order’s promo and shipping, with Back on the review step, is that trigger. A record the controller already kept is not. A paid charge is not.

Do not reach for Memento because Command’s undo() stores payable and reference. Those are the command’s own fields, set at execute, used at undo. They are not an originator’s guts. The Design Patterns Roadmap lists this pattern in Wave 3 on purpose: the name shows up in interviews more often than the pain shows up in checkout.

Cheat sheet

Originator  Order                   snapshot() / restore(); owns promo, shipping, wrap
Memento     Order.Snapshot          nested, no public getters; copies items
Caretaker   DraftUndo               stack of tokens; checkpoint / undo
Processor   OrderProcessor          still charges; is not serialized

Trigger to apply: undo must restore private fields the holder may not read
Trigger to stop:  the caller already has the previous record, or undo is a refund
Scoreboard:       new draft field = edit snapshot/restore; DraftUndo does not change
Not Command:      put fields back vs reverse a gateway call
Not State:        restore values vs change which verbs are legal

Do:

  • Nest the snapshot so only the originator can read it.
  • Copy mutable guts (List.copyOf, new ArrayList on restore). Aliasing is not undo.
  • Checkpoint at step boundaries. Let OrderProcessor keep charging.
  • Test restore through Order; test the stack without opening Snapshot.

Don’t:

  • Add getters on Order so a controller can copy fields.
  • Serialize OrderProcessor because it happens to hold the draft.
  • Put promoCode() on the memento “for debugging” and then use it in production UI.
  • Use Memento to undo gateway.charge — that is Command.
  • Snapshot a record the caller already stored in a local variable.

Wrap-up

Memento is a token only the originator can write and read, plus a caretaker that is not allowed to peek. The review-step undo was expensive because every new draft field leaked through copyAllFields or through a serialized processor. Order.snapshot / restore plus DraftUndo makes the next knob an internal copy, keeps restore unit-testable without a gateway, and leaves process free to charge. If the previous Order is already in the caller’s hand, assign it back. If undo must refund a card, that is a command, not a snapshot.

Wave 3 is the set that is easy to over-apply. The rest of the catalog sits on the map.

Next optional step in the series Copy a configured checkout fixture instead of rebuilding every constructor argument. Prototype: Copy an Object Instead of Reconstructing It