Tangled Communication Logic? You Don’t Need Smarter Objects — You Need a Mediator
Previously, in How Flyweight Helps When Your App Is Drowning in Objects, we tackled object overload by sharing and reusing data. The Mediator pattern, by contrast, addresses a different kind of overload — when your code has too many objects talking to each other, creating a communication chaos.
What is the Mediator Pattern?
The Mediator (also known as Intermediary or Controller) is a behavioral design pattern that centralizes communication between objects. Instead of objects referring to and updating each other directly in a tangled web of references, they collaborate indirectly through a special mediator object. In essence, the mediator acts as a hub for messages: any object (often called a colleague in this pattern) that needs to communicate with another object will send a message to the mediator, and the mediator routes it to the appropriate destination. By doing so, the pattern reduces chaotic many-to-many dependencies and simplifies relationships into a more manageable one-to-many schema.
Imagine you have five components in a program and each one might need to interact with all the others. Without a mediator, each component would hold references to the other four and call them as needed — that’s potentially 10 separate direct connections (a fully connected mesh). With a mediator, each component only knows about one object (the mediator itself) and has zero direct references to the other components. All communication flows through the mediator. This dramatically cuts down the number of interconnections, making the system architecture easier to understand and maintain. The colleagues become oblivious of each other’s existence; they only know about the mediator. From any single component’s perspective, it’s like dropping a message into a mailbox and not caring which specific colleague eventually picks it up — the mediator will handle that decision.
Under the hood, implementing a mediator typically involves:
- Defining a Mediator interface (or abstract class) with methods for communication (e.g., a
notify
orsend
method). - Each colleague class holds a reference to the mediator (often set via constructor or setter). When something noteworthy happens inside a colleague, it notifies the mediator instead of calling another colleague directly.
- The Concrete Mediator class knows about all the colleague objects (it may maintain references or a list of colleagues). It implements the communication logic inside the mediator method. For example, if Component A sends a “X happened” event, the mediator might know that means Component B and C should react, and will call their methods accordingly.
By restricting direct communications and funneling interactions through one place, the Mediator pattern achieves its goal: colleagues become simpler and more reusable because they aren’t tightly coupled to lots of peers. The complex interaction logic is encapsulated in the mediator. As a result, you can change how components interact by modifying only the mediator, without ripping through every class.
Real-World Analogy
Design patterns often click when you relate them to real life. Think of a busy theater production with actors, lighting technicians, sound engineers, stagehands, and directors. If every actor had to coordinate directly with every crew member (“dim the lights when I finish this line”, “play the thunder sound at my cue”), it would be chaotic. Instead, productions employ a stage manager — a person who acts as a central coordinator. The stage manager receives signals or requests from anyone (“Actor: I’m ready for scene 2” or “Lighting: The spotlight is overheating”) and then relays instructions to the appropriate parties (“Lighting crew: adjust the spotlight”, “Sound: prepare the thunder effect”). Here, the stage manager is the mediator. All cast and crew communicate through this one role. This setup prevents a tangled mess of everyone yelling instructions at everyone else. Each participant focuses on their job and trusts the stage manager to handle the coordination. In the same way, a Mediator object in code directs the traffic among colleagues, so that no two colleague objects need direct connections.
Pros of the Mediator Pattern
Using the Mediator pattern in the right situation can yield several benefits:
Reduced Coupling Between Components
Colleagues no longer need to know the internals of each other. Each object talks to the mediator, not directly to other objects. This loose coupling makes it easier to refactor or replace one component without breaking others. You can add or remove colleagues with minimal impact on the rest of the system, since the interaction logic is centralized in the mediator.
Simplified Communication Logic
All the complex interaction rules are centralized in one place (the mediator). This can make the system’s behavior easier to understand. Instead of tracing a chain of method calls across many classes, you can look at the mediator to see how an event from one object will affect others. This aligns with the Single Responsibility Principle by giving the mediator the sole responsibility of coordinating interactions, and it keeps the individual colleagues focused on their own jobs.
Improved Reusability of Components
Because colleagues are no longer tightly intertwined, you can often reuse them in different contexts. For example, a UI component (say, a checkbox) written to work with a mediator doesn’t hardcode references to specific other UI components. You could reuse that checkbox in a different dialog that has a different mediator. The colleagues depend only on the mediator interface, so as long as you provide a new mediator that implements the expected interface, the same colleague class can function in a new system.
Centralized Control Point
Having a single point of control can be very handy for certain cross-cutting concerns. For instance, you could add logging, debugging, or scheduling logic in the mediator without touching the colleagues. It’s easier to enforce global policies (e.g., “only one action happens at a time” or “these two operations must never occur simultaneously”) in a mediator because it sees the big picture of interactions.
Flexibility in Changing Interactions
If the requirements for how objects should interact change, you typically only need to update the mediator’s code. You won’t have to dive into multiple classes to tweak their interplay. This also means you can develop alternative mediator implementations to orchestrate the same set of components in different ways (following the Open/Closed Principle). For example, you might swap in a different mediator to change an app’s workflow without altering the components themselves.
Cons of the Mediator Pattern (and Solutions)
Like all design patterns, Mediator introduces its own challenges that you should be aware of, along with ways to mitigate them:
Mediator Can Become a “God Object”
Since the mediator centralizes all coordination, it can grow unwieldy as it takes on more and more responsibilities. If not kept in check, a mediator might start handling too much logic and effectively become an omniscient “god class”. This is problematic because it concentrates too much knowledge in one place, which can be hard to maintain.
Be disciplined about the mediator’s scope. If a mediator starts getting too complex, consider refactoring by splitting it into multiple smaller mediators that handle different clusters of colleagues. For example, you might have one mediator for coordinating UI components and another for coordinating backend processes, rather than one mega-mediator for everything. Also, adhere to high-level guidelines or use cases — the mediator should coordinate, not necessarily implement all the business logic itself.
Added Indirection (Slight Complexity Overhead)
Introducing a mediator means there’s an extra layer of indirection in communications. This can make the code a bit harder to follow at first, since you can’t see direct links between components. Debugging is also slightly more complex: instead of jumping from one object to the directly called object, you need to check the mediator’s logic to understand the flow.
Good naming and documentation can help here — if the mediator’s methods clearly describe the event being handled (e.g., notify(sender: Event)
with descriptive event types), it’s easier to trace what's happening. For debugging, you can instrument the mediator to log interactions (“Component X requested Y – mediator sending to Z”), which makes it clear how messages are moving. Over time, developers get used to the mediated flow, and the clarity at the architecture level often outweighs the initial indirection.
Potential Performance Impact
Because every communication goes through an extra object (the mediator), there could be a minor performance hit compared to direct calls. In most cases this overhead is negligible (a function call or message dispatch), but in performance-critical inner loops or real-time systems it could add up.
If you find that mediator coordination is a bottleneck, you might need to optimize by either simplifying the mediator’s work or, in extreme cases, bypassing the mediator for very low-level, frequent interactions. However, this is rare — usually the clarity and decoupling benefits trump the tiny cost of an extra method call.
Colleague–Mediator Coupling
It’s true that Mediator reduces coupling between colleagues, but note that colleagues are still coupled to the mediator. They must be aware of the mediator interface (often even hold a reference to the mediator). This is a form of coupling as well — essentially, you trade many small couplings for one bigger coupling. If you change the mediator interface, all colleagues may need updating.
To minimize impact, define a clear mediator interface (abstract class or protocol) that doesn’t change often. Colleagues depend on that interface, not a concrete implementation. This way, you could swap out one mediator for another (say, a GUIInteractionMediator
for a CLIInteractionMediator
) without breaking colleagues, as long as the interface is consistent. In strongly typed languages like Swift, using a protocol for the mediator helps here. Also, in languages that support dependency injection, you can inject a different mediator implementation to vary behavior.
Memory Management Considerations
In some languages, having objects refer to a mediator and the mediator referring back to those objects can create reference cycles that need careful handling. For example, in Swift (which uses ARC for memory), if each colleague has a strong reference to the mediator and the mediator has strong references to each colleague, you get a retain cycle where nothing gets deallocated.
The common practice is to use weak references for one side of the connection. Often, colleagues hold a weak reference to the mediator (since typically the mediator is more permanent or at least managed from above). In our Swift example below, we’ll use weak
for the mediator reference inside each device to avoid retain cycles. In other environments, you might use smart pointers or simply be cautious to break the connection when it’s no longer needed.
Limitations and When Not to Use Mediator
While we’ve covered some cons as general downsides, it’s also important to recognize the limitations of the Mediator pattern — i.e. cases or contexts where Mediator might not be the best choice:
Not Needed for Simple Interactions
If your system only has a few components with well-defined, simple interactions, a mediator can be unnecessary ceremony. For instance, two classes that call each other in one or two places don’t warrant an entire mediator object between them. In such cases, the direct communication is clear and easy to maintain, and adding a mediator would just complicate things.
Insufficient for One-to-Many Event Distribution
The Mediator is great for centralizing logic, but if you have a scenario where one object’s state change needs to notify an arbitrary number of other objects, the Observer pattern (or a pub-sub event bus) might fit more naturally. Mediator tends to work well when the set of colleagues is relatively fixed or known, and the mediator contains the routing logic. If you need truly dynamic subscription to events (where objects come and go or subscribe to different events at runtime), a pure Observer pattern is more straightforward. (That said, you can implement a mediator that uses an event-bus under the hood — but at that point, the mediator is essentially acting like a publisher in an observer system.)
Scaling Concerns
As the number of colleagues grows very large, a single mediator might become a bottleneck — both in terms of performance and in development (many people editing the same mediator code). If hundreds of objects all funnel through one mediator, the mediator could be doing an awful lot. In such large-scale systems, you might consider splitting responsibilities (as mentioned in mitigations) or using more decentralized patterns.
Centralized Complexity
Mediator rearranges complexity, it doesn’t eliminate it. If the interactions between components are inherently complex with lots of conditional logic, the mediator will also be complex. You’ve moved the complexity into one place (which is good for understanding the big picture) but the overall system is still as complex as the problem it’s solving. This is a limitation in the sense that Mediator can simplify relationships but it can’t magically make a hard problem easy. You still have to carefully design the mediator’s logic. Keep this in mind: if the logic is too convoluted even after introducing a mediator, you might need to rethink the system at a higher level or combine patterns.
When to Use the Mediator Pattern
Given the pros, cons, and limitations, when is the Mediator pattern a good choice? Here are some scenarios and signals that Mediator is worth considering:
Complex Communication Networks
Use Mediator when you have a lot of interacting objects with many interdependent behaviors. A classic example is a GUI dialog or form with many widgets enabling/disabling each other or triggering validations (buttons, text fields, checkboxes that affect one another). Without mediator, the code for these interactions becomes spaghetti. With mediator, each widget talks only to the dialog (mediator) and the dialog orchestrates the enable/disable logic.
Spaghetti Dependency Cleanup
If you find yourself saying, “Object A needs to know about B and C, and B needs to know about A and D, and C knows about A and B…” — that’s a red flag. This tight coupling is hard to maintain. Introducing a mediator can untangle this by making A, B, C, D depend only on M (mediator) instead of on each other. This decoupling makes the codebase more modular.
Pluggable Modules or Plugins
In a modular system (like an app with plugins or features that can be added/removed), a mediator can act as the communication hub for modules. Each plugin doesn’t directly call others (which might not even exist); instead, it sends requests to the mediator. The mediator then figures out if some other plugin should handle it. This way, you can add a new plugin and just have it register with the mediator, rather than rewriting half the app to integrate the new module.
Coordinating Multiple Dependencies
Sometimes a certain operation involves multiple components in sequence or in tandem. A mediator is useful if you want to centralize the coordination for such multi-object processes. For instance, consider a save operation in an application that involves several steps (validate data, write to database, update UI, log the action). Rather than each part calling the next, a mediator could manage the workflow: each step reports back to the mediator, which then invokes the next step. This makes the workflow easier to adjust in one place.
You Need a Central Control for Policy
If there are global policies or modes that affect how objects interact, a mediator can enforce them. For example, suppose you have an app-wide “read-only mode”. The mediator could be aware of this mode and prevent certain interactions (like disabling editing features) by intercepting messages between components. Without a mediator, each component would need to know about the read-only policy, leading to duplicate checks scattered everywhere.
Swift Code Example: A Smart Home Mediator
To make the concept clearer, let’s walk through a Swift example. Imagine a simple smart home system with a motion sensor, a light, and an alarm siren. We want the following behavior: when the motion sensor detects movement, it should not directly turn on the light or trigger the alarm itself; instead, it notifies a central hub, which then decides to turn on the light and ring the alarm. This “hub” is our mediator. The devices (sensor, light, alarm) are the colleagues that communicate via the hub.
Below is a Swift implementation of this scenario using the Mediator pattern. (Don’t worry if you’re not a Swift expert — the code is straightforward and we’ll explain it after.)
import Foundation
/// The Mediator protocol declares an interface for communication.
protocol SmartHomeMediator: AnyObject {
func notify(sender: Device, event: Event)
}
/// We define events that colleagues can notify the mediator about.
enum Event {
case motionDetected
// In a fuller example, you might have events like doorOpened, smokeDetected, etc.
}
/// Base class for all devices in our smart home.
/// It holds a reference to the mediator so subclasses can use it.
class Device {
let name: String
weak var mediator: SmartHomeMediator? // weak to avoid retain cycles
init(name: String, mediator: SmartHomeMediator) {
self.name = name
self.mediator = mediator
}
}
/// Concrete colleague: Motion Sensor.
class MotionSensor: Device {
func detectMotion() {
print("\(name): Motion detected!")
// Notify the mediator about the event.
mediator?.notify(sender: self, event: .motionDetected)
}
}
/// Concrete colleague: Light.
class Light: Device {
func turnOn() {
print("\(name): Turning ON")
}
func turnOff() {
print("\(name): Turning OFF")
}
}
/// Concrete colleague: Alarm.
class Alarm: Device {
func ring() {
print("\(name): Ringing alarm!")
}
func stop() {
print("\(name): Alarm stopped.")
}
}
/// Concrete Mediator: the Smart Home Hub.
class SmartHomeHub: SmartHomeMediator {
// The hub may keep references to devices if it needs to direct specific ones.
private var devices: [Device] = []
func addDevice(_ device: Device) {
devices.append(device)
}
/// The mediator's notify method handles incoming events and routes them.
func notify(sender: Device, event: Event) {
switch event {
case .motionDetected:
print("Hub: \(sender.name) reported motion. Notifying relevant devices...")
for device in devices {
if device === sender {
continue // the sensor that sent the event doesn't need to hear it
}
if let light = device as? Light {
light.turnOn()
}
if let alarm = device as? Alarm {
alarm.ring()
}
}
}
}
}
// --- Client code: Setting up the system ---
let hub = SmartHomeHub()
// Create devices and register them with the hub mediator.
let motionSensor = MotionSensor(name: "FrontDoorSensor", mediator: hub)
let livingRoomLight = Light(name: "LivingRoomLight", mediator: hub)
let siren = Alarm(name: "MainAlarm", mediator: hub)
// Add devices to the hub's list of colleagues.
hub.addDevice(motionSensor)
hub.addDevice(livingRoomLight)
hub.addDevice(siren)
// Simulate the motion sensor detecting movement:
motionSensor.detectMotion()
If you run this code, the output might look like this:
FrontDoorSensor: Motion detected!
Hub: FrontDoorSensor reported motion. Notifying relevant devices...
LivingRoomLight: Turning ON
MainAlarm: Ringing alarm!
Let’s break down what happened in the example:
- We defined a
SmartHomeMediator
protocol with anotify
method. The mediator is expected to handle asender
and anevent
. In a more complex system, you might pass more context or use different methods for different types of requests, but this keeps it simple. - We created an
Event
enum to represent events. Here we only have one event.motionDetected
, but enumerating events like this is a common approach in mediator implementations (so the mediator can use aswitch
or lookup to decide what to do). - The
Device
base class holds a reference to a mediator. We marked itweak
to avoid a strong reference cycle (each device doesn’t keep the mediator alive if the mediator is released). This base isn’t strictly required, but it avoids duplicating the mediator reference in every device class. Each device has aname
just for identifying itself in output. - We have three device classes:
MotionSensor
,Light
, andAlarm
, each subclassingDevice
.
- TheMotionSensor
has adetectMotion()
method. When called, it prints a message and usesmediator?.notify(self, .motionDetected)
to tell the hub “hey, motion happened.” Notice the sensor doesn’t try to turn on lights or sound alarms itself – it doesn’t even know those exist. It only knows about its mediator.
- TheLight
has methods to turn on/off (in our scenario we’ll only useturnOn()
when motion is detected).
- TheAlarm
has methods to ring or stop the siren. SmartHomeHub
is our concrete mediator, implementingSmartHomeMediator
. It holds a list ofdevices
(colleagues). We provide anaddDevice
method to register devices with the hub. Thenotify
method is where the logic lives. When an event comes in:
- If the event is.motionDetected
, the hub prints a log message and then iterates over all registered devices. For each device that isn’t the sender (the sensor itself), it checks: if it’s aLight
, callturnOn()
, if it’s anAlarm
, callring()
. (We used Swift’s type castingas?
to determine the device type.)
- In a real system, the mediator might have more complex logic (time of day, whether the alarm system is armed, etc., to decide what to do on motion). But the principle is the same — one place to decide what happens when an event occurs.- In the client code, we instantiate the mediator (hub) and the devices, passing the hub into each device’s initializer. We register each device with
hub.addDevice
. Now the hub knows about all three devices. Finally, we simulate an event:motionSensor.detectMotion()
. The sensor then callshub.notify(...)
, and the hub processes that by calling the appropriate methods on the light and alarm.
This example demonstrates how the Mediator pattern works in practice:
- The MotionSensor (colleague) did not turn on the Light or Alarm directly. It had no direct reference to them.
- The Light and Alarm classes did nothing until the mediator told them to act. They didn’t know the sensor even existed; they only know the hub might call their methods.
- The SmartHomeHub (mediator) had the knowledge of the system’s logic: “if motion sensor triggers, then turn on lights and sound alarm”. This logic is centralized. If we wanted to change what happens on motion (say, only turn on lights but not alarm during daytime), we’d modify the
SmartHomeHub.notify
method, without touching the sensor, light, or alarm classes at all.
Benefits in the example: If we later add a new sensor or another light, it’s easy to hook them into this system by registering them with the hub. The existing classes don’t need to change. Also, if we decide to reuse the MotionSensor
class in a different project, it’s not tightly coupled to Light
or Alarm
– we could hook it up to a different mediator for a different purpose (maybe a security system mediator that does something else on motion). Each class is focused on its own role, and the coordination is handled in one place.
Conclusion
The Mediator pattern is a powerful tool for managing complex object interactions. By introducing a dedicated mediator object to handle communications, we decouple components and simplify the dependency graph of a system. This makes our code more maintainable and extensible — changes to interaction logic are localized in the mediator, and components can often be reused in new contexts.
However, as we’ve discussed, Mediator is not a cure-all. It centralizes and tames communication chaos, but it requires careful design to avoid turning into an overly complex entity itself. The pattern works well in scenarios with lots of interconnected parts, but can be overkill for simpler cases or when an event broadcasting model (Observer) would suffice.
Feel free to experiment with the Mediator pattern in your own projects. Start with a small example and you’ll quickly see the benefit of having that single hub controlling the communication. As with all patterns, the goal is cleaner and more robust code. Mediator, when applied in the right context, can turn a confusing tangle of object interactions into a well-organized conversation moderated by a sensible go-between.
Enjoyed the read? Give it a clap, share it with friends or teammates, and hit follow if you’re up for more thoughtful takes on patterns and architecture. More’s on the way. Happy coding!