Observer
Core. Expect to meet this one, and expect to be asked for it by name.
Start with the problem
A logger needs to write to more than one place: the console while you are developing, a file once the service ships. The direct version writes both, right inside the one method that logs a line.
void log(String message) {
System.out.println(message);
fileWriter.write(message);
}
For two fixed destinations, this reads fine. Anyone looking at log can see exactly where a line goes.
Watch where it goes
A third destination arrives: a metrics counter that only cares about errors. log grows a branch. Then someone asks for the console to see every line while the file sees only warnings and up. The branch has to start checking severity too, one if per destination, each with its own condition.
The real cost is not the length of log. Every new destination, and every new rule about which lines a destination wants, means opening the one method every caller in the service depends on. A rolling file that starts a new file past some size is next on the list. It has nothing to do with severity at all, but it still means editing log, because that is the only place a destination gets wired in.
The move
Give log a list of subscribers instead of a hardcoded pair, and let each subscriber decide for itself whether a given line is any of its business.
public final class Logger {
private final List<Registration> registrations = new CopyOnWriteArrayList<>();
public void addAppender(Appender appender, Level threshold, Formatter formatter) {
registrations.add(new Registration(appender, threshold, formatter));
}
public void log(Level level, String message) {
LogRecord record = new LogRecord(clock.now(), level, message, Thread.currentThread().getName());
for (Registration r : registrations) {
if (level.compareTo(r.threshold()) >= 0) {
r.appender().write(r.formatter().format(record));
}
}
}
}
This is close to corpus/logger's Logger. Appender has exactly one method, write(String), so Logger never learns what a destination does with a line once it receives one. A rolling file joins the list as one more Registration, and nothing inside Logger.log changes to make room for it — measured on this corpus's own curveball at a reference_diff of 0.
What modern Java changes here
The Gang of Four version of this pattern usually comes with a Subject interface, an Observer interface, and a registration protocol both sides implement. In a single JVM, that ceremony is rarely worth it. Appender has one abstract method, so it is a functional interface, and a destination that needs no state of its own can be a lambda passed straight to addAppender rather than a named class.
logger.addAppender(line -> auditTrail.add(line), Level.WARN, Formatter.oneLine());
java.beans.PropertyChangeListener and the old java.util.Observer class that once shipped in the JDK are both closer to legacy plumbing than to something worth reaching for today. The latter was deprecated because it offered no way to say what changed, only that something did. 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. Logger's List<Registration> already is that, with no framework underneath it.
When naming it is wrong
A logger with exactly one destination, hardcoded, and no second destination named anywhere in the requirements does not need a list of subscribers. Two lines inside log, one per destination, do exactly what they say. Wrapping either one in an Appender before a second destination is even asked for buys nothing but an extra file to read.
The threshold: reach for a listener list once a second destination exists, or is named in the requirements. The other trigger is one existing destination needing a rule the others do not share, the way a size-triggered rollover does. A single destination with no second one in view scores lower under the Standard's D3 dimension than the two lines it replaced. The tag for that is over-engineered (premature interface).
Watch the boundary too. A change to how delivery itself works, such as making slow destinations stop blocking every caller, touches Logger no matter how many subscribers exist. That change is measured at a reference_diff of 36 on this corpus. The listener list absorbs a new subscriber for free. It does not absorb a change to its own dispatch mechanism for free.
Where this lives in the app
Syllabus item B4 builds this against corpus/logger. It measures a rolling-file destination that costs nothing against a dispatch change that costs 36 lines. The seam's real boundary is something you can point to, not something you take on faith.