Mediator Pattern: Centralizing Peer-to-Peer Communication
How to untangle a mesh of objects that all talk directly to each other by routing their communication through a single mediator.
Mediator Pattern
"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 lets you vary their interaction independently." When objects need to coordinate — a chat room's participants, a dialog box's form controls, an air traffic control tower's aircraft — the naive approach has every object hold references to every other object it needs to talk to. That produces an N×N web of dependencies that gets worse with every new participant. The Mediator pattern replaces the mesh with a hub: every object talks only to the mediator, and the mediator decides who else needs to know.
This guide is the companion to State Pattern — both patterns centralize logic that would otherwise be smeared across many places, State across an object's lifecycle, Mediator across a set of peers.
1. The Problem: Direct Peer-to-Peer References
Consider a chat room where each participant needs to broadcast messages to every other participant, and a moderator can mute anyone.
// VIOLATION: every Participant holds direct references to every other Participant
class Participant {
private final String name;
private final List<Participant> peers = new ArrayList<>();
private boolean muted = false;
Participant(String name) { this.name = name; }
void addPeer(Participant peer) { peers.add(peer); }
void send(String message) {
if (muted) return;
for (Participant peer : peers) {
peer.receive(this.name, message); // direct call to every known peer
}
}
void receive(String from, String message) {
System.out.println("[" + name + "] " + from + ": " + message);
}
void mute() { this.muted = true; }
}
// Wiring N participants requires N*(N-1) addPeer() calls, done somewhere central
// anyway — the "decentralization" is an illusion, just without a name for the hub.
Participant alice = new Participant("Alice");
Participant bob = new Participant("Bob");
Participant carol = new Participant("Carol");
alice.addPeer(bob); alice.addPeer(carol);
bob.addPeer(alice); bob.addPeer(carol);
carol.addPeer(alice); carol.addPeer(bob);The problems compound as the room grows:
- Adding a participant means updating every existing participant's peer list.
- Adding cross-cutting behavior (rate limiting, profanity filtering, "muted users don't receive DMs") means editing
Participantitself, and that logic is duplicated ifsend()exists in multiple places. - Testing a single
Participantin isolation requires constructing a whole mesh of fake peers. - This is the same coupling problem the Coupling & Cohesion guide describes: every
Participantis coupled to every other concreteParticipant, when what it actually needs is coupling to one abstraction — "somewhere to send a message."
2. Structure
- Mediator — an interface declaring how colleagues communicate through it (
sendMessage(sender, message),mute(participant)). - Concrete Mediator — holds references to all colleagues and implements the coordination logic (who receives what, in what order, under what conditions).
- Colleague — no longer holds references to other colleagues; holds one reference to the mediator and calls it instead.
3. Full Java Implementation
// Mediator interface — the only thing a Colleague is allowed to know about
interface ChatMediator {
void register(Participant participant);
void sendMessage(Participant sender, String message);
void mute(Participant participant);
}
// Colleague — holds a mediator reference, never a peer reference
abstract class Participant {
protected final ChatMediator mediator;
protected final String name;
Participant(ChatMediator mediator, String name) {
this.mediator = mediator;
this.name = name;
mediator.register(this);
}
void send(String message) {
mediator.sendMessage(this, message); // delegates to the hub, not to peers
}
abstract void receive(String from, String message);
String getName() { return name; }
}
class UserParticipant extends Participant {
UserParticipant(ChatMediator mediator, String name) { super(mediator, name); }
@Override
void receive(String from, String message) {
System.out.println("[" + name + "] " + from + ": " + message);
}
}
class BotParticipant extends Participant {
BotParticipant(ChatMediator mediator, String name) { super(mediator, name); }
@Override
void receive(String from, String message) {
if (message.contains("help")) {
mediator.sendMessage(this, "Try /faq for common questions.");
}
}
}
// Concrete Mediator — owns ALL coordination logic in one place
class ChatRoom implements ChatMediator {
private final List<Participant> participants = new ArrayList<>();
private final Set<Participant> muted = new HashSet<>();
@Override
public void register(Participant participant) {
participants.add(participant);
}
@Override
public void mute(Participant participant) {
muted.add(participant);
}
@Override
public void sendMessage(Participant sender, String message) {
if (muted.contains(sender)) {
return; // cross-cutting rule lives in exactly one place
}
for (Participant p : participants) {
if (p != sender) {
p.receive(sender.getName(), message);
}
}
}
}// Usage — participants never reference each other
ChatMediator room = new ChatRoom();
Participant alice = new UserParticipant(room, "Alice");
Participant bob = new UserParticipant(room, "Bob");
Participant faqBot = new BotParticipant(room, "FaqBot");
alice.send("Hi everyone!"); // Bob and FaqBot receive it; Alice does not
bob.send("I need help please"); // FaqBot reacts by sending its own message via the mediator
room.mute(bob);
bob.send("Can anyone hear me?"); // dropped centrally — no participant-side check neededAdding a fourth participant means one new UserParticipant(room, "Dave") call — zero edits to Alice, Bob, or ChatRoom's existing logic. Adding a new cross-cutting rule (rate limiting, profanity filter) means editing sendMessage() in exactly one class instead of hunting through every colleague.
The mediator is often the natural home for coordination policy that doesn't obviously belong to any single colleague — ordering, filtering, conflict resolution. If you find yourself asking "which participant's job is this rule?", the honest answer is usually "none of them — it's the room's rule," which is the mediator's job.
Over-applying Mediator: for two objects that only ever interact with each other (not a group), a direct reference is simpler and the mediator is an unnecessary indirection. Mediator earns its keep once you have three or more colleagues whose interactions would otherwise form a mesh.
4. When to Use vs. When It's Overkill
| Use Mediator when... | It's overkill when... |
|---|---|
| Many objects need to communicate and direct references would form an N×N mesh | Only two objects ever talk to each other |
| Cross-cutting coordination rules (ordering, filtering, conflict resolution) don't belong to any single participant | The "coordination" is a single, trivial pass-through with no policy |
| You want to add/remove participants without touching existing ones | The set of participants is fixed and will never grow |
| UI widgets (form fields, buttons) need to react to each other's state without knowing about each other | The interactions are a simple, fixed pipeline better expressed as a function chain |
| You want a single place to unit-test all interaction rules | The mediator itself would become an unmanageable god object doing unrelated things (watch for SRP violations creeping back in through the hub) |
5. Mediator vs. Observer
Both patterns reduce direct coupling between objects, and Mediator is frequently implemented using Observer internally (colleagues subscribe to mediator events) — but they solve different shaped problems:
| Mediator | Observer | |
|---|---|---|
| Direction | Bidirectional — colleagues both send to and receive from the hub | Unidirectional — subject notifies observers; observers don't talk back through the same channel |
| Who knows whom | Mediator knows all colleagues; colleagues know only the mediator | Subject knows its observers (via a list); observers know the subject, not each other |
| Purpose | Coordinate a group of peers with potentially complex, stateful interaction rules | Broadcast "something happened" to any number of interested, decoupled listeners |
| Typical shape | Hub-and-spoke, request/response-like | Publish/notify, fire-and-forget |
// Observer: subject just announces; it doesn't orchestrate replies
orderService.onOrderPlaced(order -> inventoryService.reserve(order));
orderService.onOrderPlaced(order -> emailService.sendConfirmation(order));
// InventoryService and EmailService never coordinate with each other
// Mediator: the hub actively coordinates multi-way interaction with policy
chatRoom.sendMessage(alice, "help"); // hub decides who gets it, checks mute list,
// may trigger a bot reply back through itselfA rule of thumb: if the "listeners" never need to know about each other or respond back into the same conversation, Observer is enough. If participants are having something closer to a genuine conversation — replies, mutual awareness mediated centrally, coordination policy — reach for Mediator.
6. Mediator vs. Facade
These are also commonly confused because both sit "in front of" a group of objects, but their intent is opposite in one key way:
| Mediator | Facade | |
|---|---|---|
| What it coordinates | Peers that are aware they're part of a group and communicate through it | An external client that wants a simple entry point into a subsystem |
| Do participants know about the coordinator? | Yes — colleagues hold a reference to the mediator and call it | Not necessarily — the subsystem classes are usually unaware a Facade exists in front of them |
| Direction of new behavior | New interaction rules are added inside the mediator | New simplified operations are added inside the facade; the subsystem is unchanged |
| Typical use | Chat rooms, air traffic control, UI dialog coordination | OrderFacade.checkout() wrapping InventoryService, PaymentService, ShippingService |
Put simply: Facade simplifies a subsystem for outsiders; Mediator coordinates insiders. A Facade could internally use a Mediator, but they answer different questions — "how do I call this subsystem easily?" versus "how do these peers talk to each other without a mesh of references?"
7. Testing Colleagues in Isolation
Because colleagues depend on a ChatMediator interface, not a concrete ChatRoom, each colleague is trivially testable with a lightweight fake mediator instead of standing up an entire room of peers:
class RecordingMediator implements ChatMediator {
final List<String> sentMessages = new ArrayList<>();
final List<Participant> registered = new ArrayList<>();
public void register(Participant p) { registered.add(p); }
public void mute(Participant p) { /* not exercised in this test */ }
public void sendMessage(Participant sender, String message) {
sentMessages.add(sender.getName() + ": " + message);
}
}
class BotParticipantTest {
@Test
void botRepliesWithFaqHintWhenAskedForHelp() {
RecordingMediator mediator = new RecordingMediator();
BotParticipant bot = new BotParticipant(mediator, "FaqBot");
bot.receive("Alice", "I need help please");
assertTrue(mediator.sentMessages.stream()
.anyMatch(m -> m.contains("Try /faq")));
}
}Testing ChatRoom itself is just as clean — construct it with a handful of real or fake Participants and assert on the mute/broadcast policy directly, without needing a BotParticipant's FAQ logic to be correct at the same time. This separation is a direct consequence of colleagues depending on an abstraction (ChatMediator) rather than on each other — the same testability benefit the SOLID Principles guide describes under Dependency Inversion.
8. Common Pitfalls
| Pitfall | Why it happens | Fix |
|---|---|---|
| Mediator becomes a god object | Every new cross-cutting rule gets bolted onto the same ChatRoom class, until it's doing rate limiting, profanity filtering, analytics, and persistence all at once | Split by responsibility: a ChatRoom mediator for message routing, a separate ModerationPolicy it delegates to for muting/filtering rules |
| Mediator knows too much about concrete colleague types | ChatRoom.sendMessage() starts doing if (p instanceof BotParticipant) to special-case bot behavior | Push type-specific behavior back into the colleague's own receive() override — the mediator should treat all colleagues uniformly through the shared interface |
| Circular mediator chatter | A colleague's receive() immediately calls mediator.sendMessage() again in response, which can trigger another receive(), forming an infinite loop | Guard against reentrancy explicitly (e.g. a "currently broadcasting" flag) or design reactive colleagues (like bots) to reply asynchronously, not inline |
| Registering colleagues without unregistering them | Participants who disconnect are never removed from the mediator's internal list, causing it to grow unbounded and to keep "notifying" dead objects | Provide a symmetric unregister(Participant) and call it from wherever disconnect/cleanup happens |
| Treating any shared-dependency class as a "mediator" | A Logger or Config object injected into many classes gets mislabeled as a Mediator just because many classes depend on it | Mediator specifically coordinates interaction between peers; a passive shared utility with no coordination logic is just a shared dependency, not this pattern |
9. Mediator-Shaped Coordination in Spring
Spring's ApplicationEventPublisher is a framework-provided mediator: beans publish events instead of holding direct references to every other bean that might care, and the ApplicationContext itself plays the role of the mediator that routes events to listeners.
// Event — the "message" being routed through the mediator
class OrderPlacedEvent {
private final String orderId;
OrderPlacedEvent(String orderId) { this.orderId = orderId; }
String getOrderId() { return orderId; }
}
// Colleague #1 — publishes through the mediator (ApplicationEventPublisher),
// never calls InventoryService or EmailService directly
@Service
class OrderService {
private final ApplicationEventPublisher publisher;
OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; }
void placeOrder(String orderId) {
// ... persist order ...
publisher.publishEvent(new OrderPlacedEvent(orderId));
}
}
// Colleague #2 — reacts without OrderService knowing it exists
@Component
class InventoryReservationListener {
@EventListener
void onOrderPlaced(OrderPlacedEvent event) {
System.out.println("Reserving inventory for " + event.getOrderId());
}
}
// Colleague #3 — same event, completely independent reaction
@Component
class OrderConfirmationEmailListener {
@EventListener
void onOrderPlaced(OrderPlacedEvent event) {
System.out.println("Emailing confirmation for " + event.getOrderId());
}
}OrderService never references InventoryReservationListener or OrderConfirmationEmailListener — Spring's ApplicationContext is the mediator that knows about all registered listeners and routes the event to each. This is worth contrasting with the earlier Mediator vs. Observer discussion: this specific Spring example is closer to Observer in shape (one-directional broadcast, listeners don't reply back through the same channel) — it's the routing infrastructure itself (the ApplicationContext) that structurally plays a mediator role, even though the publish/subscribe usage pattern here reads as Observer. A genuinely Mediator-shaped Spring use case looks more like a @Service that explicitly coordinates several other injected services' calls in a specific sequence with shared decision logic — the event bus is the coordination mechanism, but not every use of it rises to full Mediator-style bidirectional coordination.
10. Real-World Examples
- Air traffic control tower — the textbook example. Aircraft never coordinate directly with each other; every communication goes through the tower, which knows the full picture and can enforce sequencing/priority rules.
- UI dialog/form coordination — a "Submit" button that should enable only when three other fields are valid is classic Mediator territory: each field notifies a
FormMediator, which decides the button's enabled state, rather than fields checking each other directly. - Chat/messaging platforms (Slack channels, Discord servers) — a channel or server object is the mediator between all connected clients.
- Spring's
ApplicationEventPublisher/ message brokers (Kafka, RabbitMQ) — at a coarser, distributed-systems grain, a message broker is a Mediator between producers and consumers who never reference each other directly. - Air traffic-style workflow orchestrators — Saga orchestrators in microservices (as opposed to choreography) are Mediator applied to service-to-service coordination: services report to the orchestrator instead of calling each other in a mesh.
Interview Questions
- Describe the N×N coupling problem that Mediator solves. How does the number of relationships grow with and without a mediator as participants are added?
- Walk through converting a set of tightly-coupled peer objects (e.g. UI form fields) into a Mediator-based design.
- How is Mediator different from Observer, given that both reduce direct references between objects? Give an example where Observer would be a poor fit but Mediator would work well.
- How is Mediator different from Facade? Can you have a class that is legitimately both?
- What's the risk of a Mediator becoming a "god object," and how does that risk map back to the Single Responsibility Principle?
- In a chat room implemented with Mediator, where does a cross-cutting rule like "muted users can't send messages" belong, and why is that the right place for it?
- Would you implement Mediator using Observer internally? What would that look like, and what would you lose or gain?
- When is a direct reference between two objects preferable to introducing a mediator?