Mediator Pattern
C++ mediator pattern — event bus, signal/slot, and classic coordinator implementations with unsubscription, re-entrancy safety, and thread considerations.
Mediator Patternsince C++11A behavioral pattern that routes all communication between components through a single coordinator object, reducing N×(N-1)/2 direct dependencies to N connections and decoupling senders from receivers entirely.
Overview
When N components need to talk to each other, direct coupling produces a dependency graph that grows quadratically. The mediator collapses this: each component addresses the mediator, the mediator addresses everyone else.
Three concrete forms dominate in C++:
- Event bus — type-indexed publish/subscribe; publishers and subscribers share no compile-time coupling at all
- Classic mediator — a concrete coordinator class that explicitly manages named participants and complex routing logic
- Signal/slot — per-signal mediators; components wire up point-to-point through a lightweight signal object rather than a central registry
Choose an event bus when participants are unknown at compile time or change dynamically. Choose a classic mediator when the coordination logic itself is non-trivial and belongs in one auditable place — an order router, an ATC system, a game event dispatcher with priority rules. Use signal/slot when coupling is between specific pairs of components and the logic is simple enough to express as a callback.
Examples
Type-Erased Event Bus (C++11)
The foundational technique erases the event type behind void*, storing a recovery lambda alongside:
#include <functional> // std::function — C++11
#include <typeindex> // std::type_index — C++11
#include <unordered_map> // C++11
#include <vector>
#include <algorithm>
class EventBus {
public:
using Token = std::size_t; // opaque subscription handle
private:
struct Entry {
Token token;
std::function<void(const void*)> handler; // C++11
};
std::unordered_map<std::type_index, std::vector<Entry>> handlers_;
Token next_token_ = 0;
public:
template<typename Event>
Token subscribe(std::function<void(const Event&)> handler) { // C++11
Token tok = next_token_++;
handlers_[typeid(Event)].push_back({
tok,
[h = std::move(handler)](const void* e) {
h(*static_cast<const Event*>(e));
}
});
return tok;
}
template<typename Event>
void unsubscribe(Token tok) {
auto it = handlers_.find(typeid(Event));
if (it == handlers_.end()) return;
auto& list = it->second;
list.erase(
std::remove_if(list.begin(), list.end(),
[tok](const Entry& e) { return e.token == tok; }),
list.end());
}
template<typename Event>
void publish(const Event& event) {
auto it = handlers_.find(typeid(Event));
if (it == handlers_.end()) return;
// Snapshot: guards against handlers that subscribe/unsubscribe
// mid-iteration, which would invalidate the vector iterator.
auto snapshot = it->second;
for (const auto& entry : snapshot)
entry.handler(&event);
}
};
// Events are plain aggregates — no base class, no virtual dispatch
struct UserLoggedIn { std::string username; };
struct MessageSent { std::string from, to, text; };
struct UserLoggedOut { std::string username; };
class AuditLogger {
EventBus::Token login_tok_ = 0;
EventBus::Token logout_tok_ = 0;
public:
void attach(EventBus& bus) {
login_tok_ = bus.subscribe<UserLoggedIn>([](const UserLoggedIn& e) {
std::println("[audit] login: {}", e.username); // std::println — C++23
});
logout_tok_ = bus.subscribe<UserLoggedOut>([](const UserLoggedOut& e) {
std::println("[audit] logout: {}", e.username);
});
}
~AuditLogger() = default;
void detach(EventBus& bus) {
bus.unsubscribe<UserLoggedIn>(login_tok_);
bus.unsubscribe<UserLoggedOut>(logout_tok_);
}
};The snapshot copy in publish is not free, but it's the correct default. Without it, a handler that unsubscribes itself (a common pattern for one-shot handlers) invalidates the std::vector iterator mid-loop — undefined behaviour.
Classic Mediator — Air Traffic Control
When coordination logic is complex — precedence, state, regulatory constraints — centralize it in the mediator rather than distributing it across participants:
#include <queue>
#include <string_view> // C++17
#include <unordered_map>
class Runway;
class ATC { // abstract mediator interface
public:
virtual void request_landing(Runway&, std::string_view callsign) = 0;
virtual void vacated(Runway&, std::string_view callsign) = 0;
virtual ~ATC() = default;
};
class Runway {
std::string name_;
ATC* atc_ = nullptr;
bool occupied_ = false;
public:
explicit Runway(std::string name) : name_{std::move(name)} {}
void set_atc(ATC& atc) noexcept { atc_ = &atc; } // noexcept — C++11
const std::string& name() const noexcept { return name_; }
bool occupied() const noexcept { return occupied_; }
void set_occupied(bool v) noexcept { occupied_ = v; }
void request(std::string_view callsign) {
if (atc_) atc_->request_landing(*this, callsign);
}
void vacate(std::string_view callsign) {
if (atc_) atc_->vacated(*this, callsign);
}
};
class TowerATC final : public ATC {
std::unordered_map<std::string, std::queue<std::string>> hold_queues_;
public:
void request_landing(Runway& rwy, std::string_view callsign) override {
if (!rwy.occupied()) {
rwy.set_occupied(true);
std::println("[ATC] {} cleared for {}", callsign, rwy.name());
} else {
hold_queues_[rwy.name()].emplace(callsign);
std::println("[ATC] {} holding — {} in use", callsign, rwy.name());
}
}
void vacated(Runway& rwy, std::string_view callsign) override {
std::println("[ATC] {} vacated {}", callsign, rwy.name());
auto& q = hold_queues_[rwy.name()];
if (!q.empty()) {
auto next = std::move(q.front()); q.pop();
request_landing(rwy, next); // process head of queue immediately
} else {
rwy.set_occupied(false);
}
}
};
// Usage — participants reference the mediator; they never reference each other
TowerATC tower;
Runway r27{"27L"}, r09{"09R"};
r27.set_atc(tower); r09.set_atc(tower);
r27.request("UAL123"); // cleared
r27.request("DAL456"); // holding
r27.vacate("UAL123"); // DAL456 cleared automaticallyAdding a new runway or a new aircraft type does not touch any other participant class — only TowerATC changes if the routing rules change.
Signal/Slot Without a MOC (C++11)
Finer-grained than an event bus: each Signal is its own mediator for a specific event signature.
template<typename... Args> // variadic templates — C++11
class Signal {
public:
using SlotId = std::size_t;
private:
struct Slot { SlotId id; std::function<void(Args...)> fn; };
std::vector<Slot> slots_;
SlotId next_id_ = 0;
public:
SlotId connect(std::function<void(Args...)> fn) {
SlotId id = next_id_++;
slots_.push_back({id, std::move(fn)});
return id;
}
void disconnect(SlotId id) {
slots_.erase(
std::remove_if(slots_.begin(), slots_.end(),
[id](const Slot& s) { return s.id == id; }),
slots_.end());
}
void emit(Args... args) const {
auto snapshot = slots_; // safe against disconnect-during-emit
for (const auto& s : snapshot)
s.fn(args...);
}
bool empty() const noexcept { return slots_.empty(); }
};
class Slider {
int value_ = 0;
public:
Signal<int> value_changed; // zero overhead when empty
void set_value(int v) {
if (v == value_) return;
value_ = v;
value_changed.emit(v);
}
int value() const noexcept { return value_; }
};
// Components connect through signals; neither knows about the other
Slider s;
int last_seen = 0;
auto id = s.value_changed.connect([&last_seen](int v) { last_seen = v; });
s.set_value(42); // last_seen == 42
s.value_changed.disconnect(id);
s.set_value(99); // last_seen still 42 — slot was removedUnlike Qt's signal/slot, no MOC is required. The trade-off: no automatic lifetime management — if a lambda captures a reference to an object that has been destroyed, the emit call is UB.
Best Practices
Always return a subscription token. A subscribe() with no return value cannot be undone. Every subscriber that doesn't outlive the bus unconditionally needs a disconnection path. Return a token; require callers to call unsubscribe before the subscriber's destructor completes.
Snapshot before iterating. Publish while a handler is registered to the same bus is legal and common (one-shot handlers that unsubscribe themselves, handlers that publish follow-up events). Without snapshotting the handler list before iteration, either path produces iterator invalidation UB.
Events as cheap value types. Events travel through void* and are reconstructed on the other side. Heap-allocated or reference-counted events add overhead on every publish. Prefer flat aggregates; embed small strings by value; use std::string_view (C++17) only if the event lifetime is guaranteed to outlast the publish call.
Isolate event declarations. Event struct headers should be owned by neither publisher nor subscriber. Both include the shared event header; neither transitively includes the other. This is the primary coupling-reduction mechanism and should not be undermined by putting events inside publisher headers.
Prefer classic mediators when routing logic is stateful. A bus with business logic duct-taped into handlers across a dozen files is worse than a mediator. If the coordinator needs to remember state between messages, enforce ordering, or make multi-participant decisions, give it a class with a name and a home.
Common Pitfalls
Dangling captures after unsubscription omission. If a subscriber holds a lambda that captures this and the subscriber is destroyed while still registered, the bus holds a live std::function pointing into freed memory. The next publish fires into a dangling pointer. This is the most common mediator-related crash in production codebases. Enforce destruction-time unsubscription via RAII or a subscription guard wrapper.
Re-entrant publish without snapshotting. A handler for event A that publishes event B on the same bus recurses into publish. If the inner publish modifies handlers_[typeB] while the outer loop is iterating handlers_[typeA], the result is iterator invalidation when both share the same std::unordered_map. The snapshot pattern in publish handles this at the cost of a vector copy per dispatch.
std::type_index instability across shared libraries. Within a single binary, typeid(T) is stable and std::type_index comparisons are correct. Across DSO boundaries — plugin systems, hot-reload architectures — two translation units in different shared objects can each define struct Foo in an anonymous namespace and produce type_index values that compare equal when they shouldn't, or unequal when they should. Use explicit numeric event IDs (constexpr uint32_t) for plugin-facing buses.
std::function overhead on hot paths. std::function (C++11) incurs a heap allocation when the callable exceeds the small-buffer threshold (typically 16–32 bytes), plus a virtual dispatch per call. For a bus that dispatches thousands of events per frame, this matters. Alternatives: store raw function pointers for fixed-arity events; use a policy-based slot list where the callable type is a template parameter; or adopt a type-erased but inline-storage approach with std::inplace_function from various open-source libraries.
No thread safety by default. Concurrent subscribe/publish from different threads races on the std::unordered_map. Guard with std::shared_mutex (C++17): shared lock for publish (multiple readers), exclusive lock for subscribe/unsubscribe. Be aware that holding the lock during handler dispatch while a handler tries to subscribe creates a deadlock — release the lock before dispatching, or use the snapshot pattern with the lock released before iteration.
Mediator vs. Observer vs. Event Bus
| Classic Mediator | Observer | Event Bus | |
|---|---|---|---|
| Coupling | Low — via mediator | Medium — subject holds observer list | Very low — no party knows another |
| Coordination logic | Centralized in mediator | Distributed in observers | Distributed in subscribers |
| Subscription owner | Mediator | Subject | Bus |
| Best fit | Complex stateful routing | One-to-many state sync | Fully decoupled messaging |
| Unsubscription | Explicit | Explicit | Token-based |
Mediator wins when the coordination logic is non-trivial, stateful, and deserves its own auditable class. Event buses win when the goal is pure decoupling and the routing is unconditional fan-out.
See Also
- Observer Pattern — subject-push notification without a central coordinator
- Type Erasure — the technique underpinning type-indexed handler storage
- RAII — the right pattern for automatic subscription lifetime management