The ticket says “BOGO should not discount if the SKU is already reserved by fraud, and a declined charge must release that reserve and skip the receipt.” You open PercentOff and find Inventory. You open StripeGateway and find FraudClient plus OrderNotifier. You open the notifier and find it confirming stock. Every colleague knows every other colleague. Adding a second fraud rule means editing the gateway, the policy, and the mailer — and OrderProcessor is no longer the only place money moves.
That is Mediator’s entire complaint. From the Design Patterns Roadmap: colleagues should not name each other; a hub sequences the talk.
This post stays on the shared Order / PaymentGateway / DiscountPolicy / OrderProcessor lab. We do not re-lecture the three families. We pull arrows between discount, fraud, inventory, charge, and notify — because that mesh is not a missing constructor parameter.
The mesh that grew between colleagues
Here is the same checkout after three tickets landed in three classes. Nobody wrote this badly on purpose:
public final class PercentOff implements DiscountPolicy {
private final Inventory inventory;
private final BigDecimal factor;
@Override
public BigDecimal payable(Order order) {
if (!inventory.inStock(order)) {
return order.total();
}
return order.total().multiply(factor).setScale(2, RoundingMode.HALF_UP);
}
}
public final class StripeGateway implements PaymentGateway {
private final StripeClient client;
private final FraudClient fraud;
private final OrderNotifier notifier;
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
if (fraud.screen(order).blocked()) {
return PaymentResult.declined("fraud");
}
PaymentResult result = PaymentResult.approved(client.charge(order.id(), amount));
if (result.approved()) {
notifier.notifyPaid(order, result);
}
return result;
}
}
Inventory, on a low-stock path, already calls the same notifier to ping ops. Discount asks inventory. The gateway asks fraud and the notifier. The notifier asks inventory to confirm. Now price the declined-charge ticket:
Release reserve on decline -> which class owns the reserve? gateway? inventory? both?
Skip receipt on fraud block -> notifier is inside charge(); add another if
Test PercentOff in isolation -> construct Inventory, and soon FraudClient
Add a second gateway -> copy fraud + notify into PayPalGateway
Four costs, and none of them are about card networks. Pricing, charging, and notifying have become a clique.
Note: The problem is not that checkout has several steps. Facade already hid a conversation from controllers. The problem is the colleagues calling each other so there is no single sequence left to hide. A front door on a mesh is still a mesh.
What Mediator actually is
Two parts, one promise: every arrow that used to go colleague-to-colleague now goes through the hub.
| Part | In this lab | Job |
|---|---|---|
| Colleague | DiscountPolicy, FraudClient, Inventory, PaymentGateway, OrderNotifier, OrderRepository | One job. Does not import the others. |
| Mediator | CheckoutMediator | Knows the colleagues. Runs the protocol. |
Colleagues stay ignorant of each other. Only the mediator is allowed to know the graph. If PercentOff still imports Inventory “for BOGO stock,” you have not mediated; you have added a seventh type next to the clique.
You do not need a class named Mediator. You need a verb the entry point already understands — here, “place this order” — implemented by a type that is allowed to name everyone:
public final class CheckoutMediator {
private final DiscountPolicy discount;
private final FraudClient fraud;
private final Inventory inventory;
private final PaymentGateway gateway;
private final OrderRepository orders;
private final OrderNotifier notifier;
public CheckoutMediator(
DiscountPolicy discount,
FraudClient fraud,
Inventory inventory,
PaymentGateway gateway,
OrderRepository orders,
OrderNotifier notifier) {
this.discount = discount;
this.fraud = fraud;
this.inventory = inventory;
this.gateway = gateway;
this.orders = orders;
this.notifier = notifier;
}
public PlacementResult place(Order order) {
FraudDecision decision = fraud.screen(order);
if (decision.blocked()) {
return PlacementResult.blocked(decision.reason());
}
if (!inventory.reserve(order)) {
return PlacementResult.outOfStock();
}
BigDecimal payable = discount.payable(order);
PaymentResult result = gateway.charge(order, payable);
if (!result.approved()) {
inventory.release(order);
return PlacementResult.declined(result.failureReason());
}
orders.markPaid(order.id(), result.reference());
notifier.notifyPaid(order, result);
return PlacementResult.paid(result.reference(), payable);
}
}
The graph is the point: fraud, then reserve, then price, then charge, then persist, then notify; release on decline. That sequence used to be scattered across PercentOff and StripeGateway.
OrderProcessor.process can be this hub for a smaller lab (discount, gateway, repository). The name Mediator starts to earn its keep when the colleagues had started importing each other, not when you renamed a facade.
Colleagues go back to one job
PercentOff prices. It does not ask inventory. StripeGateway captures. It does not screen fraud or send mail. Inventory reserves and releases when told:
public final class PercentOff implements DiscountPolicy {
private final BigDecimal factor;
public PercentOff(BigDecimal percentOff) {
this.factor = BigDecimal.ONE.subtract(percentOff.movePointLeft(2));
}
@Override
public BigDecimal payable(Order order) {
return order.total().multiply(factor).setScale(2, RoundingMode.HALF_UP);
}
}
public final class StripeGateway implements PaymentGateway {
private final StripeClient client;
public StripeGateway(StripeClient client) {
this.client = client;
}
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
try {
return PaymentResult.approved(client.charge(order.id(), amount));
} catch (StripeCardException e) {
return PaymentResult.declined(e.getDeclineCode());
}
}
}
BOGO-that-needs-stock is not a policy that imports Inventory. It is the mediator reserving first, then asking discount.payable. If the product truly cannot price without a stock snapshot, pass a StockSnapshot value into payable — data, not a back-call into the warehouse.
Wiring stays at the edge. Controllers talk to the hub (or to a Facade that is this hub). They do not inject Inventory beside it.
Note: Keep the mediator’s methods few. place is one conversation. The moment CheckoutMediator grows sendWeeklyDigest and reindexCatalog, it is a god object with a pattern name. Refunds may deserve their own hub — or a Command if the pain is queue and undo.
What the diff looks like now
Same feature request, both designs:
Before — declined charge must release reserve and skip mail
M PercentOff.java already imported Inventory; more stock ifs
M StripeGateway.java fraud + notifier + now release?
M OrderNotifier.java confirm-reserve on paid, maybe skip on fraud
M every gateway copy the same mesh
After — sequence lives in one place
A CheckoutMediator.java reserve, price, charge, release-or-notify
M PercentOff.java inventory import removed
M StripeGateway.java fraud and notifier imports removed
One file owns the protocol. Adding PayPal is still an Adapter behind PaymentGateway. It does not learn fraud. A new discount formula is still a Strategy. It does not learn inventory.
Proving the protocol without a clique
The seam pays a second dividend: you can test “declined charge releases stock and does not notify” with fakes that never import each other.
@Test
void declinedChargeReleasesReserveAndDoesNotNotify() {
FakeFraudClient fraud = FakeFraudClient.alwaysClear();
RecordingInventory inventory = new RecordingInventory(true);
FakePaymentGateway gateway = FakePaymentGateway.alwaysDeclined("card_declined");
RecordingNotifier notifier = new RecordingNotifier();
CheckoutMediator checkout = new CheckoutMediator(
new NoDiscount(), fraud, inventory, gateway, new RecordingRepository(), notifier);
PlacementResult result = checkout.place(order());
assertFalse(result.paid());
assertTrue(inventory.releaseCalled());
assertNull(notifier.lastOrder);
}
PercentOff tests still pass an Order and assert a BigDecimal — no Inventory. If a pricing test constructs FraudClient, the mesh has grown back inside the policy.
Mediator is not Facade, and not Observer
All three reduce coupling. They reduce different arrows.
| Facade | Mediator | Observer | |
|---|---|---|---|
| For whom | Outside callers (HTTP, mobile, CLI) | Colleagues that used to call each other | Dependents after a fact |
| Direction | One-way: caller → front → internals | Hub sequences; colleagues do not chat | Subject notifies; listeners do not coordinate |
| Internals | May still know each other | Must not know each other | Must not know each other, and must not order each other |
| In this lab | CheckoutFacade.place so controllers stop assembling | CheckoutMediator.place so PercentOff stops importing Inventory | OrderEvents.orderPaid so process stops naming Mailer |
If web and mobile copied screen → quote → charge, and the subsystem types never imported each other, you wanted Facade (or OrderProcessor). Relabeling it CheckoutMediator is ceremony.
If the only remaining pain is “SMS plus audit plus email after paid,” you wanted Observer. Listeners do not reserve stock for each other. A subscriber that calls inventory.release because another subscriber charged is a mediator hiding in a bus.
You can stack them honestly: Facade at the HTTP edge, Mediator if colleagues had a mesh, Observer for independent after-effects (EmailNotifier, AuditLog). Do not stack them because the catalog has three names.
When Mediator is the wrong move
Wave 3 is easy to over-apply. Skip the hub when:
- Two types should take a constructor parameter.
OrderProcessor(PaymentGateway, DiscountPolicy)is collaboration, not a mesh. ACheckoutMediatorwhose only job isdiscount.payablethengateway.chargeis a rename ofprocess(). Do not insert a hub between two ports that already meet in one method. - The colleagues never named each other. Controllers assembled the conversation;
PercentOffandStripeGatewaystayed ignorant. That is Facade’s trigger, already covered. AddingMediatorin the middle is a second front for the same one-way talk. - You are about to write a god mediator. Forty methods, every new feature lands in the hub, colleagues become empty bags. The mesh moved into one file; it did not disappear. Split conversations (
placevsrefund) before you split colleagues into a message protocol they will not keep consistent. - Observer (or a framework bus) already fans out independent reactions. Persist, then publish
order.paid. Do not make the mailer, the auditor, and the SMS client colleagues of a mediator unless they must negotiate (reserve vs release vs skip). Independent listeners are not colleagues. - A colleague needs to call the hub on every setter. GoF diagrams show
colleague.changed() → mediator. In this lab that is usually a cycle:Inventorytelling the mediator to notify, which tells inventory to confirm. Prefer the mediator calling out in a visible sequence over a web of callbacks.
The healthy trigger is a ticket you cannot place without editing two colleagues that already import each other. “Release on decline, skip mail on fraud” while StripeGateway owns notify and PercentOff owns stock — that is a mesh. “Pass PaymentGateway into OrderProcessor” is not.
Mediator also is not Chain of Responsibility. Chain is a line of handlers that may pass a request along; each handler does not orchestrate the rest. If fraud, inventory, and payment must all run, in order, with compensating release, that is a hub (or a workflow), not a chain that might stop after the first if.
Cheat sheet
Mediator CheckoutMediator.place owns the sequence; only type that knows the graph
Colleagues DiscountPolicy, Fraud, one job each; no imports of each other
Inventory, Gateway, Notifier, Orders
Callers HTTP / Facade map in; call place; map out
Trigger to apply: colleagues import each other and a ticket must edit two of them
Trigger to stop: two constructor params, or a facade over types that never chat
Scoreboard: new gateway = Adapter; protocol (release on decline) stays in the hub
Not Facade: hub for colleagues vs front for callers
Not Observer: coordinated protocol vs independent reactions after a fact
Do:
- Name the hub after the conversation (
CheckoutMediator,place), notCheckoutColleagueBus. - Strip imports: policies price, gateways charge, inventory reserves, notifiers notify.
- Test the protocol with fakes that do not reference each other; test
PercentOffwith anOrder. - Keep Observer for the paid tail if those listeners do not negotiate stock.
Don’t:
- Insert a mediator between
DiscountPolicyandPaymentGatewaywhenOrderProcessoralready takes both. - Let
PercentOffkeep anInventoryfield after the hub exists. - Rename
CheckoutFacadeto Mediator when the internals never called each other. - Hide a required sequence on an event bus so the design “matches Observer plus Mediator.”
Wrap-up
Mediator is a hub that owns a protocol so colleagues can stop naming each other. Checkout was expensive because discount asked inventory, the gateway asked fraud and the mailer, and a declined charge had no honest home. CheckoutMediator.place makes release-on-decline one method, keeps PercentOff a formula, keeps StripeGateway a capture, and leaves controllers talking to one verb. Two types that only needed a constructor argument still only need a constructor argument.
Wave 3 is the set that is easy to over-apply. The catalog on the map still says when the next name is worth a type.