> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Implement the Observer Pattern in Java (Java 21)
- URL: https://www.ggorantala.dev/how-to-implement-observer-pattern-java/
- Published: 2026-06-20T04:55:00.000Z
- Updated: 2026-10-31T06:34:55.000Z
- Description: Build a stock-alert system with the observer pattern in Java 21: a thread-safe subject, lambda listeners, safe unsubscribe, and 16 checks that prove it works.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, design-patterns, java-tutorials, core-java

## 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:

```log
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.

```mermaid
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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) covers them, and the [immutable class guide](https://ggorantala.dev/how-to-create-immutable-class-java/?ref=ggorantala.dev) 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:

```bash
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:

```log
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`

```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`

```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`

```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`

```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:

- **`CopyOnWriteArrayList`** gives 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/catch` around 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 `synchronized` block.** 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 into `Inventory` can't deadlock.
- **`subscribe` returns a `Subscription`.** 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.

```bash
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`

```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`

```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`

```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

```bash
javac -d out src/main/java/dev/ggorantala/inventory/*.java
java -cp out dev.ggorantala.inventory.Demo

```

```log
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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) once it's up).

`src/test/java/dev/ggorantala/inventory/Checks.java`

```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; } }
}

```

```bash
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.

```log
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](https://ggorantala.dev/how-to-use-copyonwritearraylist-java/?ref=ggorantala.dev) 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`

```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"); }
    }
}

```

```log
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:

```log
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:

```log
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 the `Subscription` and 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 an `ExecutorService` inside the listener, or publish through a queue. See the [BlockingQueue tutorial](https://ggorantala.dev/how-to-use-blockingqueue-producer-consumer-java/?ref=ggorantala.dev).
- **Ordering.** Listeners are called in the order they subscribed. Don't rely on it for correctness.
- **JDK alternatives.** `java.beans.PropertyChangeSupport` exists for property-change events, and `java.util.concurrent.Flow` defines 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](https://ggorantala.dev/how-to-implement-strategy-pattern-java/?ref=ggorantala.dev) 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 `Subscription` instead 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).