On This Page
What you'll have when you're done
Here's the output of the finished program. A warehouse tracks mug stock, and three separate pieces of code react to every change without the warehouse knowing they exist:
LOW STOCK: MUG-BLUE is down to 4
SOLD OUT: MUG-BLUE
--- audit ---
MUG-BLUE +8 (0 -> 8)
MUG-BLUE -2 (8 -> 6)
MUG-BLUE -2 (6 -> 4)
MUG-BLUE -1 (4 -> 3)
MUG-BLUE -3 (3 -> 0)
That's the observer pattern in Java: one object (the subject) announces that something happened, and any number of other objects (the observers, or listeners) react. The subject never imports them, never names them, and never cares how many there are.
You'll write it from scratch with nothing but the JDK, in about 175 lines. The version you'll end up with handles the three things most tutorials skip: a listener that throws, a listener that unsubscribes while an event is being delivered, and several threads changing stock at once. This is for you if you've seen the pattern in a textbook with Observable and Observer and wondered what you'd actually write today.
flowchart LR
W[Inventory<br/>the subject] -->|StockChanged| A[AuditLog]
W -->|StockChanged| L[LowStockAlert]
W -->|StockChanged| X[lambda: sold out]
What you need to follow along
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 | java -version |
| Build | plain javac (no Maven or Gradle needed) |
javac -version |
| IntelliJ IDEA or Eclipse | optional, any current release | Help → About |
Any JDK from 17 up compiles this; records need 16+ and I ran it on 21. You should be comfortable reading Java and know what an interface is. If records are new to you, my records tutorial covers them, and the immutable class guide explains why the event below is a record.
I wrote this one on the command line because it needs no dependencies, which also means it behaves the same on a bare machine as on yours.
Lay out the inventory project
Make the folders and move into the project:
mkdir -p observer/src/main/java/dev/ggorantala/inventory
cd observer
In IntelliJ IDEA or Eclipse
I didn't click through these, so treat them as docs-based steps. In IntelliJ IDEA: File → New → Project, choose Java, set the JDK to 21, build system IntelliJ, and name it observer. In Eclipse: File → New → Java Project, set the JRE to 21. In both, create the package dev.ggorantala.inventory and put the files below into it.
The final tree:
observer/
└── src/main/java/dev/ggorantala/inventory/
├── StockChanged.java
├── StockListener.java
├── Subscription.java
├── Inventory.java
├── LowStockAlert.java
├── AuditLog.java
└── Demo.java
How to implement the observer pattern in Java, step by step
The compiling part took minutes. What took thought was step 4, where I had to decide what happens when a listener misbehaves.
Step 1: Define the event
Goal: describe what happened, as data, so every listener gets the same facts.
src/main/java/dev/ggorantala/inventory/StockChanged.java
package dev.ggorantala.inventory;
import java.time.Instant;
/** Immutable fact: the stock level of one SKU moved from oldQty to newQty. */
public record StockChanged(String sku, int oldQty, int newQty, Instant at) {
public StockChanged {
if (sku == null || sku.isBlank()) {
throw new IllegalArgumentException("sku must not be blank");
}
}
public int delta() {
return newQty - oldQty;
}
}
A record fits well because an event is a fact about the past. Nobody should edit it after it's published, and with one instance shared across all listeners, immutability keeps one listener from changing what the next one sees. The compact constructor rejects a blank SKU early.
Checkpoint: nothing to run yet.
Step 2: Define the observer interface
Goal: give listeners one method to implement.
src/main/java/dev/ggorantala/inventory/StockListener.java
package dev.ggorantala.inventory;
/** The observer. One method, so lambdas and method references work. */
@FunctionalInterface
public interface StockListener {
void onStockChanged(StockChanged event);
}
One method means a lambda or method reference works as a listener. That's the main modern change from the old two-class textbook version: you rarely need a named class for a three-line reaction.
Step 3: Define the unsubscribe handle
Goal: let a caller stop listening without holding a reference to the subject's internals.
src/main/java/dev/ggorantala/inventory/Subscription.java
package dev.ggorantala.inventory;
/** Handle returned by subscribe(). Close it to stop receiving events. */
public interface Subscription extends AutoCloseable {
@Override
void close();
}
Overriding close() without throws Exception is deliberate: callers can use it in try-with-resources without wrapping it in a try/catch.
Step 4: Write the subject
Goal: keep stock levels, accept subscribers, and publish events safely.
src/main/java/dev/ggorantala/inventory/Inventory.java
package dev.ggorantala.inventory;
import java.time.Clock;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.BiConsumer;
/** The subject. Owns the stock levels and tells listeners when one changes. */
public class Inventory {
private final Map<String, Integer> stock = new HashMap<>();
private final List<StockListener> listeners = new CopyOnWriteArrayList<>();
private final BiConsumer<StockListener, Throwable> onListenerFailure;
private final Clock clock;
public Inventory() {
this(Clock.systemUTC(), (l, t) -> System.err.println("listener failed: " + t));
}
public Inventory(Clock clock, BiConsumer<StockListener, Throwable> onListenerFailure) {
this.clock = Objects.requireNonNull(clock);
this.onListenerFailure = Objects.requireNonNull(onListenerFailure);
}
public Subscription subscribe(StockListener listener) {
Objects.requireNonNull(listener, "listener");
listeners.add(listener);
return () -> listeners.remove(listener);
}
public synchronized int quantity(String sku) {
return stock.getOrDefault(sku, 0);
}
public void receive(String sku, int units) {
adjust(sku, units);
}
public void ship(String sku, int units) {
adjust(sku, -units);
}
private void adjust(String sku, int delta) {
StockChanged event;
synchronized (this) {
int old = stock.getOrDefault(sku, 0);
int updated = old + delta;
if (updated < 0) {
throw new IllegalStateException(
"only " + old + " of " + sku + " in stock, cannot remove " + -delta);
}
stock.put(sku, updated);
event = new StockChanged(sku, old, updated, clock.instant());
}
// Notify outside the lock so a slow listener can't block other writers.
publish(event);
}
private void publish(StockChanged event) {
for (StockListener listener : listeners) { // snapshot iteration
try {
listener.onStockChanged(event);
} catch (RuntimeException e) {
onListenerFailure.accept(listener, e);
}
}
}
int listenerCount() {
return listeners.size();
}
}
Four decisions are packed in here, and each one fixes a real bug:
CopyOnWriteArrayListgives you a snapshot when you iterate. A listener can subscribe or unsubscribe during delivery without breaking the loop. I show the failure it prevents in the troubleshooting section.try/catcharound each listener. One bad listener shouldn't stop the ones after it, and it shouldn't undo a stock change that has already happened. The failure goes to a handler you control.- Publishing outside the
synchronizedblock. The state change is atomic, but listeners run after the lock is released, so a slow listener can't stall other threads, and a listener that calls back intoInventorycan't deadlock. subscribereturns aSubscription. Removing a lambda later is awkward because you'd need the same instance; the handle solves that.
I also injected a Clock instead of calling Instant.now() so tests can pin the timestamp.
Checkpoint: compile everything.
mkdir -p out
javac -d out src/main/java/dev/ggorantala/inventory/*.java
No output means success.
Step 5: Write two real listeners
Goal: show that new behaviour is a new class, not a change to Inventory.
src/main/java/dev/ggorantala/inventory/LowStockAlert.java
package dev.ggorantala.inventory;
import java.util.function.Consumer;
/** Fires once when stock falls to or below the threshold, not on every sale after that. */
public class LowStockAlert implements StockListener {
private final int threshold;
private final Consumer<String> sink;
public LowStockAlert(int threshold, Consumer<String> sink) {
this.threshold = threshold;
this.sink = sink;
}
@Override
public void onStockChanged(StockChanged e) {
boolean crossedDown = e.oldQty() > threshold && e.newQty() <= threshold;
if (crossedDown) {
sink.accept("LOW STOCK: " + e.sku() + " is down to " + e.newQty());
}
}
}
The condition oldQty > threshold && newQty <= threshold fires on the crossing, not on every sale below the line. Without the oldQty half you'd send a "low stock" message for every unit sold after the first.
src/main/java/dev/ggorantala/inventory/AuditLog.java
package dev.ggorantala.inventory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Keeps a line for every change. Handy for tests and for "what just happened?" */
public class AuditLog implements StockListener {
private final List<String> lines = Collections.synchronizedList(new ArrayList<>());
@Override
public void onStockChanged(StockChanged e) {
lines.add(String.format("%s %+d (%d -> %d)", e.sku(), e.delta(), e.oldQty(), e.newQty()));
}
public List<String> lines() {
synchronized (lines) {
return List.copyOf(lines);
}
}
}
Step 6: Wire it together
Goal: subscribe three listeners and cause some changes.
src/main/java/dev/ggorantala/inventory/Demo.java
package dev.ggorantala.inventory;
public class Demo {
public static void main(String[] args) {
Inventory inventory = new Inventory();
AuditLog audit = new AuditLog();
inventory.subscribe(audit);
inventory.subscribe(new LowStockAlert(5, System.out::println));
inventory.subscribe(e -> {
if (e.newQty() == 0) System.out.println("SOLD OUT: " + e.sku());
});
inventory.receive("MUG-BLUE", 8);
inventory.ship("MUG-BLUE", 2);
inventory.ship("MUG-BLUE", 2); // 8 -> 6 -> 4 : crosses the threshold of 5
inventory.ship("MUG-BLUE", 1); // 4 -> 3 : already low, no second alert
inventory.ship("MUG-BLUE", 3); // 3 -> 0
System.out.println("--- audit ---");
audit.lines().forEach(System.out::println);
}
}
The third listener is a lambda defined inline. The Inventory class has no idea it exists.
Run it with three listeners
javac -d out src/main/java/dev/ggorantala/inventory/*.java
java -cp out dev.ggorantala.inventory.Demo
LOW STOCK: MUG-BLUE is down to 4
SOLD OUT: MUG-BLUE
--- audit ---
MUG-BLUE +8 (0 -> 8)
MUG-BLUE -2 (8 -> 6)
MUG-BLUE -2 (6 -> 4)
MUG-BLUE -1 (4 -> 3)
MUG-BLUE -3 (3 -> 0)
If you see exactly one LOW STOCK line, the crossing logic works. The step from 4 to 3 stayed quiet, which is the point.
Prove it with 16 checks
My environment couldn't download JUnit, so I wrote the checks as a plain main that prints PASS or FAIL and exits non-zero on failure. They map one to one onto JUnit 5 @Test methods when you're ready (see the JUnit 5 tutorial once it's up).
src/test/java/dev/ggorantala/inventory/Checks.java
package dev.ggorantala.inventory;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class Checks {
static int passed, failed;
static void check(String name, boolean ok) {
if (ok) { passed++; System.out.println("PASS " + name); }
else { failed++; System.out.println("FAIL " + name); }
}
public static void main(String[] args) throws Exception {
List<String> alerts = new ArrayList<>();
Inventory inv = new Inventory();
AuditLog audit = new AuditLog();
inv.subscribe(audit);
inv.subscribe(new LowStockAlert(5, alerts::add));
inv.receive("A", 8); inv.ship("A", 2); inv.ship("A", 2); inv.ship("A", 1);
check("audit records every change", audit.lines().size() == 4);
check("low-stock alert fires once on the crossing", alerts.equals(List.of("LOW STOCK: A is down to 4")));
List<StockChanged> seen = new ArrayList<>();
Subscription s = inv.subscribe(seen::add);
inv.receive("B", 1);
s.close();
inv.receive("B", 1);
check("close() stops delivery", seen.size() == 1);
check("close() twice is harmless", noThrow(s::close));
try (Subscription t = inv.subscribe(seen::add)) { inv.receive("C", 1); }
inv.receive("C", 1);
check("try-with-resources unsubscribes", seen.size() == 2);
List<String> failures = new ArrayList<>();
Inventory inv2 = new Inventory(Clock.systemUTC(), (l, t) -> failures.add(t.getMessage()));
List<String> after = new ArrayList<>();
inv2.subscribe(e -> { throw new IllegalStateException("boom"); });
inv2.subscribe(e -> after.add(e.sku()));
inv2.receive("D", 1);
check("a failing listener doesn't block the next one", after.equals(List.of("D")));
check("failure goes to the handler", failures.equals(List.of("boom")));
check("state still updated after listener failure", inv2.quantity("D") == 1);
Inventory inv3 = new Inventory();
AtomicInteger once = new AtomicInteger();
Subscription[] self = new Subscription[1];
self[0] = inv3.subscribe(e -> { once.incrementAndGet(); self[0].close(); });
inv3.receive("E", 1); inv3.receive("E", 1);
check("a listener can unsubscribe itself mid-notification", once.get() == 1);
Inventory inv4 = new Inventory();
AtomicInteger late = new AtomicInteger();
inv4.subscribe(e -> inv4.subscribe(x -> late.incrementAndGet()));
inv4.receive("F", 1);
check("listener added mid-notification misses the current event", late.get() == 0);
Inventory inv5 = new Inventory();
List<StockChanged> ev = new ArrayList<>();
inv5.subscribe(ev::add);
boolean threw = false;
try { inv5.ship("G", 1); } catch (IllegalStateException e) { threw = true; }
check("overselling throws and publishes nothing", threw && ev.isEmpty() && inv5.quantity("G") == 0);
Clock fixed = Clock.fixed(Instant.parse("2026-10-25T10:00:00Z"), ZoneOffset.UTC);
Inventory inv6 = new Inventory(fixed, (l, t) -> {});
List<StockChanged> ev6 = new ArrayList<>();
inv6.subscribe(ev6::add);
inv6.receive("H", 3);
check("injected clock stamps the event", ev6.get(0).at().equals(Instant.parse("2026-10-25T10:00:00Z")));
check("blank sku rejected", throwsIAE(() -> new StockChanged(" ", 0, 1, Instant.now())));
check("null listener rejected", throwsNPE(() -> inv6.subscribe(null)));
Inventory inv7 = new Inventory();
AtomicInteger count = new AtomicInteger();
inv7.subscribe(e -> count.incrementAndGet());
ExecutorService pool = Executors.newFixedThreadPool(8);
List<Future<?>> fs = new ArrayList<>();
for (int i = 0; i < 8; i++) fs.add(pool.submit(() -> { for (int j = 0; j < 1000; j++) inv7.receive("Z", 1); }));
for (Future<?> f : fs) f.get();
pool.shutdown();
check("8 threads x 1000 receives: 8000 events", count.get() == 8000);
check("8 threads x 1000 receives: quantity 8000", inv7.quantity("Z") == 8000);
System.out.printf("%n%d passed, %d failed%n", passed, failed);
if (failed > 0) System.exit(1);
}
static boolean noThrow(Runnable r) { try { r.run(); return true; } catch (Exception e) { return false; } }
static boolean throwsIAE(Runnable r) { try { r.run(); return false; } catch (IllegalArgumentException e) { return true; } }
static boolean throwsNPE(Runnable r) { try { r.run(); return false; } catch (NullPointerException e) { return true; } }
}
mkdir -p out-test
javac -d out-test -cp out src/test/java/dev/ggorantala/inventory/Checks.java
java -cp out:out-test dev.ggorantala.inventory.Checks
On Windows use ; instead of : in the classpath.
PASS audit records every change
PASS low-stock alert fires once on the crossing
PASS close() stops delivery
PASS close() twice is harmless
PASS try-with-resources unsubscribes
PASS a failing listener doesn't block the next one
PASS failure goes to the handler
PASS state still updated after listener failure
PASS a listener can unsubscribe itself mid-notification
PASS listener added mid-notification misses the current event
PASS overselling throws and publishes nothing
PASS injected clock stamps the event
PASS blank sku rejected
PASS null listener rejected
PASS 8 threads x 1000 receives: 8000 events
PASS 8 threads x 1000 receives: quantity 8000
16 passed, 0 failed
My first run showed 15 passed and 1 failed. The failing check was my own: I'd said the audit would hold 5 lines when four changes had happened. The code was right and my arithmetic was wrong.
Why events are published outside the lock
When ship runs, three things happen in order: the quantity changes inside a synchronized block, the event gets built, and then the lock is released and each listener runs. Doing it in that order means listeners always see the state after the change, and a listener can safely call quantity() again.
The listener list is a CopyOnWriteArrayList. Every add or remove copies the underlying array, and iteration walks the old copy. That's expensive if you subscribe constantly, but listener lists are read far more often than they change, which is the case this class was built for. If you want the internals, my CopyOnWriteArrayList article goes further.
The Subscription is just a lambda that removes the same listener instance. remove on a missing element returns false, which is why closing twice is harmless.
Three ways I broke it
I wrote a naive subject on purpose (a plain ArrayList and a bare for loop) to show what the choices above prevent.
src/test/java/dev/ggorantala/inventory/Traps.java
package dev.ggorantala.inventory;
import java.util.ArrayList;
import java.util.List;
/** Three ways a hand-rolled subject goes wrong. Not part of the finished project. */
public class Traps {
interface L { void on(String s); }
static class NaiveSubject {
final List<L> listeners = new ArrayList<>();
void fire(String s) { for (L l : listeners) l.on(s); }
}
public static void main(String[] args) {
NaiveSubject s = new NaiveSubject();
s.listeners.add(new L() { public void on(String x) { s.listeners.remove(this); } });
s.listeners.add(x -> System.out.println("second listener got " + x));
s.listeners.add(x -> System.out.println("third listener got " + x));
try { s.fire("evt"); } catch (Exception e) { System.out.println("trap 1: " + e.getClass().getName()); }
NaiveSubject s2 = new NaiveSubject();
s2.listeners.add(x -> { throw new RuntimeException("bad listener"); });
s2.listeners.add(x -> System.out.println("this never prints"));
try { s2.fire("evt"); } catch (Exception e) { System.out.println("trap 2: " + e.getMessage() + ", second listener skipped"); }
}
}
trap 1: java.util.ConcurrentModificationException
trap 2: bad listener, second listener skipped
A listener removes itself while the loop runs. Cause: ArrayList's iterator detects the change and throws. Fix: iterate over a snapshot, as CopyOnWriteArrayList does. Note the third listener never got the event at all.
A listener throws. Cause: the exception travels up through the for loop and the remaining listeners never run. Fix: catch per listener and report it, as Inventory.publish does. Whether you swallow, log, or rethrow after delivering to everyone is your call; I'd log and move on for a stock system.
You extend java.util.Observable. This one is a compile warning, not a crash:
warning: [deprecation] Observable in java.util has been deprecated
class OldObservable extends Observable { }
Observable and Observer are deprecated since Java 9. They're a class (so you burn your one inheritance slot), their notification isn't type-safe, and Observable makes you call setChanged() before notifyObservers(), which people forget. Writing your own interface, as above, is about twenty lines.
Two smaller ones from the compiler. Giving a lambda the wrong shape fails like this:
error: incompatible types: incompatible parameter types in lambda expression
And I deliberately left listenerCount() package-private; calling it from outside the package says listenerCount() is not public in Inventory; cannot be accessed from outside package.
Before you wire this into a real service
- Memory leaks are the pattern's classic trap. The subject holds a strong reference to every listener, so a short-lived object that forgets to call
close()stays in memory for as long as the subject lives. Always keep theSubscriptionand close it, ideally in try-with-resources or a shutdown hook. Weak references fix some cases but add surprises (listeners vanish when nothing else holds them), so I'd avoid them unless you've measured a problem. - Listeners run on the caller's thread. A slow listener slows
ship. If that matters, hand events to anExecutorServiceinside the listener, or publish through a queue. See the BlockingQueue tutorial. - Ordering. Listeners are called in the order they subscribed. Don't rely on it for correctness.
- JDK alternatives.
java.beans.PropertyChangeSupportexists for property-change events, andjava.util.concurrent.Flowdefines a reactive-streams interface set with back-pressure. Both suit specific jobs; for a plain in-process event, the code above is smaller. I haven't run either in this article. - Cross-service events (Kafka, a message broker) are a different tool. The observer pattern is in-process only.
- Next patterns: the strategy pattern swaps one behaviour; observer fans one event out to many. A typed event bus generalises this across event types.
Checklist
- Model the event as an immutable record.
- Give observers a single-method interface so lambdas work.
- Return a
Subscriptioninstead of making callers hold the listener. - Iterate a snapshot, catch per listener, and publish outside the lock.
- Don't extend
Observable; it's deprecated.
Observer pattern in Java: quick answers
Is the observer pattern still used in modern Java? Yes, under other names: event listeners in Swing and JavaFX, ApplicationEvent in Spring, PropertyChangeListener in JavaBeans. The Observable class is what went out of fashion, not the idea.
What replaced java.util.Observer and Observable? Nothing official as a drop-in. The JDK documentation points to java.beans for property changes and java.util.concurrent.Flow for reactive streams. For plain events, define your own listener interface as in step 2.
How do I make an observer pattern thread-safe? Keep the listener list in a CopyOnWriteArrayList, make state changes atomic, and notify after you've released any lock, as shown in step 4. The 8-thread check in the test file verifies the result: 8000 events and a final quantity of 8000.
How do I unsubscribe a lambda listener? Return a handle from subscribe. A lambda has no useful identity to remove later, so the handle captures it for you.
References
- JDK 21 Javadoc:
java.util.Observable(deprecated),CopyOnWriteArrayList,java.util.concurrent.Flow,java.beans.PropertyChangeSupport. - JEP 395 (records).