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.

PartIn this labJob
ColleagueDiscountPolicy, FraudClient, Inventory, PaymentGateway, OrderNotifier, OrderRepositoryOne job. Does not import the others.
MediatorCheckoutMediatorKnows 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.

FacadeMediatorObserver
For whomOutside callers (HTTP, mobile, CLI)Colleagues that used to call each otherDependents after a fact
DirectionOne-way: caller → front → internalsHub sequences; colleagues do not chatSubject notifies; listeners do not coordinate
InternalsMay still know each otherMust not know each otherMust not know each other, and must not order each other
In this labCheckoutFacade.place so controllers stop assemblingCheckoutMediator.place so PercentOff stops importing InventoryOrderEvents.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. A CheckoutMediator whose only job is discount.payable then gateway.charge is a rename of process(). Do not insert a hub between two ports that already meet in one method.
  • The colleagues never named each other. Controllers assembled the conversation; PercentOff and StripeGateway stayed ignorant. That is Facade’s trigger, already covered. Adding Mediator in 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 (place vs refund) 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: Inventory telling 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), not CheckoutColleagueBus.
  • Strip imports: policies price, gateways charge, inventory reserves, notifiers notify.
  • Test the protocol with fakes that do not reference each other; test PercentOff with an Order.
  • Keep Observer for the paid tail if those listeners do not negotiate stock.

Don’t:

  • Insert a mediator between DiscountPolicy and PaymentGateway when OrderProcessor already takes both.
  • Let PercentOff keep an Inventory field after the hub exists.
  • Rename CheckoutFacade to 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.

Next optional step in the series Undo a draft Order without exposing private fields. Memento: Snapshot State Without Breaking Encapsulation