8 min read ·
The observer pattern is not an architecture
The observer pattern is a list and a for loop. Somewhere between the textbook and the architecture review, it turned into a message broker with its own dashboards, its own on-call rotation and its own line in the budget.
The small version is one of the best ideas in programming. The big version is sometimes necessary and usually isn't, and the difference is a line you can draw on a whiteboard: the edge of a process, one running program with its own memory. Here's the small version, in C++.
#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. A label that prints the number, a preview that redraws, a logger: the slider knows about none of them. All it knows is one signature, void(int). It never includes their headers, and a fourth observer changes nothing in the slider.
That decoupling is real, and it's old. The Gang of Four put Observer in Design Patterns in 1994 and called Smalltalk's Model-View-Controller perhaps the best-known early use. The browser's addEventListener is the same idea. So are Qt's signals and slots. A button shouldn't know who cares that it was clicked, and this is how you keep it ignorant.
The book gives the pattern two other names, Dependents and Publish-Subscribe. The second one escaped. Today "pub/sub" usually means a broker: a separate server, like Kafka or RabbitMQ, that holds messages between the program that sends them and the programs that read them. One phrase now covers a for loop inside a single program and a distributed system with a pager attached. That's how a 23-line idea ends up as a box on an architecture diagram.
Three bugs in 23 lines
Even the small version hides three bugs.
First, there's no unsubscribe. Destroy the label while the slider lives, and the next set calls a function that still points at the dead label's memory. A garbage-collected language gives you the opposite bug: the list keeps the dead label alive for as long as the slider lives. It's common enough to have a name, the lapsed listener problem, and Node expects you to hit it. An EventEmitter prints a "possible EventEmitter memory leak" warning once a single event collects more than 10 listeners. The fix is a token that subscribe returns and whose destructor removes the observer.
Second, order. This vector happens to call observers in the order they subscribed. Nothing in the class promises that, and one day the preview starts to assume the label went first. Then someone swaps the vector for a hash map so removal is cheap, and the order becomes 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; the note complains, among other things, that "the order of notifications delivered by Observable is unspecified."
Third, re-entrancy: an observer that calls back into the thing that is notifying it. The book saw this coming. Observers can't see each other, it warns, so "a seemingly innocuous operation on the subject may cause a cascade of updates." C++ makes it worse. An observer that calls set recurses into the loop it's running inside. One that calls subscribe pushes onto the vector the loop is walking, and that breaks the loop whether or not the vector has to move. The language stops making promises at that point. Undefined behavior, in a slider.
Here's the good news. Inside one process, all three are afternoon bugs. Put a breakpoint in any observer, and the debugger shows set one frame below, and the code that changed the value one frame below that. The whole chain of cause and effect sits in one call stack, the list of who called whom, in one memory space, under one debugger.
Hold on to that. It's the thing you're about to give up.
Now add a broker
Nobody puts a message broker between a slider and its label. Plenty of teams put one between two services that live in the same repo and ship on the same day.
Say you run a small online shop. Checkout used to call sendReceipt(order). Then somebody asked, "Shouldn't this be event-driven?", and now checkout publishes an OrderPlaced event and an email service consumes it. It's the same observer pattern. The list of callbacks just lives on another machine now.
A customer writes in: two receipts for one order. The email service's logs say the handler ran twice. Its stack trace starts at the consumer's poll loop, the loop that keeps asking the broker "anything new?", and climbs up into the handler. Below the poll loop there's nothing. Whatever caused this lives in another process, in another log, on another machine with its own clock.
The double receipt isn't even a bug, by the broker's standards. If a consumer crashes after it handles a message but before it saves its position, whichever consumer takes over reads that message again. Kafka's documentation calls this "at-least-once." Your for loop never called anyone twice. Now every observer has to survive a repeat.
The slider's three bugs come back bigger too. The missing unsubscribe is now a consumer nobody owns, still reading events for a feature that died last year. The order you leaned on is now a promise per partition, one of the buckets Kafka splits a stream into, and between services there's no shared clock to settle who went first. I wrote about why a system that talks in messages has no "now", so I won't repeat it here. The cascade is two services that publish at each other, with a network in the middle and a lag graph where the stack overflow used to be.
Then the operational bill. The broker is a server, so somebody patches it, sizes it and gets paged when its disk fills. Consumer lag needs a dashboard. Messages that keep failing need a dead-letter queue, a side queue where they wait for a human, and a human has to read it. The event's shape needs versioning, because producer and consumer no longer deploy together. None of that existed when checkout called a function.
Causality laundering
A function call is a phone call. An event is a flyer on a lamppost.
The phone call has a caller. You can ask who's calling and why, and the stack answers. The flyer just appears. I call this causality laundering: the event arrives clean, with no record of who sent it or why.
The industry knows this, and it bought a fix. Distributed tracing, which tags each request with an ID and follows it from service to service, exists largely to undo the laundering. The OpenTelemetry conventions for messaging say a producer SHOULD attach a "message creation context" to each message, because without it "consumer traces cannot be directly correlated with producer traces." So you install an SDK, thread an ID through every message header and pay for a tracing backend, all to rebuild the stack trace the function call gave you for free.
"Shouldn't this be event-driven?"
Usually, no.
The case for it is always decoupling, and the decoupling is laundered too. The email service still depends on the event's name, its fields, its timing and its order. All of that moved 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.
Three honest reasons
I have nothing against a broker somebody priced. I know three honest reasons to pay for the hop.
The sender must not wait. The work is slow, or it arrives in spikes a queue has to absorb, and the user shouldn't stand there while it happens.
The work must survive the receiver being down. If the email service is out for an hour, the receipts still go out when it comes back.
The receiver belongs to someone else: another team, with its own release schedule and its own on-call. Here the decoupling is organizational, and it's real.
Look at the shop again. Mail servers are slow, and checkout shouldn't hang on one, so that receipt might pass the first test. Fine. Then write "must not wait" next to the arrow, make the handler safe to run twice, and put the broker in the budget. Three words on a diagram settle the argument before the review starts.
If you can't write a reason, you don't have an event. You have a function call you were too proud to make.
