PideYa's logistics is a three-way conversation that never stops: the kitchen finishes an order and looks for a courier; the courier completes a delivery and asks if anything is pending; an order has been waiting too long and must be reassigned. In the current code, kitchen, orders, and couriers know and call each other: a mesh where every class holds references to the others, and where adding a piece (here come the scooter couriers with cooler boxes!) forces you to touch everything. Mediator proposes redoing the topology: no colleague talks to another directly, and all coordination flows through one central object — turning the mesh into a star. And with the usual honesty: we'll also see how that star can degenerate into the dreaded god mediator.

Contents

  1. The problem in PideYa: the delivery mesh
  2. Mesh vs. star: the coupling arithmetic
  3. Intent and structure of the pattern
  4. Complete Java implementation: DispatchCenter
  5. The conversation over time
  6. Variants of the pattern
  7. The god-mediator risk
  8. When to use it and when not to
  9. Relationship with other patterns
  10. Common mistakes
  11. Exercises and conclusion

The problem in PideYa: the delivery mesh

The current state, summed up in three classes locked in an embrace:

public class Kitchen {
    private List<Courier> couriers;                  // knows the couriers

    public void orderFinished(Order order) {
        for (Courier c : couriers) {                 // and walks them itself
            if (c.isAvailable() && c.acceptsZone(order.getZone())) {
                c.assign(order);
                return;
            }
        }
        order.markWaitingForCourier();               // and decides what happens if there's none
    }
}

public class Courier {
    private Kitchen kitchen;                         // knows the kitchen

    public void deliveryCompleted() {
        this.available = true;
        Order pending = kitchen.anyOrderWaiting(this.zone);  // and asks it
        if (pending != null) {
            assign(pending);
        }
    }
}

The symptoms, one by one:

  • Everyone knows everyone: Kitchen holds couriers, Courier holds the kitchen, and the stuck-order monitor will know both. Each cross-reference is a compile-time dependency and a new/setter someone maintains.
  • The coordination logic is scattered: part of "how a courier gets assigned" lives in Kitchen, part in Courier. To understand the full protocol you must read every class; to change it, touch them all.
  • Reuse is impossible: a Courier can't be tested or reused without a real Kitchen hanging off it.
  • Growth hurts quadratically: adding the congestion monitor, or external couriers from a subcontractor, means new cross-references in the existing classes.

And note what the problem is not: each class does its individual job well (cooking, delivering). What's excessive is that they also carry the coordination protocol. That protocol needs a single owner.

Mesh vs. star: the coupling arithmetic

With n participants all collaborating with all, the mesh has up to n(n−1)/2 connections; the star, exactly n. With 4 colleagues: 6 versus 4; with 8: 28 versus 8. But more important than the count is who knows what: in the star, each colleague knows only the mediator — adding the fifth colleague touches none of the other four.

flowchart LR
    subgraph Mesh["Mesh: everyone knows everyone"]
        C1[Kitchen] --- R1[Couriers]
        C1 --- P1[Ready orders]
        C1 --- M1[Congestion monitor]
        R1 --- P1
        R1 --- M1
        P1 --- M1
    end
    subgraph Star["Star: everyone knows the mediator"]
        C2[Kitchen] --- X[DispatchCenter]
        R2[Couriers] --- X
        P2[Ready orders] --- X
        M2[Congestion monitor] --- X
    end

Intent and structure of the pattern

Intent (GoF): define an object that encapsulates how a set of objects interact. Mediator promotes loose coupling by keeping objects from referring to each other explicitly, and it lets you vary their interaction independently.

classDiagram
    class DispatchMediator {
        <<interface>>
        +orderReady(order: Order)
        +courierAvailable(c: Courier)
        +orderStuck(order: Order)
    }
    class DispatchCenter {
        -waiting: Queue~Order~
        -available: List~Courier~
        +orderReady(order: Order)
        +courierAvailable(c: Courier)
        +orderStuck(order: Order)
    }
    class Kitchen {
        -center: DispatchMediator
        +finishOrder(o: Order)
    }
    class Courier {
        -center: DispatchMediator
        +deliveryCompleted()
        +assign(o: Order)
    }

    DispatchMediator <|.. DispatchCenter
    Kitchen --> DispatchMediator : notifies
    Courier --> DispatchMediator : notifies
    DispatchCenter --> Courier : coordinates
    DispatchCenter ..> Kitchen : coordinates
GoF role In PideYa
Mediator (coordination interface) DispatchMediator
ConcreteMediator (the entire protocol) DispatchCenter
Colleague (participants who know only the mediator) Kitchen, Courier (and tomorrow CongestionMonitor, ExternalCourier...)

The golden rule that defines the pattern: colleagues never reference each other. A colleague does its local work and, when something relevant to others happens, tells the mediator; the mediator decides whose turn it is to react and gives the order.

Complete Java implementation: DispatchCenter

The mediator interface. Its methods are the events the colleagues report — name them after what happened, not after what should be done (the mediator decides that):

public interface DispatchMediator {
    void orderReady(Order order);
    void courierAvailable(Courier courier);
    void orderStuck(Order order);
}

The colleagues, now lightweight: they do their thing and report. Courier no longer knows Kitchen; Kitchen no longer walks the couriers:

public class Kitchen {

    private final DispatchMediator center;

    public Kitchen(DispatchMediator center) {          // injected, as always (DIP)
        this.center = center;
    }

    public void finishOrder(Order order) {
        order.markReadyForPickup();                    // its own work
        center.orderReady(order);                      // tell the mediator; done
    }
}

public class Courier {

    private final DispatchMediator center;
    private final String zone;
    private boolean available = true;

    public Courier(DispatchMediator center, String zone) {
        this.center = center;
        this.zone = zone;
    }

    public void deliveryCompleted() {
        this.available = true;                         // its own work
        center.courierAvailable(this);                 // report; no idea what happens next
    }

    /** Order it receives FROM the mediator. */
    public void assign(Order order) {
        this.available = false;
        this.currentOrder = order;
        // pick up, navigate, etc.
    }

    public boolean isAvailable() { return available; }
    public boolean acceptsZone(String z) { return zone.equals(z); }
}

The concrete mediator: here, and only here, lives the complete coordination protocol — readable top to bottom in one class:

public class DispatchCenter implements DispatchMediator {

    private final Queue<Order> waiting = new ArrayDeque<>();
    private final List<Courier> fleet = new ArrayList<>();

    public void registerCourier(Courier c) {
        fleet.add(c);
    }

    @Override
    public void orderReady(Order order) {
        Optional<Courier> candidate = findAvailable(order.getZone());
        if (candidate.isPresent()) {
            candidate.get().assign(order);
        } else {
            waiting.add(order);                        // the protocol decides what to do
            EventLog.INSTANCE.info("Order " + order.getId() + " waiting");
        }
    }

    @Override
    public void courierAvailable(Courier courier) {
        waiting.stream()
                .filter(o -> courier.acceptsZone(o.getZone()))
                .findFirst()
                .ifPresent(o -> {
                    waiting.remove(o);
                    courier.assign(o);
                });
    }

    @Override
    public void orderStuck(Order order) {
        // reassignment: back to the queue with priority, alert support...
        waiting.add(order);
    }

    private Optional<Courier> findAvailable(String zone) {
        return fleet.stream()
                .filter(Courier::isAvailable)
                .filter(c -> c.acceptsZone(zone))
                .findFirst();
    }
}

Look at what was gained:

  • The protocol is a single text: "if there's a courier in the zone, assign; if not, queue; when someone frees up, check the queue". Before, it was scattered across three classes.
  • Changing the assignment criterion ("the nearest" instead of "the first available") touches one line of one class. In fact, that criterion is a family of interchangeable algorithms... exactly what Strategy will reify in its lesson — the mediator will be its perfect client.
  • Testing Courier is now trivial: inject a fake DispatchMediator and done.
  • Adding a new colleague (the CongestionMonitor that detects long waits and calls orderStuck) touches neither Kitchen nor Courier.

The conversation over time

The same scenario from the beginning — a courier frees up and an order is waiting — now as a star:

sequenceDiagram
    participant K as Kitchen
    participant C as DispatchCenter
    participant R as Courier (María)
    K->>C: orderReady(order 88)
    C->>C: anyone available in zone? no → queue it
    Note over R: María finishes another delivery
    R->>C: courierAvailable(María)
    C->>C: anything waiting for her zone? yes: order 88
    C->>R: assign(order 88)
    Note over K,R: Kitchen and María never spoke

Variants of the pattern

  • With or without a Mediator interface: if there will only ever be one center, GoF allows skipping the interface and using the concrete class. We keep it for testability (fake mediators in the colleagues' tests) and because an ExternalDispatchCenter (subcontractor) is plausible.
  • How does the colleague notify the mediator? Our version uses specific methods (orderReady(...)), clear and typed. The alternative is a single generic method (notify(colleague, "ORDER_READY", data)) — more flexible, less safe; the moment you see it, you're one step from an event bus (a mention at Observer's doorstep and a full treatment in module 6).
  • UI mediator: GoF's canonical example is a dialog whose widgets (button, list, text field) coordinate through the dialog — "checking the box enables the button". If you've ever written a screen controller that coordinates its components, you've written a mediator.

The god-mediator risk

The classic criticism of the pattern, and it's fair: the mediator concentrates the coupling instead of eliminating it. That's good (it's localized, readable, changeable) until it isn't: the center that also computes routes, applies bonuses, decides shifts, and sends notifications becomes a god object — the system's biggest blob of responsibility, untouchable out of fear and a bottleneck for every change. Warning signs and antidotes:

  • The mediator does domain work instead of coordinating → extract that work to the colleagues or to services (SRP: the mediator coordinates, the colleagues work).
  • The mediator grows methods that only interest one pair of colleagues → maybe that pair deserves to talk directly, or to get its own small mediator.
  • The algorithms inside the mediator vary → extract them as strategies (Strategy) and leave the mediator the orchestration.

The honest version of the contract: Mediator doesn't reduce the protocol's complexity — it moves it to a single place. If the protocol is intrinsically huge, the mediator will be big; the pattern owes you order, not miracles.

When to use it and when not to

Use it when:

  • A set of objects collaborates in complex, changing ways, and the cross-references keep multiplying.
  • You want to reuse or test the colleagues on their own, without dragging their interlocutors along.
  • The coordination protocol deserves to be a named piece of its own, readable and modifiable in one go.

Avoid it when:

  • There are only two collaborators with a simple relationship: a direct call (or an Observer) suffices; a mediator between two is bureaucracy.
  • The "protocol" is trivial and stable: a two-thread mesh doesn't hurt.
  • You'd be building the god mediator from day one: if the class is born with twenty responsibilities, the problem is domain partitioning, not topology.

Relationship with other patterns

  • Observer: the other way to decouple communication — and the module's most frequent confusion. A one-line preview (full head-to-head in the comparison): Observer broadcasts "X happened" to whoever wants to hear it, with no central protocol; Mediator directs a protocol with known roles. Also, a mediator can be implemented by listening to its colleagues as an observer.
  • Facade: superficially similar (one object in front of several). The facade offers a simplified one-way interface to a subsystem that doesn't know it; the mediator holds a two-way dialogue with colleagues who do know it.
  • Strategy: the mediator's variable criteria (courier assignment) get extracted as strategies.
  • Singleton: the temptation to make DispatchCenter a global singleton; better to inject it, for the reasons we already suffered in module 2.

Common mistakes

  • Colleagues keeping references to each other "just for this case": the first cross-reference reopens the mesh; by the third, you have a mesh and a mediator (the worst of both worlds).
  • The mediator that works: if DispatchCenter computes the route or charges for the delivery, it has stopped coordinating. Coordinating is deciding who and when; the how belongs to the colleagues.
  • Notification cycles: the mediator orders a colleague, the colleague notifies the mediator, which orders again... Protect yourself by distinguishing "event report" (colleague → mediator) from "order" (mediator → colleague) and don't report events from inside received orders.
  • A mediator interface that mirrors one implementation: methods like assignToFirstAvailable(...) bake the protocol into the interface; name them after events (orderReady) so you can change the policy without touching the colleagues.
  • Turning it into a junk drawer: "since everything passes through here, let's also put in the logging, the cache, and the metrics". Every new tenant brings the god mediator closer.

Exercises

Exercise 1: a new colleague without touching the old ones

Implement CongestionMonitor: every minute it reviews the waiting orders and, if any has waited more than 10 minutes, calls center.orderStuck(order). The center must then also notify support using the Notifier from module 2. State which classes get created, which get modified, and which remain untouched.

Exercise 2: mesh → star

Draw (or describe) the dependencies of these four classes before and after applying Mediator: Kitchen, Courier, TrackingScreen (shows the customer where their order is), and DispatchCenter. How many class-to-class references are there in each version?

Exercise 3: spot the god

A year later, DispatchCenter has 1,800 lines: it assigns couriers, computes routes with traffic, applies the rain bonus, manages shifts, and sends the push notifications. Propose a split: what stays in the mediator and where does everything else go? (Name previously seen patterns where they apply.)

Solutions

Solution 1: you create CongestionMonitor (a new colleague that knows only DispatchMediator); you modify only DispatchCenter (the body of orderStuck adds supportNotifier.send(...), with the notifier injected in its constructor); Kitchen, Courier, and the DispatchMediator interface remain untouched (the method already existed). That is the star's dividend: growing without touching the neighbors.

Solution 2: before (mesh): Kitchen→Courier, Courier→Kitchen, TrackingScreen→Kitchen, TrackingScreen→Courier — 4 references (and growing quadratically with every colleague). After (star): Kitchen→Mediator, Courier→Mediator, TrackingScreen→Mediator, and the mediator knows its colleagues — the complete coordination sits in 1 class and each colleague has exactly 1 dependency, testable with a fake mediator.

Solution 3: only the orchestration stays in DispatchCenter: receiving events, consulting the pieces, and ordering assignments. Out go: route computation to a RoutingService (a colleague/domain service); the assignment criterion and the rain bonus to interchangeable strategies (Strategy) that the center uses; shift management to its own ShiftManager (the center asks it for availability); the push notifications to the existing Notifier classes, invoked by the center but implemented outside — or better yet, triggered by the order's status changes via Observer, which is precisely the lesson after next.

Conclusion

Mediator reorders the collaboration's topology: from the mesh where kitchen, orders, and couriers all knew each other, to the star where each colleague knows only DispatchCenter and the coordination protocol lives whole, readable, and changeable, in a single place. You've seen the coupling arithmetic (n versus n²), the conversation in sequence, its variants, and the warning that always accompanies it: the mediator concentrates coupling — watch that it doesn't bloat into a god mediator, extracting work to colleagues and algorithms to strategies.

The next pattern changes the scene but not the family: from delivery to the customer's cart. Editing the cart means experimenting — add this, remove that, apply a coupon — and the customer wants to be able to go back without the cart exposing its guts for someone to photograph. Saving and restoring state without breaking encapsulation: that delicate balance is exactly Memento's specialty.

© Copyright 2026. All rights reserved