Syllabus · B4
Observer / listener — the side effect that does not know who is watching
The idea
Observer / listener
A logger needs to write to more than one place at once — the console while you develop, a file once it ships. The fast version hardcodes both: `System.out.println(line); fileWriter.write(line); inside log()`. That works until someone asks for a third destination, or wants the console to see everything while the file sees only warnings and up.
corpus/logger solves it the way addAppender is named for. Logger keeps a list of Registrations: an Appender, its own threshold, its own Formatter. log() builds one record and walks the list, calling write on whichever destinations clear their own bar. Logger never asks what an Appender does with a line; Appender has exactly one method, so there is nothing left to ask.
That not-knowing is the payoff, and it is measured, not assumed. curveballs/03-rolling-appender adds a destination that rolls over to a new file at a size limit, and reference/src does not change at all — reference_diff: 0, in budget.json. The new appender is a subscriber like any other.
It is not free everywhere. curveballs/01-async-appenders, which stops a slow destination from blocking the caller, touches Logger itself: reference_diff: 36. That curveball changes how delivery works, not only who is listening.
Reach for this seam when a side effect can be added or dropped without the source knowing what it does. When adding a listener means editing the source's own dispatch, you have not found the seam yet.
Worked walkthrough
NOTES — seven files, and the two lines that decide who finds out what happened
Run it first
..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out src\*.java
..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
javac -Xlint:all prints nothing and exits 0. Real output, verbatim:
1. two destinations, independent thresholds and formats
console (threshold DEBUG, brief format):
DEBUG: starting up
INFO: listening on port 8080
WARN: disk usage at 81%
ERROR: payment gateway timeout
audit (threshold WARN, detailed format):
[main] WARN - disk usage at 81%
[main] ERROR - payment gateway timeout
2. the same appender, registered again, under a second format
console now has 6 lines:
DEBUG: starting up
INFO: listening on port 8080
WARN: disk usage at 81%
ERROR: payment gateway timeout
ERROR: disk full
[main] ERROR - disk full
3. one subscriber throws; the others do not know
console still received it, log() did not throw:
INFO: scheduled backup complete
Two things in that output are worth stopping on before reading a line of Logger.
Block 1's audit never receives starting up or listening on port 8080 — its own threshold is WARN, and nothing about that decision lives anywhere near console's threshold of DEBUG. Block 2 registers console a second time, under ERROR and a different formatter. One error() call then produces two new lines in consoleLines, the same event rendered twice, because two registrations of one Appender are two subscribers, not one. Block 3 is the one to remember at 1am: the flaky destination throws on every call, and scheduled backup complete still reaches console, and main never sees an exception.
Appender.java and Formatter.java — the two questions kept separate
public interface Appender {
void write(String formattedLine);
}
One method, and it takes a String, never a LogRecord. Formatting happens once, in Logger.offer, before any destination is asked to do anything. Hand a destination the raw record instead, and every destination re-implements "look up my formatter and call it" — which makes Formatter a suggestion rather than a rule. console in Main is a lambda, consoleLines::add; that is the whole footprint an Appender needs.
public interface Formatter {
String format(LogRecord record);
}
Threshold and format are two separate questions, asked of two separate objects. Registration holds one answer to each, independently. Reading record.level() inside an Appender to decide whether to write would work for exactly one destination's opinion about filtering, and the moment a second destination disagrees, that logic has to be duplicated. Keeping filtering in Logger.log and formatting in Formatter is what makes console and audit free to disagree completely, as block 1 shows.
Registration.java — why a List<Registration>, not three parallel lists
public record Registration(Appender appender, Level threshold, Formatter formatter) {
}
This is the seam's storage decision, and it is the one thing not given by the contract. Three parallel lists (appenders, thresholds, formatters) cannot express "the same appender twice under two configurations" without the caller keeping the three lists in step by hand. Registration is the one-line answer: a value holding exactly what one subscription needs. "How many subscribers exist" is then answered by "how many Registrations are in the list" — block 2's second console entry is a second Registration, nothing more exotic.
Logger.java — the publisher
private final List<Registration> registrations = new ArrayList<>();
A list of subscriptions, not a list of destinations. If this field held List<Appender> instead, threshold and format would need somewhere else to live — parallel arrays, or fields inside Appender itself. Either one puts filtering inside the very thing Appender exists to keep ignorant of filtering.
public void addAppender(Appender appender, Level threshold, Formatter formatter) {
Objects.requireNonNull(appender, "appender");
Objects.requireNonNull(threshold, "threshold");
Objects.requireNonNull(formatter, "formatter");
registrations.add(new Registration(appender, threshold, formatter));
}
This method is the whole subscription. It stores the threshold and formatter it was handed — not a fixed one, not the previous caller's one. faded/GapTest.java checks exactly this: register a destination at ERROR and it must not see an INFO record, which fails the moment addAppender writes a threshold of its own choosing instead of the caller's.
LogRecord record = new LogRecord(level, message, Thread.currentThread().getName());
for (Registration registration : registrations) {
if (level.atLeast(registration.threshold())) {
offer(registration, record);
}
}
One record, built once, handed to every subscriber that clears its own bar. The loop walks every registration, not the first match and not the last one added. logger.error("disk full") in block 2 offers to all three current registrations. Two of them happen to be the same Appender under different configurations, and the loop does not know that and does not need to. Stop the loop after the first hit, and block 2's second console line never appears — that is the bug logFansOutToEveryDestination in GapTest is named after.
private void offer(Registration registration, LogRecord record) {
String line;
try {
line = registration.formatter().format(record);
} catch (RuntimeException formatFailed) {
return;
}
try {
registration.appender().write(line);
} catch (RuntimeException writeFailed) {
// this destination is broken; no other subscriber's copy of the record depends on it
}
}
Two separate try blocks, and that separation is deliberate. A Formatter that throws means there is no line to write, so that destination is skipped before write is ever called. An Appender that throws has already been handed a line. That failure is the destination's own problem, and it must never become log()'s problem.
Delete the second try and block 3's flaky appender exposes it immediately. write throws RuntimeException: no space left on device, and nothing inside offer catches it. The exception propagates out of log() on the next call that reaches flaky, so scheduled backup complete never reaches console either — the loop in log() never gets past flaky to try. One subscriber's bug becomes every subscriber's outage, which is the failure aThrowingDestinationDoesNotStopTheOthers in GapTest checks by name.
Nothing in Logger names a destination. Search this file for System.out or a file handle and you find neither. Logger can fan out to a console, a list, and a destination that throws on every call, and it never needs to know which is which.
Main.java — the driver
A missing driver caps D2 at 0, whatever Logger does. This one runs three scenarios rather than one happy path: independent configuration, the same subscriber registered twice, and a broken subscriber that does not take the others down with it.
Appender flaky = line -> { throw new RuntimeException("no space left on device"); };
logger.addAppender(flaky, Level.DEBUG, brief);
logger.info("scheduled backup complete");
This is the argument for the two try blocks, in three lines of output. After flaky is registered, console still receives the next message and main does not crash. Delete offer's second try and this block is where you would find out — not in a code review, in a stack trace with flaky's message on it.
When not to
When an observer seam is not worth its file
D3 level 3 reads "the seam set is minimal — no speculative interface with a single implementation and no foreseeable second one". An observer interface with one listener, registered once, at start-up, and never anywhere else, is exactly that.
The bad example
Say a ShoppingCart needs exactly one reaction to an item being added: recompute the total. Nothing else in the requirements reacts to that event, and nothing else is named as coming. The tempting version:
public interface CartListener {
void onItemAdded(Item item);
}
public final class ShoppingCart {
private final List<CartListener> listeners = new ArrayList<>();
public void addListener(CartListener listener) {
listeners.add(listener);
}
public void addItem(Item item) {
items.add(item);
for (CartListener listener : listeners) {
listener.onItemAdded(item);
}
}
}
And at the one call site that will ever exist:
cart.addListener(item -> total = total.add(item.price()));
What it costs
A registration API for a subscriber count that is always exactly one. addListener answers the question "how many things react to this?" — a question this cart's requirements already answered: one, forever, unless a requirement says otherwise.
A loop that always runs once. for (CartListener listener : listeners) is listeners.get(0).onItemAdded(item) wearing a costume, and the costume is what a reader has to look through to find that out.
Nowhere to see the total recompute by reading ShoppingCart. The one thing this class's requirement actually asks for now lives in a lambda at a call site somewhere else, behind an interface built for a fan-out that never happens.
Compare it to calling the method directly:
public void addItem(Item item) {
items.add(item);
recomputeTotal(item);
}
One line shorter, one file fewer. The requirement (recompute the total when an item is added) is a method name in the class whose job it is.
The threshold
Reach for the seam when one of these holds, and not otherwise:
- A second subscriber exists now, in the requirements or in the tests — a receipt printer and an inventory check both reacting to the same event, say.
- The set of reactions is meant to vary at runtime — plugins, or a UI that lets a user attach and detach handlers.
- The publisher and the reaction genuinely belong in different modules, so that even one reaction should not require the publisher to import it.
corpus/logger clears bar 1 on day one: two destinations sit in the base requirements, not imagined. That is why Logger earns the seam instead of paying for one it does not need. A cart with one fixed reaction has not cleared any of the three, and the method call is the right answer until a requirement says it changed.
Worked source
The 7 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/Appender.java8 linesworked/src/Formatter.java8 linesworked/src/Level.java13 linesworked/src/LogRecord.java7 linesworked/src/Logger.java63 linesworked/src/Registration.java7 linesworked/src/Main.java53 lines
worked/src/Appender.java8 lines
// Appender.java — one destination.
//
// Receives an already-formatted line and does whatever that destination does with it —
// print it, append it to a list, forward it somewhere else. It is never told how many
// other destinations exist, and it is never asked to filter or format anything itself.
public interface Appender {
void write(String formattedLine);
}
worked/src/Formatter.java8 lines
// Formatter.java — renders one record as text.
//
// A destination's format and its threshold are chosen independently at registration
// time, so this interface knows nothing about thresholds — only how to turn a record
// into the String a destination will receive.
public interface Formatter {
String format(LogRecord record);
}
worked/src/Level.java13 lines
// Level.java — the ordering every threshold check rests on.
//
// Ordered from least to most severe. A destination's threshold is a Level, and "does
// this record clear it" is one comparison, defined once here so every subscriber answers
// the same question the same way.
public enum Level {
DEBUG, INFO, WARN, ERROR, FATAL;
/** True when this level is at least as severe as {@code threshold}, inclusive. */
public boolean atLeast(Level threshold) {
return this.ordinal() >= threshold.ordinal();
}
}
worked/src/LogRecord.java7 lines
// LogRecord.java — one event.
//
// Built once per call to Logger.log and handed to every subscriber unchanged. A value:
// two records with the same three fields are the same record, and nothing here mutates
// after construction.
public record LogRecord(Level level, String message, String threadName) {
}
worked/src/Logger.java63 lines
// Logger.java — the publisher.
//
// Holds no rule about what a destination does with a line. Every destination decides
// that for itself, behind Appender and Formatter. Logger's whole job is: build one
// record, then offer it to every subscriber whose own threshold it clears.
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class Logger {
private final List<Registration> registrations = new ArrayList<>();
/**
* Subscribes one destination. The same appender may be registered more than once,
* each time under its own threshold and formatter — two registrations of the same
* Appender behave as two independent subscribers, and neither knows the other exists.
*/
public void addAppender(Appender appender, Level threshold, Formatter formatter) {
Objects.requireNonNull(appender, "appender");
Objects.requireNonNull(threshold, "threshold");
Objects.requireNonNull(formatter, "formatter");
registrations.add(new Registration(appender, threshold, formatter));
}
/** Records one event and offers it to every subscriber whose threshold it clears. */
public void log(Level level, String message) {
if (level == null) {
throw new IllegalArgumentException("level must not be null");
}
Objects.requireNonNull(message, "message");
LogRecord record = new LogRecord(level, message, Thread.currentThread().getName());
for (Registration registration : registrations) {
if (level.atLeast(registration.threshold())) {
offer(registration, record);
}
}
}
/**
* One subscriber, one record. A subscriber that throws is skipped for this record;
* every other subscriber still runs, and log() never throws because one of them broke.
*/
private void offer(Registration registration, LogRecord record) {
String line;
try {
line = registration.formatter().format(record);
} catch (RuntimeException formatFailed) {
return;
}
try {
registration.appender().write(line);
} catch (RuntimeException writeFailed) {
// this destination is broken; no other subscriber's copy of the record depends on it
}
}
public void debug(String message) { log(Level.DEBUG, message); }
public void info(String message) { log(Level.INFO, message); }
public void warn(String message) { log(Level.WARN, message); }
public void error(String message) { log(Level.ERROR, message); }
}
worked/src/Registration.java7 lines
// Registration.java — one subscription.
//
// An Appender plus the threshold and formatter it was registered with. A value, stored
// as data in a list rather than as parallel fields on Logger — which is what lets the
// same Appender subscribe twice under two different configurations.
public record Registration(Appender appender, Level threshold, Formatter formatter) {
}
worked/src/Main.java53 lines
// Main.java — the driver.
//
// Registers three subscribers with independent thresholds and formats, including the
// same appender twice under two configurations, and shows one broken subscriber not
// stopping the other two.
import java.util.ArrayList;
import java.util.List;
public final class Main {
public static void main(String[] args) {
Logger logger = new Logger();
List<String> consoleLines = new ArrayList<>();
Appender console = consoleLines::add;
List<String> auditLines = new ArrayList<>();
Appender audit = auditLines::add;
Formatter brief = record -> record.level() + ": " + record.message();
Formatter detailed = record ->
"[" + record.threadName() + "] " + record.level() + " - " + record.message();
System.out.println("1. two destinations, independent thresholds and formats");
logger.addAppender(console, Level.DEBUG, brief);
logger.addAppender(audit, Level.WARN, detailed);
logger.debug("starting up");
logger.info("listening on port 8080");
logger.warn("disk usage at 81%");
logger.error("payment gateway timeout");
System.out.println(" console (threshold DEBUG, brief format):");
consoleLines.forEach(line -> System.out.println(" " + line));
System.out.println(" audit (threshold WARN, detailed format):");
auditLines.forEach(line -> System.out.println(" " + line));
System.out.println("2. the same appender, registered again, under a second format");
logger.addAppender(console, Level.ERROR, detailed);
logger.error("disk full");
System.out.println(" console now has " + consoleLines.size() + " lines:");
consoleLines.forEach(line -> System.out.println(" " + line));
System.out.println("3. one subscriber throws; the others do not know");
Appender flaky = line -> { throw new RuntimeException("no space left on device"); };
logger.addAppender(flaky, Level.DEBUG, brief);
logger.info("scheduled backup complete");
System.out.println(" console still received it, log() did not throw:");
System.out.println(" " + consoleLines.get(consoleLines.size() - 1));
}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.