Mediator — Find the Bug¶
Each section presents a Mediator that looks fine but is broken. Find the bug yourself, then check.
Table of Contents¶
- Bug 1: Cyclic notifications
- Bug 2: Component reaches into another via Mediator
- Bug 3: Magic-string event typo
- Bug 4: Mediator holds strong refs causing leak
- Bug 5: Saga compensation runs in wrong order
- Bug 6: Compensations not idempotent
- Bug 7: Synchronized Mediator becomes bottleneck
- Bug 8: Component leaks into constructor before init
- Bug 9: Mediator's notify swallows exceptions
- Bug 10: Workflow non-determinism
- Bug 11: State scattered across Mediator and Components
- Bug 12: Distributed Mediator double-spends on retry
- Practice Tips
Bug 1: Cyclic notifications¶
public final class Dialog implements Mediator {
private final TextField a = new TextField(this);
private final TextField b = new TextField(this);
public void notify(Component sender, String event) {
if (sender == a) b.setValue(a.value()); // triggers b's notify
if (sender == b) a.setValue(b.value()); // triggers a's notify
}
}
Setting a causes infinite recursion.
Reveal
**Bug:** `b.setValue` triggers `b`'s `notify`, which calls `a.setValue`, which triggers `a`'s `notify`, which calls `b.setValue`... StackOverflow. **Fix:** detect cycles with a thread-local flag.private final ThreadLocal<Boolean> inUpdate = ThreadLocal.withInitial(() -> false);
public void notify(Component sender, String event) {
if (inUpdate.get()) return;
inUpdate.set(true);
try {
if (sender == a) b.setValue(a.value());
if (sender == b) a.setValue(b.value());
} finally {
inUpdate.set(false);
}
}
Bug 2: Component reaches into another via Mediator¶
public final class FormDialog implements Mediator {
public final TextField username = new TextField(this);
public final Button submit = new Button(this);
// expose components publicly; defeats encapsulation
}
class UsernameField {
public void onChange() {
if (text.isEmpty()) {
((FormDialog) mediator).submit.setEnabled(false); // BUG
}
}
}
Reveal
**Bug:** The Component reaches *through* the Mediator to manipulate another Component. Defeats decoupling. The Component is now coupled to `FormDialog`'s structure. **Fix:** Mediator exposes named actions, not Components.public interface Mediator {
void onTextChanged(String fieldName, String value);
}
class UsernameField {
public void onChange() {
mediator.onTextChanged("username", text);
}
}
// FormDialog handles the routing privately:
public void onTextChanged(String fieldName, String value) {
if ("username".equals(fieldName) && value.isEmpty()) submit.setEnabled(false);
}
Bug 3: Magic-string event typo¶
public void notify(Component sender, String event) {
if ("submit_clicked".equals(event)) handleSubmit(); // expects this
// ...
}
class SubmitButton {
public void onClick() {
mediator.notify(this, "submit-clicked"); // BUG: hyphen, not underscore
}
}
Submit button does nothing. No error.
Reveal
**Bug:** Magic strings don't match. Compiler can't catch typos. The Mediator silently ignores the event. **Fix:** typed methods or enums. Or enum events: **Lesson:** Magic strings are a fragile interface. Always typed.Bug 4: Mediator holds strong refs causing leak¶
public class GlobalMediator {
private static final GlobalMediator INSTANCE = new GlobalMediator();
private final List<Component> components = new ArrayList<>();
public void register(Component c) { components.add(c); }
// No unregister
}
class RequestComponent {
public RequestComponent() {
GlobalMediator.INSTANCE.register(this);
}
}
After thousands of requests, OOM.
Reveal
**Bug:** Components register but never unregister. The static Mediator holds them; they can't be GC'd. **Fix:** add unregister or use weak references. Or: **Lesson:** Mediators outliving Components must support unregister or use weak refs.Bug 5: Saga compensation runs in wrong order¶
class Saga:
def run(self):
for action, comp in self.steps:
try: action()
except:
# compensate in same order
for _, c in self.steps[:i]:
c()
raise
Compensations run in execute order, causing dependency errors.
Reveal
**Bug:** Compensations run in *forward* order. They should run in *reverse* order. If `charge` ran first and `reserve` second, you must `release` (reverse `reserve`) before `refund` (reverse `charge`) — otherwise dependencies are violated. **Fix:** **Lesson:** Compensations always run in reverse order. Like nested function calls — last in, first out.Bug 6: Compensations not idempotent¶
Saga retries trigger duplicate refunds; customer gets multiple refunds.
Reveal
**Bug:** `refund` creates a new refund record every call. Retries duplicate refunds. **Fix:** check before acting, or use idempotency keys. **Lesson:** Compensations are Commands; they must be idempotent. Saga retries are inevitable.Bug 7: Synchronized Mediator becomes bottleneck¶
public final class GlobalEventMediator {
public synchronized void notify(Event e) {
for (Component c : components) c.handle(e);
}
}
Throughput collapses under load; tens of thousands of events/sec is impossible.
Reveal
**Bug:** `synchronized` serializes all notifications. With many concurrent producers, they queue. **Fix:** if Components are independent, dispatch async. Or: shard by event type / source; one Mediator per shard. **Lesson:** Centralized Mediators must be designed for concurrency. `synchronized` everywhere is the classic mistake.Bug 8: Component leaks into constructor before init¶
public final class FormDialog implements Mediator {
private final SubmitButton submit;
public FormDialog() {
// submit is created BEFORE FormDialog is fully constructed
submit = new SubmitButton(this); // `this` partially constructed
submit.click(); // calls back into FormDialog's notify
}
public void notify(...) {
someField.access(); // someField not yet initialized
}
}
NPE deep in notify.
Reveal
**Bug:** `this` leaked into `SubmitButton` while the constructor was still running. `notify()` runs against a partially-constructed Mediator. **Fix:** initialize all fields BEFORE creating Components / wiring callbacks. Or, separate "create" from "start" / "wire": **Lesson:** Don't leak `this` from constructors. Component subscriptions / callbacks must happen after full construction.Bug 9: Mediator's notify swallows exceptions¶
public void notify(Component sender, String event) {
try {
// routing logic that may throw
validateAndDispatch(sender, event);
} catch (Exception e) {
// silently ignored
}
}
Bugs go unnoticed. Logs show nothing.
Reveal
**Bug:** Catch-all empty. Exceptions disappear. Components and the surrounding system never know something went wrong. **Fix:** at minimum, log. Better, surface or rethrow appropriately. **Lesson:** Empty catch blocks hide bugs. Mediator is a critical path — failures must be observable.Bug 10: Workflow non-determinism¶
@workflow.defn
class OrderWorkflow:
@workflow.run
async def run(self, order_id):
timestamp = datetime.now() # BUG: non-deterministic
await workflow.execute_activity(charge, order_id, timestamp)
In Temporal, workflow replay produces a different timestamp; activity result mismatch.
Reveal
**Bug:** `datetime.now()` is non-deterministic. Replay sees a different time. Workflow engine detects mismatch and fails. **Fix:** use Temporal's `workflow.now()` (deterministic — recorded in history). Or pass the timestamp as input. **Lesson:** Workflows must be deterministic. Random, time, network — all become activities, with results recorded for replay.Bug 11: State scattered across Mediator and Components¶
class FormDialog {
private boolean isValid; // also stored in components
}
class TextField {
private boolean valid; // duplicate state
public void onChange() {
valid = !value.isEmpty();
mediator.notify(this, "changed");
}
}
Inconsistencies: dialog's isValid says false, but field's valid says true.
Reveal
**Bug:** State of validity duplicated. They drift. **Fix:** single source of truth. Either: - Mediator computes validity from Component values when needed. - Component owns validity; Mediator queries. No `isValid` field; computed from Components. **Lesson:** Decide upfront where state lives. Don't duplicate.Bug 12: Distributed Mediator double-spends on retry¶
class Orchestrator:
async def place(self, order):
await self.payment.charge(order) # no idempotency key
await self.inventory.reserve(order)
await self.shipping.dispatch(order)
Network glitch causes orchestrator retry; customer charged twice.
Reveal
**Bug:** No idempotency. Retries duplicate charges, reservations, shipments. **Fix:** every Component call gets an idempotency key. Components dedup based on the key. **Lesson:** Distributed Mediators must assume at-least-once delivery. Every Component call must be idempotent.Practice Tips¶
- Cycles in Mediators are common. Detect with thread-local flags or design out two-way bindings.
- Components shouldn't reach through Mediator. Mediator exposes actions, not Components.
- Magic strings rot. Use typed events.
- Long-lived Mediators leak. Add unregister or use weak refs.
- Compensations: reverse order, idempotent. Always.
- Synchronized Mediators don't scale. Async dispatch + per-handler error isolation.
thisleak from constructor is silent and devastating. Wire after full init.- Empty catch blocks hide bugs. At minimum, log.
- Workflows must be deterministic. Outsource non-determinism to activities.
- Single source of truth for state. Mediator OR Component, not both.
- Distributed Mediators retry. Idempotency keys everywhere.