When an order moves to the "paid" state, the system has to decrement stock, email the invoice, write an audit log entry and notify the accounting system. If OrderService.pay() calls all four directly, the class knows too much and has to change every time a new task appears. The Observer pattern solves exactly that: the object that emits events does not need to know who is listening, and listeners register and unregister themselves. This article goes from a hand-written implementation, through the common pitfalls, to what the JDK and Spring provide: PropertyChangeSupport, ApplicationEventPublisher and the Flow API. Every snippet uses only syntax and APIs available in Java 17 (checked with javac --release 17), and the output is pasted verbatim from a real run.

The previous article in the series, Strategy pattern with lambdas and functional interfaces, showed how lambdas remove subclasses. This article reuses that idea: most modern listeners are just a lambda.

What problem the Observer pattern solves

As described on refactoring.guru, the Observer pattern defines a subscription mechanism that notifies multiple objects about events that happen to the object they observe. It resolves a tension between two sides: receivers should not keep polling "has it changed yet?", and the sender should not push messages to anyone who does not care. The classic Design Patterns book by the GoF calls this pair of roles Subject and Observer.

RoleResponsibilityExample in this article
Subject (publisher)Holds the list of observers, lets them subscribe and unsubscribe, notifies them on eventsOrderService, EventBus
Observer (subscriber)Declares a method that receives notificationsConsumer<T>, PropertyChangeListener
EventThe data that travels with the notificationrecord OrderPaid(String id)
ClientCreates the subject and observers and wires them togetherThe main method or the Spring configuration

The main benefit of the Observer pattern is that it follows Open/Closed: adding a new observer does not touch the subject's code. The price is that notification order is not guaranteed, and control flow is harder to read because "who runs when this event happens" no longer lives in one place.

Implementing the Observer pattern by hand with lambdas

The most minimal Observer pattern needs only a list of Consumer objects and two operations. The subscribe method returns a Runnable that unsubscribes, which saves you from holding a reference to the listener itself:

static class NaiveSubject<T> {
    private final List<Consumer<T>> listeners = new ArrayList<>();
    Runnable subscribe(Consumer<T> l) {
        listeners.add(l);
        return () -> listeners.remove(l);
    }
    void publish(T event) {
        for (Consumer<T> l : listeners) l.accept(event);
    }
}

This version works in a single-threaded demo, but it has at least three bugs that real code will hit. The next section checks each one with a program called Pitfalls.java.

Three common pitfalls when you implement the Observer pattern yourself

Pitfall 1 of the Observer pattern: a listener unsubscribes itself while being called

A "one-shot" listener (receives the first event, then withdraws) is a very common need. With an ArrayList, people usually expect a ConcurrentModificationException. The actual result is subtler:

--- 1. A listener unsubscribes itself while being called (ArrayList) ---
  one-shot received A
--- 2. Same scenario with CopyOnWriteArrayList ---
  one-shot received A
  regular received A
  regular received B

In scenario 1 no exception is thrown, but the "regular" listener never receives event A. The reason is that after the first element is removed, size() drops from 2 to 1, which equals the iterator's cursor, so hasNext() returns false and the loop ends quietly. This is the most dangerous kind of bug because it leaves no trace. With three listeners in the list, a later run can throw ConcurrentModificationException. The standard fix is to iterate over a snapshot: CopyOnWriteArrayList lets the iterator read from the old array, so adding or removing mid-loop does not affect the current iteration, and it is also safe when several threads subscribe at once. In exchange, every add or remove copies the array, so it suits lists that change rarely and are traversed often, which is exactly the profile of a listener list.

Pitfall 2 of the Observer pattern: one failing listener breaks the whole chain

--- 3. One listener throws ---
  publish blew up; the second listener was not called
  listener failed, ignoring: broken
  second listener received X

If publish does not catch exceptions, a failing first listener steals the event from every listener after it, and the error also propagates back to the publisher (for example, OrderService sees an exception from the email module). The SafeSubject version wraps each call in try/catch and logs the failure. In the Observer pattern, this decision is a design policy, not a universal truth: if the listener is a mandatory step of a transaction, let the error surface; if it is only a side notification, isolate it.

Pitfall 3 of the Observer pattern: memory leaks from forgetting to unsubscribe

In the Observer pattern, the subject holds a strong reference to every observer, so a short-lived observer (for example, a listener tied to a screen or a request) registered on a long-lived subject is never released. This is the classic "lapsed listener problem". To prevent it, always return an unsubscribe handle as shown above, use try-with-resources when possible, or hold the observer through a WeakReference when the lifecycle is unclear (the trade-off is that the observer can disappear before anyone wants it gone).

The Observer pattern in the JDK: from Observable to PropertyChangeSupport

The JDK has shipped the Observer pattern since version 1.0 through java.util.Observable and Observer, but both classes are marked @Deprecated(since="9"). The Observable Javadoc spells out why: the event model is too limited, the notification order is unspecified, and state changes do not correspond one-to-one with notifications. The Javadoc suggests three replacements: the java.beans package for a richer event model, the data structures in java.util.concurrent for reliable message passing between threads, and the Flow API for reactive streams programming. Because Observable is a class (not an interface), your domain class is forced to extend it, which is also awkward when the class already has a parent.

The java.beans package is the lightest option when you need to track properties. PropertyChangeSupport manages the listener list, filters by property name, and skips the event when the old and new values are equal:

static class Order {
    private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);
    private String status = "NEW";

    void addListener(PropertyChangeListener l) { pcs.addPropertyChangeListener("status", l); }

    void setStatus(String s) {
        String old = this.status;
        this.status = s;
        pcs.firePropertyChange("status", old, s);   // does not fire if old.equals(new)
    }
}
status: NEW -> PAID
status: PAID -> SHIPPED

The second setStatus("PAID") call produces no line, exactly as the documentation describes. Swing, JavaBeans and many UI libraries use this mechanism. For server-side business code, it leans toward "a property changed" rather than "a business event happened", so people usually move to an event bus.

Event bus: a variant of the Observer pattern that fully decouples the publisher

In its basic form, an observer has to know the concrete subject to subscribe. An event bus (a variant of the Observer pattern, close to Publish/Subscribe) adds an intermediary: the publisher just pushes events into the bus, and listeners subscribe by event type. Neither side knows the other. Below is a minimal Observer pattern bus using a sealed interface and record types (both available since Java 17):

sealed interface OrderEvent permits OrderPlaced, OrderPaid {}
record OrderPlaced(String id, long amount) implements OrderEvent {}
record OrderPaid(String id) implements OrderEvent {}

static class EventBus {
    private final Map<Class<?>, List<Consumer<Object>>> handlers = new ConcurrentHashMap<>();

    <E> AutoCloseable on(Class<E> type, Consumer<? super E> handler) {
        Consumer<Object> wrapper = o -> handler.accept(type.cast(o));
        handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(wrapper);
        return () -> handlers.get(type).remove(wrapper);
    }

    void post(Object event) {
        for (var entry : handlers.entrySet()) {
            if (entry.getKey().isInstance(event)) {
                for (Consumer<Object> h : entry.getValue()) {
                    try { h.accept(event); }
                    catch (RuntimeException ex) { System.out.println("  handler failed: " + ex.getMessage()); }
                }
            }
        }
    }
}

Run with four handlers (one listening to all of OrderEvent, two listening to OrderPaid of which one deliberately fails), then unsubscribe the audit handler:

post OrderPlaced:
  audit: OrderPlaced[id=DH-1, amount=500000]
  stock: reserving items for DH-1
post OrderPaid:
  audit: OrderPaid[id=DH-1]
  mail: sending invoice for DH-1
  handler failed: SMTP down
after unsubscribing audit, post OrderPaid:
  mail: sending invoice for DH-2
  handler failed: SMTP down

The audit handler is registered on the parent type OrderEvent, so it receives both kinds of event thanks to isInstance. The SMTP failure is isolated, and the mail handler that ran before it was unaffected. Note that two handlers of the same type run in registration order, but the order between different types depends on ConcurrentHashMap, so you should not rely on it. This bus runs synchronously on the publisher's thread. Libraries such as Guava EventBus and Spring also default to synchronous delivery because it is easier to reason about and keeps the transaction context.

The Observer pattern in Spring: ApplicationEventPublisher and @EventListener

Spring already ships an Observer pattern event bus inside the ApplicationContext. According to the Spring Framework documentation, the model is based on ApplicationEvent and ApplicationListener, and is described outright as the standard "Observer design pattern". Since Spring 4.2 you can publish any object (extending ApplicationEvent is not required) and listen with the @EventListener annotation. The example below uses Spring 6.0.11 (released July 2023) with a record as the event:

record OrderPaid(String orderId, long amount) {}

static class OrderService {
    private final ApplicationEventPublisher publisher;
    OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; }

    void pay(String orderId, long amount) {
        System.out.println("[" + Thread.currentThread().getName() + "] OrderService: recorded payment " + orderId);
        publisher.publishEvent(new OrderPaid(orderId, amount));
        System.out.println("[" + Thread.currentThread().getName() + "] OrderService: publishEvent returned");
    }
}

static class MailListener {
    @EventListener @Order(2)
    void onPaid(OrderPaid e) { System.out.println("  MailListener received " + e); }
}

static class StockListener {
    @EventListener @Order(1)
    void onPaid(OrderPaid e) { System.out.println("  StockListener received " + e); }
}
[main] OrderService: recorded payment DH-1001
  StockListener received OrderPaid[orderId=DH-1001, amount=1250000]
  MailListener received OrderPaid[orderId=DH-1001, amount=1250000]
[main] OrderService: publishEvent returned

The output confirms three important things. First, with a Spring-style Observer pattern, OrderService knows nothing about the two listeners. Second, the listeners run on the main thread, the same thread as the publisher, and publishEvent returns only after both have finished, exactly as the Spring documentation says: "by default, event listeners receive events synchronously". Third, @Order decides the order, so StockListener (order 1) runs before MailListener even though it is declared after it.

Because delivery is synchronous, listeners sit inside the publisher's transaction. This is convenient (a rollback takes everything with it) but also dangerous: if you send an email from a synchronous listener and the transaction then rolls back, the customer still receives an invoice for an order that does not exist. Spring offers @TransactionalEventListener (since 4.2), which by default runs only after a successful commit (phase = AFTER_COMMIT) and suits side effects that reach outside the system. To run asynchronously, combine @EventListener with @Async; the documentation notes that exceptions in an asynchronous listener do not propagate back to the publisher, and that such a listener cannot return a follow-up event.

Flow API: the Observer pattern with back-pressure

The traditional Observer pattern is "push": the subject sends as much as it likes, and a slow observer either gets overwhelmed or blocks the publisher. Java 9 added java.util.concurrent.Flow, four nested interfaces that match the Reactive Streams specification. According to the Flow Javadoc, a Publisher produces items, a Subscriber receives them through onSubscribe, onNext, onError and onComplete, and a Subscription links the two with request(long n) to signal demand and cancel() to stop. The core difference: an item is delivered only when the subscriber has asked for it. That is back-pressure.

CriterionClassic Observer patternFlow API
Direction of controlThe subject pushes; the observer cannot control the rateThe subscriber drives with request(n)
Completion and errorsNo standard signalonComplete, onError
OrderingUnspecifiedCalls to a single subscriber are sequential
CostVery lowHigher: buffers, an executor, the Subscription lifecycle

The JDK provides SubmissionPublisher out of the box. According to its Javadoc, it delivers items asynchronously (by default through ForkJoinPool.commonPool()), each subscriber has its own buffer with a default maximum size of Flow.defaultBufferSize(), submit blocks the publisher when the buffer is full, and offer drops the item. The subscriber below is deliberately slow (100 ms per item), asks for only one item at a time, and has a buffer of just 4:

@Override public void onSubscribe(Flow.Subscription s) {
    this.sub = s;
    s.request(1);                       // ask for just 1 item
}
@Override public void onNext(Integer item) {
    Thread.sleep(delayMs);              // slow processing
    System.out.printf("  [%s] processed %d%n", ..., item);
    sub.request(1);                     // ask for the next only after finishing
}

Results when using submit (ten items). The time column is when submit returned, rounded to 10 ms, measured on a machine with 16 GB of RAM (timestamps drift by a few tens of ms between runs):

Flow.defaultBufferSize() = 256
  producer submit(1) at +0 ms, estimated lag 1
  producer submit(2) at +0 ms, estimated lag 2
  producer submit(3) at +0 ms, estimated lag 3
  producer submit(4) at +0 ms, estimated lag 4
  producer submit(5) at +0 ms, estimated lag 5
  [fj-1] processed 1
  producer submit(6) at +100 ms, estimated lag 5
  [fj-1] processed 2
  producer submit(7) at +210 ms, estimated lag 5
  ...
  producer submit(10) at +520 ms, estimated lag 5
  ...
  done

The first five items go into the buffer immediately, then the publisher is blocked: each following submit returns only after the subscriber finishes one item (the 100 ms gap matches the processing time). This is back-pressure: the publisher's speed is pulled down to the receiver's speed. The number 5 (not 4) comes from 4 items sitting in the buffer plus 1 item being processed by the subscriber inside onNext; because request(1) is called only after processing finishes, that item does not yet count as consumed.

Re-running with capacities 3, 4 and 8 gives estimated lags of 5, 5 and 9: the Javadoc allows capacity 3 to be rounded up to a power of 2 (becoming 4), so it also yields 5, while 4 and 8 are already powers of 2.

Note that submit returns only an estimate of the maximum lag, not an exact measurement, so do not use it as a counter. Here is the same scenario with offer instead of submit:

  dropped 5
  dropped 7
  dropped 8
  dropped 9
  dropped 10
  total dropped: 5
  [fj-1] processed 1
  [fj-1] processed 2
  [fj-1] processed 3
  [fj-1] processed 4
  [fj-1] processed 6
  done

The publisher does not wait, five items are discarded, and the subscriber receives only the rest (in this run 1, 2, 3, 4 and 6). Which item gets dropped depends on when the subscriber takes the first item out of the buffer, so it cannot be predicted: this time item 6 got in because the subscriber had just finished taking item 1, while item 5 did not. Choosing between submit and offer is choosing between "never lose data" and "never block": for payments choose the former, while real-time display metrics can choose the latter. For complex pipelines (map, filter, merging streams), libraries such as Project Reactor and RxJava implement operators on top of this specification; the JDK itself provides only the four interfaces and SubmissionPublisher.

When not to use the Observer pattern

The Observer pattern is not always the right choice. Consider alternatives in these cases:

  • Business flows that must run in order. If "decrement stock" must finish before "create shipment", call them explicitly. The Observer pattern makes ordering implicit and hard to test.
  • Only one listener, and always only one. A direct call is much clearer. Do not build a bus for two classes.
  • You need a return value. The Observer pattern is one-way; if the publisher needs an answer, use a plain interface or Strategy.
  • External side effects inside a transaction. Sending email or calling a third-party API from a synchronous listener can fall outside the rollback scope. Use AFTER_COMMIT or a durable queue (outbox) instead of trusting in-memory events.
  • Events must survive a restart. An in-memory event bus loses everything when the process dies. In that case you need a message broker, not the Observer pattern.

Checklist for implementing or reviewing the Observer pattern

  • With the Observer pattern, iterate the listener list over a snapshot (CopyOnWriteArrayList or copy before looping).
  • Decide the failure policy explicitly: isolate each listener or let exceptions surface.
  • Always provide a way to unsubscribe, and call it when the observer's lifecycle ends.
  • Document whether events run synchronously or asynchronously, on which thread, and inside which transaction.
  • Do not depend on listener order unless there is an explicit mechanism such as @Order.
  • In the Observer pattern, use immutable events (record) so listeners cannot modify each other's data.
  • When the producer is faster than the consumer, choose Flow and decide between submit and offer.
  • Do not use java.util.Observable in new code.

Summary

The Observer pattern separates the publisher from the listeners, and the same idea appears at several levels: a hand-written list of Consumer objects, PropertyChangeSupport in the JDK, ApplicationEventPublisher in Spring, and the Flow API with back-pressure. The hard part is not the structure but the decisions around it: synchronous or not, how errors are handled, who unsubscribes, and when the transaction ends. The measurements in this article come from real runs; the source code lives in code/s6-13/.

Next article

The Observer pattern lets you attach behavior from the outside. The next article looks at the opposite direction, where the parent class holds the algorithm skeleton and subclasses only fill in the steps: Template Method: algorithm skeletons and extension points. If you want to revisit how to organize objects as a tree first, read Composite pattern: treating object trees uniformly.