6 min read ·
The observer pattern is not an architecture
The observer pattern is a list and a for loop. It's one of the best small ideas in the Gang of Four's 1994 book, and it isn't an architecture. Use it inside a process, and make every hop across a network earn its place.
I finished my master's at UIC in May, with courses in object-oriented languages and distributed systems, and since June I've been writing C++ for a university IT department (I wrote earlier about why anyone still picks C++). The observer pattern sits on the border between those two courses, and nearly all the trouble with it starts when somebody carries it across. Here's the whole thing.
#include <functional>
#include <utility>
#include <vector>
class Slider {
public:
using Observer = std::function<void(int)>;
void subscribe(Observer fn) {
observers_.push_back(std::move(fn));
}
void set(int value) {
value_ = value;
for (auto& fn : observers_) {
fn(value_);
}
}
private:
int value_ = 0;
std::vector<Observer> observers_;
};
Twenty-three lines, counting the includes. The slider keeps a list of functions and calls each one when its value changes. All the slider knows about a label that prints the number, or a preview that redraws, is one signature, void(int). That decoupling is real. The slider never includes the label's header, and a third observer changes nothing in the slider.
The book names Smalltalk's Model-View-Controller as the first example, and UI toolkits have kept copies ever since. The browser has addEventListener, and Qt has signals and slots. A button shouldn't know who cares that it was clicked, and this is how you keep it ignorant.
The pattern goes by two other names in the book, Dependents and Publish-Subscribe. The second one escaped. Today it means a broker, and the same phrase covers a for loop in one process and a distributed system with its own dashboards. A broker is the right tool when a sender must not wait for its receiver. Most arrows drawn in its name have no such sender.
Even the 23 lines hide three bugs.
There's no unsubscribe. Destroy the label while the slider lives, and the next set calls a lambda that captured a dead this. In a garbage-collected language the list keeps the dead label alive instead, a bug common enough to have its own name, the lapsed listener problem. Node prints a memory-leak warning once one event collects more than 10 listeners. The runtime expects you to forget. My first version of this post held every observer in a shared_ptr, which trades the crash for a leak that lasts as long as the subject. The fix is a token that subscribe returns and whose destructor unsubscribes.
The vector happens to call observers in the order they subscribed, which nobody promised, and one day the preview starts to assume the label went first. Swap in a std::unordered_map keyed by token, so that removal is cheap, and the order is whatever the hash table prefers. No line of business logic moved. Java shipped java.util.Observable in 1.0 and deprecated it in Java 9, and one stated reason was the unspecified notification order.
The third bug was in the book from the start. Its list of consequences warns that observers, blind to one another, cannot see what a change to the subject really costs, so one innocent-looking operation can set off a cascade of updates. In C++ it gets worse. An observer that calls set recurses into the loop it's running inside, and one that calls subscribe can make the vector reallocate under its own iterator, which is undefined behavior.
In one process, all three are afternoon bugs. Put a breakpoint in any observer, and the debugger shows set one frame up and the code that changed the value one frame above that.
Now put a broker between the slider and the label. Congratulations, your slider is eventually consistent. Nobody does this to a slider. Plenty of teams do it to two services that live in one repo and ship on the same day.
Every bug above comes back larger. The missing unsubscribe is now a consumer group that nobody owns. Kafka keeps order only within a partition, so the order you leaned on was never promised there either, and when a consumer dies before it commits its offset, whichever consumer takes over its partition reads the same message again. Two services that publish at each other are the old cascade with a network in the middle. The stack trace is gone, too. The consumer's stack starts at its poll loop, and the cause sits in another process's log.
A function call is a phone call. An event is a flyer on a lamppost.
I call this causality laundering. The event arrives clean, with no record of who sent it or why, and the team installs OpenTelemetry and threads correlation IDs through every message to rebuild the stack trace it threw away. Rebuilding it is a research problem. I spent part of my degree writing snapshot and leader-election algorithms on Akka actors in Scala. Once a system talks only in messages there is no "now" to look at, and Chandy and Lamport needed a whole algorithm to take one consistent picture of a system like that.
"Shouldn't this be event-driven?"
Usually, no.
People ask it before anyone has named a problem, and the case for it is always decoupling. That's where causality laundering turns dishonest. The label still depends on the event's name, its fields, its timing and its order. You moved that dependency out of the compiler, which checks it on every build, and into a wiki page that nobody checks. An event bus doesn't remove coupling. It removes the evidence.
I know of three honest reasons to pay for causality laundering. The sender must not wait, because the work is slow or arrives in spikes that a queue has to absorb. The work has to survive the receiver being down. The receiver belongs to another team and ships on its own schedule. If one of them is true, the hop has earned its place. Write which one next to the arrow.
If you can't write one, you don't have an event. You have a function call you were too proud to make.
