Observer, listeners and callbacks
Something happened, and several parts of the system want to know. The naive version is a method that does all of it:
public void recordDelivery(Delivery delivery) {
store.append(delivery);
invoiceService.update(delivery);
smsSender.notifyCustomer(delivery);
auditLog.write("delivery recorded: " + delivery);
dashboard.refresh();
}
Recording a delivery now means knowing about invoices, SMS, auditing and a dashboard. Adding a sixth thing means editing this method. Testing it means providing five collaborators to test one line.
The pattern
The thing that knows something happened publishes it. The things that care subscribe. Neither knows the other's type.
@FunctionalInterface
public interface DeliveryListener {
void onDelivery(Delivery delivery);
}
public final class DeliveryRecorder {
private final DeliveryStore store;
// CopyOnWriteArrayList, not ArrayList: a listener that subscribes or
// unsubscribes while we are iterating would otherwise throw
// ConcurrentModificationException — and that is a real thing listeners do.
private final List<DeliveryListener> listeners = new CopyOnWriteArrayList<>();
public DeliveryRecorder(DeliveryStore store) {
this.store = store;
}
public Runnable addListener(DeliveryListener listener) {
listeners.add(listener);
// Hand back the way to undo it. A subscribe with no unsubscribe is a
// memory leak waiting to be written.
return () -> listeners.remove(listener);
}
public void record(Delivery delivery) {
store.append(delivery);
publish(delivery);
}
private void publish(Delivery delivery) {
for (DeliveryListener listener : listeners) {
try {
listener.onDelivery(delivery);
} catch (RuntimeException e) {
// One broken listener must not stop the others, and must not
// fail the delivery. It is already recorded; this is
// notification.
System.getLogger("delivery").log(System.Logger.Level.WARNING,
"listener failed", e);
}
}
}
}
recorder.addListener(d -> invoices.update(d));
recorder.addListener(d -> sms.notifyCustomer(d));
recorder.addListener(d -> audit.write("delivery recorded: " + d));
DeliveryRecorder now knows about a store and a list of listeners. It has never
heard of SMS.
The four details that matter
Most observer bugs are one of these.
Iterate over a safe copy. A listener that unsubscribes itself while being
notified — extremely common, because "tell me once then stop" is a normal thing
to want — will throw ConcurrentModificationException on a plain ArrayList.
CopyOnWriteArrayList makes iteration safe.
A failing listener must not fail the publisher. The delivery is already recorded. If the SMS gateway is down, that is a message problem, not a delivery problem. Catch, log, continue. This is the same rule module 13's notifications followed, and it is one of the few places swallowing an exception is right.
Return an unsubscribe. Returning a Runnable from addListener means the
caller cannot lose the handle needed to detach. A listener that is never removed
keeps its enclosing object alive, which is the classic Java memory leak: a
long-lived publisher holding a short-lived listener forever.
Publish after the fact. store.append first, publish second. Notifying
that something happened before it has happened produces listeners acting on
state that is not there yet.
Order is not a contract
Listeners are notified in some order, and it is tempting to rely on it — "the invoice listener runs before the SMS listener, so the SMS can mention the invoice number."
Do not. The moment two listeners depend on each other's effects, you have a sequence dressed up as a broadcast, and it will break when somebody reorders two lines of setup. If A must happen before B, that is one listener doing both, or a method — not two observers and a hope.
Where Java already does this
You have met it repeatedly:
button.addActionListener(e -> save()); // Swing
future.thenAccept(result -> render(result)); // CompletableFuture
Every addXListener in the JDK is this pattern, and PropertyChangeSupport in
java.beans is a ready-made implementation if you want one.
Java once had java.util.Observable and java.util.Observer built in. Both
were deprecated in Java 9 — Observable was a class, so observing anything
spent your one extends, it was not serialisable usefully, and it had no
ordering or threading guarantees. If you find them in old code, that is why they
should not be copied.
When not to use it
Observer buys decoupling and charges for it in traceability.
With a direct call, you read the method and see everything that happens. With
observers, you read the method and see publish(delivery) — and finding what
actually happens means searching for every addListener call in the codebase.
Debugging becomes "set a breakpoint and see who turns up".
So: use it when the publisher genuinely should not know its audience — when listeners come and go, when they are registered by different modules, when a plugin might add one.
Do not use it for three things that always happen in the same order. That is a method, and a method you can read.
Check your work
What the pattern decouples: the thing that knows something happened from the things that care.
Why CopyOnWriteArrayList: a listener unsubscribing itself during
notification would otherwise throw.
Why a failing listener is caught: the event already happened, and notification failing must not undo it.
Why addListener returns a Runnable: so the caller cannot lose the
unsubscribe, and the publisher does not hold listeners forever.
Why publish after the action: otherwise listeners act on state that does not exist yet.
Why not to rely on order: it turns a broadcast into a hidden sequence that breaks when setup is reordered.
Why java.util.Observable was deprecated: it was a class, so it consumed
your one extends, with no ordering or threading guarantees.
The cost of the pattern: traceability. Reading the publisher no longer tells you what happens.
When not to use it: three things that always happen in the same order.
Practice
- Write
DeliveryRecorderwith two listeners and confirm both fire. - Replace
CopyOnWriteArrayListwithArrayList, then have a listener unsubscribe itself insideonDelivery. Read the exception. - Make one listener throw. Confirm the other still runs and the delivery is still stored.
- Remove the
try/catchand repeat. Decide which behaviour you would want at 9pm on a Friday. - Add a listener without keeping the returned
Runnable. Explain how you would ever remove it. - Move
publishabovestore.appendand write a listener that reads the store. - Write two listeners where the second depends on the first, then swap their registration order.
- Find three
addXListenermethods in the JDK. - Look up why
java.util.Observablewas deprecated and summarise it in one sentence. - Take the five-line
recordDeliveryfrom the top of this lesson. Argue that it should stay exactly as it is.
Next: spotting all of these in code you did not write.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship