Patterns you will actually be asked for · chapter 22 of 33
Observer: telling other objects something happened
Chapter 3.4 · Part 3, Patterns you will actually be asked for · about 30 minutes
What you need before this chapter: Part 1 in full, especially collections (1.6) and exceptions (1.8). Part 2 in full, especially single responsibility (2.4). Chapter 3.1, Strategy, for functional interfaces and lambdas, which this chapter uses the same way.
When you finish this chapter you will be able to:
- Recognise when a method hardcoding a fixed set of destinations for one piece of information is about to become a bottleneck for unrelated teams
- Extract a listener interface, give the source a list of subscribers, and register a lambda against it
- Name the two concrete ways an unguarded listener list breaks in practice, and fix both
- Say when a listener list is the wrong tool, and what change would prove it
1. The situation
A service needs to log what it is doing: to the console while a developer is watching it run, and somewhere durable once it ships, so a line is not lost the moment the terminal closes. The direct way to write this is inside the one method every part of the service calls to log something.
static void log(String message) {
System.out.println(message);
fileLines.add(message);
}
2. Naive code that is fine
javac Step1.java
java Step1
server started
request handled
file has 2 lines
For exactly two fixed destinations, this is easy to read. Anyone looking at log can see precisely where a line goes, in two statements, with nothing hidden.
3. A new requirement
A third destination arrives: a counter that only cares about errors, so an on-call engineer can see at a glance how many things have gone wrong. log now needs to know how serious a message is, so it takes a severity, and a second destination's rule — "the file only wants warnings and above" — needs its own condition too.
enum Level { DEBUG, INFO, WARN, ERROR }
static void log(Level level, String message) {
System.out.println(message);
if (level.compareTo(Level.WARN) >= 0) {
fileLines.add(message);
}
if (level == Level.ERROR) {
errorCount++;
}
}
javac Step2.java
java Step2
server started
disk usage high
database unreachable
file has 2 lines
errorCount = 1
Three destinations, three rules, one method. Still readable, and still something a reviewer can check line by line against the requirement.
4. Watch where it goes, and the real cost
A fourth destination is next: a rolling file that starts a new file once the current one passes some size. It has nothing to do with severity at all, but adding it still means opening log, because log is the only place a destination gets wired in. Whoever owns the rolling-file requirement now has to read past the console line, the file line, and the error-counting line to find where theirs goes. Every one of those existing lines is a chance to introduce a typo in code they do not own.
The real cost is not the length of log, which is still only a handful of lines. It is that every team that wants a new destination, or a new rule about which lines an existing destination wants, has to edit the one method the entire service depends on to log anything at all. A change owned by the metrics team and a change owned by the logging team end up as edits to the same four lines, and a mistake in either one breaks logging for everyone, not just for the feature being added.
5. The move
Give log a list of subscribers instead of a fixed, hardcoded set, and let each subscriber decide for itself whether a given line is any of its business.
interface Appender {
void write(String formattedLine);
}
record Registration(Appender appender, Level threshold) {}
final class Logger {
private final List<Registration> registrations = new ArrayList<>();
void addAppender(Appender appender, Level threshold) {
registrations.add(new Registration(appender, threshold));
}
void log(Level level, String message) {
for (Registration r : registrations) {
if (level.compareTo(r.threshold()) >= 0) {
r.appender().write("[" + level + "] " + message);
}
}
}
}
Logger logger = new Logger();
logger.addAppender(line -> System.out.println(line), Level.DEBUG);
logger.addAppender(fileLines::add, Level.WARN);
logger.log(Level.INFO, "server started");
logger.log(Level.WARN, "disk usage high");
logger.log(Level.ERROR, "database unreachable");
javac Step3.java
java Step3
[INFO] server started
[WARN] disk usage high
[ERROR] database unreachable
file has 2 lines
Appender has exactly one method, so Logger never learns what a destination actually does with a line once it hands one over. A rolling file joins as one more call to addAppender, and nothing inside Logger.log changes to make room for it. This is the Observer pattern: Logger keeps a list of appenders instead of naming them, and each one decides for itself whether a line is worth keeping.
6. What modern Java changes here
The version of this pattern in the original catalogue usually comes with a Subject interface and an Observer interface that both sides implement, plus a registration protocol written out in full. In a single JVM, most of that ceremony buys nothing. Appender has one abstract method, so it is a functional interface, and the second addAppender call above already passes a method reference, fileLines::add, with no class written anywhere. java.util.Observer and Observable, which once shipped in the JDK for exactly this purpose, were deprecated for a specific reason worth knowing. They told a listener that something changed with no way to say what, which pushed every listener back to re-inspecting the whole subject to find out. The pattern that survives in ordinary Java code is smaller than the book's version — a list held by the source, and listeners that each implement one small interface describing exactly what they are told.
7. When naming it is wrong
A source with exactly one destination, hardcoded, and no second destination named anywhere in the requirements does not need a list of subscribers. The two-line version from section 2 does exactly what it says, and wrapping either line in an Appender before a second destination is even asked for buys nothing but a file to read and a level of indirection nobody needed yet.
The threshold: reach for a listener list once a second destination exists, or is explicitly named in the requirements. A single destination with no second one in view scores lower under this app's grading standard than the two lines it would replace, tagged over-engineered (premature interface). Watch a different boundary too: a change to how delivery itself works — making a slow destination stop blocking every caller, say — touches Logger no matter how many subscribers exist. A listener list absorbs a new subscriber for free. It does not absorb a change to its own dispatch mechanism for free.
Your turn
Add the error counter from section 3 back as an Appender, registered so it only ever receives ERROR lines, without editing Logger.
The answer.
final class ErrorCounter implements Appender {
private int count = 0;
@Override
public void write(String formattedLine) {
count++;
}
int count() {
return count;
}
}
ErrorCounter counter = new ErrorCounter();
logger.addAppender(counter, Level.ERROR);
javac Step4.java
java Step4
[INFO] server started
[ERROR] database unreachable
[ERROR] disk full
errors seen: 2
ErrorCounter never inspects the level itself. Registering it at Level.ERROR means Logger's own threshold check keeps every line below ERROR from ever reaching it, so the counting logic stays as simple as count++.
Going deeper
Two failures show up the moment a listener list is used for real, and neither is mentioned in most treatments of this pattern.
A listener that throws takes the whole loop down with it. log in section 5 calls straight through to each Appender. If one throws, a plain for loop does not catch it, skip that appender, and move to the next one — it propagates immediately, and every appender registered after the broken one never runs.
void log(String message) {
for (Appender appender : appenders) {
appender.write(message);
}
}
javac Step5Unsafe.java
java Step5Unsafe
console: request handled
Exception in thread "main" java.lang.RuntimeException: disk full
at Step5Unsafe.lambda$main$1(Step5Unsafe.java:27)
at Step5Unsafe$Logger.log(Step5Unsafe.java:19)
at Step5Unsafe.main(Step5Unsafe.java:30)
The console appender ran. The audit appender, registered after the one that threw, never got the chance to, and the exception then escaped log entirely and crashed the caller. A logging call is not supposed to be able to crash the code that made it, so each destination needs its own fence:
void log(String message) {
for (Appender appender : appenders) {
try {
appender.write(message);
} catch (RuntimeException failed) {
// one broken destination must not silence the others
}
}
}
javac Step5Safe.java
java Step5Safe
console: request handled
(appender failed and was skipped: disk full)
audit: request handled
this line does print now
A listener list that gets mutated while it is being notified throws ConcurrentModificationException. This is the more surprising failure, because nothing about registering a new appender looks dangerous on its own. The trouble starts when one appender's own reaction to a line is to register another appender, mid-notification. A for-each loop over a plain ArrayList keeps an internal counter of how many changes have happened to the list, and throws the moment that counter changes underneath it.
logger.addAppender(line -> System.out.println("console: " + line));
logger.addAppender(line -> logger.addAppender(l -> System.out.println("late: " + l)));
logger.addAppender(line -> System.out.println("audit: " + line));
logger.log("request handled");
javac Step6Unsafe.java
java Step6Unsafe
console: request handled
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
at Step6Unsafe$Logger.log(Step6Unsafe.java:18)
at Step6Unsafe.main(Step6Unsafe.java:32)
The second appender's own call to addAppender mutates the exact ArrayList the surrounding for-each is still walking, and the third appender, audit, never runs. corpus/logger's real Logger avoids this with CopyOnWriteArrayList instead of a plain ArrayList: every write to it copies the whole underlying array rather than mutating the one an in-progress read is using, so a notification in flight keeps iterating over a stable snapshot.
private final List<Appender> appenders = new CopyOnWriteArrayList<>();
javac Step6Safe.java
java Step6Safe
console: request handled
audit: request handled
second call sees the newly added appender too:
console: second request
audit: second request
late: second request
The first log call completes cleanly, including the audit appender that used to be cut off. The newly registered late appender simply is not part of the snapshot that first call was iterating over, so it does not run yet — and the second log call, started after registration finished, sees it. That trade-off, a write that is expensive because it copies the whole list, for a read that never has to guard against the list changing underneath it, is exactly right here: log runs far more often than addAppender does.
Why this matters in an interview
A listener list looks simple enough that candidates often stop as soon as it compiles and passes an obvious test. The two failures above are exactly what an interviewer probes for next. A logger, an event bus, or a pub-sub system that cannot survive one bad subscriber, or one subscriber that reacts by subscribing again, is not a design that would survive contact with a real system. Naming both failure modes unprompted, before being asked "what happens if," is a stronger signal than getting the interface right in the first place.
Next: chapter 3.5, State: a transition table you can read. Observer handles a source telling many listeners something happened. The next problem is different: one object whose own legal next moves depend on where it already is.
← 3.3 Builder: too many constructor arguments · All chapters · 3.5 State: a transition table you can read →