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
Your service needs to log what it is doing. To the console while a developer watches it run, and somewhere durable once it ships, so a line is not lost the moment the terminal closes.
The direct way is to write both inside the one method everything calls to log.
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. You can look at log and see precisely where a line goes, in two statements, with nothing hidden.
3. A new requirement
A third destination lands on you. A counter that only cares about errors, so an on-call engineer can see at a glance how many things have gone wrong.
So log needs to know how serious a message is. 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 you can still check it line by line against the requirement.
4. Watch where it goes, and the real cost
A fourth destination is next for you. A rolling file that starts a new file once the current one passes some size.
It has nothing to do with severity. And adding it still means opening log, because log is the only place a destination gets wired in.
Now put yourself in the shoes of whoever owns the rolling-file requirement. They have to read past the console line, the file line, and the error-counting line to find where theirs goes. Every one of those lines is a chance to introduce a typo in code they do not own.
The real cost is not the length of log. It is still only a handful of lines.
The cost is that every team wanting a new destination — or a new rule about which lines an existing destination wants — has to edit the one method your entire service depends on to log anything at all.
A change owned by the metrics team and a change owned by the logging team become edits to the same four lines. A mistake in either breaks logging for everyone, not just for the feature being added.
5. The move
Give log a list of subscribers instead of a fixed set. Then 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 your destination does with a line once it hands one over.
A rolling file now joins as one more call to addAppender. Nothing inside Logger.log changes to make room for it.
You have just built 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 in the original catalogue comes with a Subject interface, an Observer interface that both sides implement, and a registration protocol written out in full.
Inside a single JVM, most of that ceremony buys you 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.
There is a piece of JDK history you should know. java.util.Observer and Observable once shipped for exactly this purpose and were deprecated. The reason is instructive: they told a listener that something changed, with no way to say what, so every listener had to re-inspect the whole subject to find out.
What 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 one named anywhere in the requirements does not need a list of subscribers.
The two-line version from section 2 does exactly what it says. Wrap either line in an Appender before a second destination is asked for and you buy a file to read and a level of indirection nobody needed.
Your threshold: reach for a listener list once a second destination exists, or is explicitly named in the requirements. A single destination with no second in view scores lower under this app's grading standard than the two lines it replaces, tagged over-engineered (premature interface).
Watch a different boundary too. A change to how delivery itself works — stopping a slow destination from blocking every caller, say — touches Logger however 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 stops 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 you use a listener list for real, and most treatments of this pattern mention neither of them.
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 carry on. 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)
Trace what happened to you there. The console appender ran. The audit appender, registered after the one that threw, never got the chance. Then the exception escaped log entirely and crashed the caller.
A logging call must never 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 mutated while it is being notified throws ConcurrentModificationException.
This one will surprise you, because registering a new appender looks harmless on its own.
The trouble starts when one appender's reaction to a line is to register another appender, mid-notification. A for-each loop over a plain ArrayList keeps an internal count of how many changes the list has seen, and throws the moment that count shifts 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 very ArrayList the surrounding for-each is still walking. The third appender, audit, never runs.
corpus/logger's real Logger avoids this with CopyOnWriteArrayList. Every write 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 now completes cleanly, audit included. The newly registered late appender is simply not part of the snapshot that call was iterating over, so it does not run yet. The second log call, started after registration finished, sees it.
Look at the trade that makes. Writes get expensive, because each one copies the whole list. Reads never have to guard against the list changing underneath them. That is exactly the right way round here, because log runs far more often than addAppender does.
Why this matters in an interview
A listener list looks simple enough that candidates stop as soon as it compiles and passes an obvious test.
The two failures above are exactly what your interviewer probes 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 — would not survive contact with a real system.
So name both failure modes unprompted, before anyone asks "what happens if". That 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 →