The checkout screen of an online store usually has five components: a coupon code field, a shipping option, a payment method, a total label, and a "Pay" button. They constrain each other: switching to express shipping changes the total, an order above 20 million VND cannot use COD, and the FREESHIP code applies only to standard shipping. If every component holds references to all the others, you end up with a web of cross-dependencies. The Mediator pattern solves exactly this by moving every coordination rule into one intermediary object. This article implements it from scratch, then pairs it with Memento, the pattern that saves and restores state without breaking encapsulation, to build an undo feature for a draft order.
The Mediator pattern and Memento both belong to the behavioral group of the 23 GoF patterns. If you have read Iterator pattern in Java: Iterable, Spliterator and Stream or State pattern and an order state machine, this article continues the same thread: separating communication and state responsibilities out of the business classes. All code runs on JDK 21 on an Apple M4 with 16 GB of RAM, needs no external libraries, and the output shown is the real output of the run.
The problem the Mediator pattern solves: components calling each other
Suppose you write the checkout without the Mediator pattern. When the user changes the payment method, PaymentSelect must call PayButton to lock the button if the order is too large, and call TotalLabel to recompute the amount. When the shipping option changes, ShippingSelect must call TotalLabel and PayButton again, and ask CouponField whether FREESHIP is present. With n components the number of links can reach n(n-1), which is 20 links for five components. The consequences:
- You cannot reuse
ShippingSelecton another screen (for example the cart page) because it dragsPayButtonandCouponFieldalong with it. - Adding a new component (for example a loyalty points field) forces changes to many existing classes.
- Business rules such as "COD is not allowed for orders of 20 million or more" get scattered across many places, which makes them hard to find and easy to let drift apart.
Structure of the Mediator pattern
According to refactoring.guru's description of the Mediator pattern, it is a behavioral pattern that "lets you reduce chaotic dependencies between objects" by making them communicate only through a mediator object. The structure has these parts:
| Role | In the checkout example | Responsibility |
|---|---|---|
| Mediator (interface) | CheckoutMediator | Declares the notify(sender, event) method |
| ConcreteMediator | CheckoutDialog | Knows every component and holds the coordination rules |
| Colleague (component) | CouponField, PaymentSelect, ShippingSelect, PayButton, TotalLabel | Holds only a reference to the Mediator interface and reports events to it |
The key point of the Mediator pattern: a many-to-many relationship becomes one-to-many. Each component has exactly one dependency, the Mediator interface. Every "when this changes, that must change too" rule lives in CheckoutDialog.
Implementing the Mediator pattern step by step
First come the interface and the base class for components. A component does not know who is listening; it only calls mediator.notify:
interface CheckoutMediator {
void notify(Component sender, String event);
}
abstract static class Component {
protected CheckoutMediator mediator;
final String name;
Component(String name) { this.name = name; }
void setMediator(CheckoutMediator m) { this.mediator = m; }
}
static class PaymentSelect extends Component {
private String method = "COD";
PaymentSelect() { super("payment"); }
void choose(String m) { method = m; mediator.notify(this, "changed"); }
String method() { return method; }
}
static class PayButton extends Component {
private boolean enabled = true;
PayButton() { super("payButton"); }
void setEnabled(boolean e) { enabled = e; }
void click() {
if (!enabled) return;
mediator.notify(this, "clicked");
}
}The CouponField, ShippingSelect, and TotalLabel classes are written the same way. Next is the ConcreteMediator of the Mediator pattern, where the three business rules are gathered:
static class CheckoutDialog implements CheckoutMediator {
final CouponField coupon = new CouponField();
final PaymentSelect payment = new PaymentSelect();
final ShippingSelect shipping = new ShippingSelect();
final PayButton pay = new PayButton();
final TotalLabel total = new TotalLabel();
private final long subtotal;
CheckoutDialog(long subtotal) {
this.subtotal = subtotal;
for (Component c : List.of(coupon, payment, shipping, pay, total)) c.setMediator(this);
refresh();
}
@Override
public void notify(Component sender, String event) {
if (sender == pay) {
log("Paid " + total() + " VND via " + payment.method());
return;
}
refresh(); // any field changed: recompute the total and the button state
}
long total() {
long ship = shipping.type().equals("EXPRESS") ? 45_000 : 25_000;
if (coupon.code().equals("FREESHIP") && shipping.type().equals("STANDARD")) ship = 0;
return subtotal + ship;
}
private void refresh() {
total.show(total());
boolean codTooBig = payment.method().equals("COD") && total() >= 20_000_000;
pay.setEnabled(!codTooBig);
}
}Two scenarios run next: an order of 1.2 million and an order of 22 million. For the second, COD must be blocked, and only after switching to bank transfer can the order be paid (cd code/s6-19 && javac -d out Mediator.java && java -cp out Mediator):
== Order of 1.2 million ==
TotalLabel = 1225000 VND
PayButton enabled=true
TotalLabel = 1200000 VND
PayButton enabled=true
TotalLabel = 1245000 VND
PayButton enabled=true
TotalLabel = 1200000 VND
PayButton enabled=true
Paid 1200000 VND via COD
== Order of 22 million, COD ==
TotalLabel = 22025000 VND
PayButton enabled=false
PayButton: clicked while locked, ignored
TotalLabel = 22025000 VND
PayButton enabled=true
Paid 22025000 VND via BANK_TRANSFER
Trace: [payment:changed, payButton:clicked]Read the output in order. The first lines come from the refresh() call in the constructor (1,200,000 + 25,000 shipping). Entering FREESHIP drops shipping to 0 (1,200,000), switching to EXPRESS makes the code ineffective (1,245,000), and switching back to STANDARD makes FREESHIP take effect again. For the 22 million order, the button is locked right at construction because COD exceeds the limit, the click is ignored, and switching to bank transfer enables the button. Thanks to the Mediator pattern, no component calls another directly, so you can add LoyaltyPoints by changing only CheckoutDialog.
The Mediator pattern in practice: the JDK, Spring, and libraries
You meet the Mediator pattern, or close variants of it, more often than you might think, even if it does not always carry that name:
- Spring
ApplicationEventPublisher: a bean publishes events and@EventListenermethods receive them. The publisher does not know who is listening, and theApplicationContextacts as the intermediary. This is the publish/subscribe variant of the Mediator pattern; see the Standard and Custom Events section of the Spring Framework documentation. - Spring MVC
DispatcherServlet: the Spring documentation describes it as a front controller that picks a handler and calls the view resolver; those components do not call each other but go through it. This is coordination in the Mediator style, even though the documentation does not use that name (DispatcherServlet).
Mediator libraries such as MediatR for .NET apply the same idea to commands and queries inside an application. It is also wise not to label everything that coordinates as a Mediator: ExecutorService is just a task executor, and publish/subscribe domain events are closer to Observer.
It helps to distinguish it from Observer: Observer is a one-way relationship in which a subject emits events to subscribers and does not care what they do. The Mediator pattern has coordination rules and can decide whom to call, in what order, even blocking an event. Many real-world Mediator pattern implementations use Observer underneath to deliver events.
When not to use the Mediator pattern
The biggest drawback of the Mediator pattern, as refactoring.guru warns ("Over time a mediator can evolve into a God Object"), is that it can swell into a God Object: it knows too much and does too much. CheckoutDialog above is only 40 lines, but add 30 promotion rules and it becomes a huge class that is hard to test. A few principles to avoid this:
- Use it only when there are many components with cross-dependencies. If two classes call each other, let them call each other.
- Split mediators by context (one for the checkout form, one for the address form) instead of using one application-wide Mediator.
- Push complex rules into separate policy classes (for example
ShippingPolicy,CodLimitPolicy), and let the Mediator pattern only coordinate. - If all you need is "whoever is interested can listen", an Observer or an event bus is enough; do not write a Mediator pattern.
Memento pattern: saving state without breaking encapsulation
Now to the second problem. A salesperson drafts an order for a business customer: adds a laptop, adds a mouse, writes a delivery note. They want to undo and redo. The naive approach is to let an outside class read every field of the order and store it. That breaks encapsulation: the outside knows the internal structure, and changing that structure breaks the undo feature.
Memento defines three roles: the Originator is the object that owns the state, creates snapshots itself, and restores itself from a snapshot; the Memento is the immutable snapshot; the Caretaker keeps the list of snapshots but cannot read their contents. In Java, the strictest approach is to nest the Memento class inside the Originator with a private constructor and fields, because then Snapshot sits within the Originator's scope and classes in other files or packages cannot read its fields.
One note on Java's private scope: it is checked at the level of the top-level class that contains the declaration, not at the level of each nested class. In the demo file below, DraftOrder, Snapshot, and History are all nested classes of the same Memento class, so as far as the language is concerned History can still read s.customer (I tried a separate three-nested-class example like this and it compiled, ran, and printed the value). The demo simply does not use that privilege, and History only calls save() and then restore(). For real hiding, move DraftOrder (with Snapshot nested inside) into its own file, or make Snapshot an empty interface implemented by a private class inside the Originator.
static class DraftOrder {
private String customer;
private final List<String> items = new ArrayList<>();
private String note = "";
static final class Snapshot {
private final String customer;
private final List<String> items;
private final String note;
private Snapshot(String customer, List<String> items, String note) {
this.customer = customer;
this.items = List.copyOf(items); // defensive copy
this.note = note;
}
}
Snapshot save() { return new Snapshot(customer, items, note); }
void restore(Snapshot s) {
this.customer = s.customer;
this.items.clear();
this.items.addAll(s.items);
this.note = s.note;
}
}The Caretaker is a pair of undo and redo stacks, with a cap on the number of snapshots so memory use is not unbounded:
static class History {
private final Deque<DraftOrder.Snapshot> undo = new ArrayDeque<>();
private final Deque<DraftOrder.Snapshot> redo = new ArrayDeque<>();
private final int limit;
private final DraftOrder order;
void checkpoint() {
undo.push(order.save());
if (undo.size() > limit) undo.removeLast(); // drop the oldest snapshot
redo.clear(); // a new action clears the redo branch
}
boolean undo() {
if (undo.isEmpty()) return false;
redo.push(order.save());
order.restore(undo.pop());
return true;
}
boolean redo() {
if (redo.isEmpty()) return false;
undo.push(order.save());
order.restore(redo.pop());
return true;
}
}The usage convention: call checkpoint() before every modification. Compile and run with javac -d out Memento.java && java -cp out Memento. Output:
Current : An Phat Company [LAP-001, MOUSE-07] note='Deliver during business hours'
After undo : An Phat Company [LAP-001, MOUSE-07] note=''
Undo again: An Phat Company [LAP-001] note=''
After redo : An Phat Company [LAP-001, MOUSE-07] note=''
A new action clears redo, redo() = false
-- History limit 3 --
Snapshots kept: 3
Undo all the way: Walk-in customer [SKU-1, SKU-2, SKU-3] note=''The last two results are worth noting. Six items are added in a row but the history is limited to 3 snapshots, so undoing all the way only goes back to the state with 3 items, not to empty. That is a deliberate trade-off between memory and undo depth. Also, after a new action the redo stack is cleared, which is the behavior of every text editor.
Memento in the JDK: StateEditable and UndoManager
The JDK ships a Memento implementation in the java.desktop module: the StateEditable interface with two methods, storeState(Hashtable) and restoreState(Hashtable), used together with StateEdit and UndoManager. According to the Javadoc, UndoManager keeps an ordered list of UndoableEdit objects, limits it to 100 entries by default, and undo()/redo() throw CannotUndoException/CannotRedoException when there is nothing to do. You do not need a graphical interface to use it, so the snippet below runs headless: javac -d out SwingUndo.java && java -Djava.awt.headless=true -cp out SwingUndo.
static class Cart implements StateEditable {
int quantity = 1;
String coupon = "";
public void storeState(Hashtable<Object, Object> state) {
state.put("quantity", quantity);
state.put("coupon", coupon);
}
public void restoreState(Hashtable<?, ?> state) {
Object q = state.get("quantity");
if (q != null) quantity = (Integer) q;
Object c = state.get("coupon");
if (c != null) coupon = (String) c;
}
}
StateEdit e1 = new StateEdit(cart, "increase quantity");
cart.quantity = 3;
e1.end();
manager.addEdit(e1);Current : quantity=3, coupon='SALE10'
undo 1 : quantity=3, coupon='' | canRedo=true
undo 2 : quantity=1, coupon='' | canUndo=false
redo 1 : quantity=3, coupon='' | Redo enter couponA trap I hit when running it for real: at first restoreState was written as a plain (Integer) state.get("quantity") and threw NullPointerException on the very first undo. The cause is that StateEdit stores the state before and after the action, then removes the unchanged keys from the Hashtable to save memory. The "enter coupon" action does not change quantity, so that key is absent at restore time. That is why you must check for null before assigning. It shows that even in a standard API, a "store only the difference" Memento has an implicit contract that the documentation describes rather briefly; write tests for every save/restore pair.
Combining the Mediator pattern and Memento
The Mediator pattern and Memento complement each other naturally. In the checkout form, each time the user changes a field, the Mediator pattern handles the ripple effects; the same intermediary object is also the logical place for the Caretaker, because it sees every change. There is an easy mistake around ordering: if a component assigns the new value first and only then reports changed, then when the Mediator takes the snapshot the state is already the state after the change, and undo cannot step back. When combining the Mediator pattern with Memento, the convention from the Memento section ("call checkpoint() before every modification") still holds here, so the component must emit two events: beforeChange before assigning so the Mediator can take a snapshot, then changed after assigning so the Mediator can recompute the derived parts.
On undo, the Mediator pattern restores the original values and then calls refresh() once, using a restoring flag so that assignments made during the restore do not create more snapshots. The rule: store only the primary state and recompute the derived state, because storing the total and the button and restoring them can drift from the primary values.
The following snippet (file MediatorMemento.java) implements both approaches for comparison, with a 22 million order where the user switches to bank transfer, then to express shipping, then undoes twice. Below is the core part; the full file is in the code folder. Run it with javac -d out MediatorMemento.java && java -cp out MediatorMemento:
void set(String v) {
mediator.notify(this, "beforeChange"); // the Mediator captures the old state here
value = v;
mediator.notify(this, "changed"); // the Mediator recomputes derived state here
}
// ...
@Override public void notify(Object sender, String event) {
if (restoring) return;
if (event.equals("beforeChange") && snapshotBefore) undo.push(save());
if (event.equals("changed")) {
if (!snapshotBefore) undo.push(save()); // WRONG: this is already the state AFTER the change
refresh();
}
}
boolean undo() {
if (undo.isEmpty()) return false;
Snapshot s = undo.pop();
restoring = true;
coupon.set(s.coupon()); payment.set(s.payment()); shipping.set(s.shipping());
restoring = false;
refresh(); // once, to keep derived state consistent
return true;
}
// ... refresh(): recompute the total and the button state from the primary state== Snapshot BEFORE the change (beforeChange) ==
Initial : coupon='', payment=COD, shipping=STANDARD | total=22025000 VND, Pay button=locked
After 2 actions: coupon='', payment=BANK_TRANSFER, shipping=EXPRESS | total=22045000 VND, Pay button=enabled
Undo 1 : coupon='', payment=BANK_TRANSFER, shipping=STANDARD | total=22025000 VND, Pay button=enabled
Undo 2 : coupon='', payment=COD, shipping=STANDARD | total=22025000 VND, Pay button=locked
== Snapshot AFTER the change (changed), the wrong way ==
Initial : coupon='', payment=COD, shipping=STANDARD | total=22025000 VND, Pay button=locked
After 2 actions: coupon='', payment=BANK_TRANSFER, shipping=EXPRESS | total=22045000 VND, Pay button=enabled
Undo 1 : coupon='', payment=BANK_TRANSFER, shipping=EXPRESS | total=22045000 VND, Pay button=enabled
Undo 2 : coupon='', payment=BANK_TRANSFER, shipping=STANDARD | total=22025000 VND, Pay button=enabledWith the Mediator pattern acting as the snapshot taker, the correct way (snapshot at beforeChange) steps back one action at a time: the first undo returns shipping to STANDARD, the second undo returns payment to COD and the Pay button is locked again as at the start. The wrong way (snapshot at changed) stores the state after the action, so the first undo changes nothing ("Undo 1" is still BANK_TRANSFER and EXPRESS), and two undos still do not get back to the original COD. This bug of the Mediator pattern combined with Memento is hard to spot because everything keeps running without any exception.
Common mistakes
1. Snapshot sharing references (shallow copy). This is the classic mistake. The code below is deliberately wrong to illustrate it: the snapshot holds the same list as the original object:
record Shallow(List<String> items) {}
Shallow save() { return new Shallow(items); } // no copy
void restore(Shallow s) { items.clear(); items.addAll(s.items()); }-- Shallow copy bug --
Snapshot saved with 1 item, but now contains: [A, B]
After restore: [] (expected [A])The snapshot was saved when only item A existed, but adding B to the original object also gives the snapshot B. Worse, restore calls items.clear() on the same list as s.items(), so both end up empty. The fix: a defensive copy such as List.copyOf in the Snapshot constructor, or immutable structures from the start. For deeply nested objects you need a deep copy; see Prototype pattern: clone, copy constructors and deep copies.
2. Unbounded history. Each snapshot of a 1,000-line order costs significant memory; set a cap like the limit above, or store deltas instead of the whole state as StateEdit does.
3. The Mediator pattern turning into a God Object. A sign: notify contains an if/else chain hundreds of lines long keyed on component names. Convert it into a rule registry table, or split into sub-mediators by functional area.
4. Event loops. The Mediator pattern invites this one: the mediator calls setEnabled, and the component reports an event back to the Mediator, leading to infinite recursion. In the example above setEnabled does not emit events; if your components do emit, you need a recursion guard flag or a comparison with the old value before notifying.
5. Snapshots holding resources that cannot be restored. Database connections, running threads, and open files cannot go into a Memento. Memento suits value data, not external resources.
Trade-offs and a checklist for the Mediator pattern and Memento
| Pattern | Gain | Cost |
|---|---|---|
| Mediator pattern | Fewer cross-dependencies, reusable components, rules in one place | Easy to become a God Object; the extra indirection makes the flow harder to follow in a debugger |
| Memento | Undo/redo and rollback without exposing internal structure; the Originator is freed from managing history | Costs memory if snapshots are large or numerous; you must manage the snapshot lifecycle |
- Three or more components with cross-dependencies? Consider the Mediator pattern. Only two classes? Call directly.
- In the Mediator pattern, components know only the mediator interface and hold no references to other components.
- Complex business rules go into policy classes; the Mediator only coordinates.
- Nest the Memento inside the Originator, keep fields private, and defensively copy every collection.
- Store only the primary state; recompute derived state after restore.
- When combining the Mediator pattern with Memento, have components emit
beforeChangeso the Mediator pattern captures the snapshot before the value changes. - With the Mediator pattern, use a recursion guard flag during restore so assignments made by undo do not create extra snapshots.
- Cap the history depth and write tests for the save, change, restore cycle.
For sources, if you want the original definitions, see the Behavioral Patterns chapter of the book Design Patterns (GoF, 1994), and the description of the Mediator pattern, Memento, and the UML diagrams at refactoring.guru cited above. All the code in this article is in code/s6-19/, consisting of Mediator.java (the Mediator pattern), Memento.java, SwingUndo.java, MediatorMemento.java (the Mediator pattern combined with Memento), and the run_output.txt file.
Next article
Today's two patterns solved how objects talk to each other and remember state. The next article goes into something every Java framework relies on: Dependency Injection: building an IoC container to understand Spring, where we write the container that creates and wires objects ourselves to see what Spring does underneath @Autowired.



