Low Level DesignDesign patterns
Observer pattern
The Observer design pattern through three real examples, from back-in-stock alerts to parking garage events to a live price feed, with diagrams, live demos and code in five languages.
- Read first
- Strategy
Observer lets one object announce that something happened. Any number of other objects can react, and the announcer doesn’t know who they are. Think of “notify me when it’s back in stock”, a parking garage’s free-space boards, or a trading screen’s live prices. They’re all the same shape: one source of news, and a list of whoever wants to hear it.
The basic version takes ten lines. Real implementations spend most of their code on what the basic version ignores. Listeners that throw. Listeners that are never removed. And listeners that are slower than the news. This page builds up to all three, one level at a time.
The shape
Section titled “The shape”Every Observer has the same parts:
- The subject keeps a list of listeners and tells each of them when something happens.
- The listener interface is the only thing the subject knows about its observers, usually one method.
- Concrete listeners each react in their own way and know nothing about each other.
- Usually an event: an immutable value saying what happened.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
The arrow from the subject points at the interface. The subject can be built, tested and shipped before any listener exists, and it keeps working when one is added or removed.
Worked examples
Section titled “Worked examples”Back-in-stock alerts
- The basic shape
- Subscribe and unsubscribe
- Notify on the change that matters
An online shop runs out of trail shoes. Ana asks to be emailed when they’re back. Ben asks for a push notification. The shop’s own dashboard wants to know too. When the restock arrives, all of them should hear about it, and only them. Try it:
Trail shoes
Out of stock
Subscribers (click to subscribe or unsubscribe)
Ana and Ben are subscribed. Restock to see who hears about it.
Without the pattern
Section titled “Without the pattern”The first version has the product send the emails itself:
import java.util.List;
// Before: the product sends the "back in stock" emails itself.class EmailService { void send(String to, String text) { System.out.println("email to " + to + ": " + text); }}
class Product { final String name; private int stock; // flaw 1: the product knows who wants to hear about it, and how to reach them. private final List<String> waitingEmails; private final EmailService email = new EmailService();
Product(String name, List<String> waitingEmails) { this.name = name; this.waitingEmails = waitingEmails; }
void restock(int units) { boolean wasEmpty = stock == 0; stock += units; if (wasEmpty && stock > 0) { // flaw 2: push alerts, SMS, or "update the wishlist badge" each mean editing Product. for (String to : waitingEmails) email.send(to, name + " is back in stock"); } }}
class Main { public static void main(String[] args) { Product shoes = new Product("Trail shoes", List.of("ana@example.com")); shoes.restock(5); // email to ana@example.com: Trail shoes is back in stock }}# Before: the product sends the "back in stock" emails itself.def send_email(to: str, text: str) -> None: print(f"email to {to}: {text}")
class Product: def __init__(self, name: str, waiting_emails: list[str]): self.name = name self._stock = 0 # flaw 1: the product knows who wants to hear about it, and how to reach them. self._waiting_emails = waiting_emails
def restock(self, units: int) -> None: was_empty = self._stock == 0 self._stock += units if was_empty and self._stock > 0: # flaw 2: push alerts, SMS, or "update the wishlist badge" each mean editing Product. for to in self._waiting_emails: send_email(to, f"{self.name} is back in stock")
if __name__ == "__main__": shoes = Product("Trail shoes", ["ana@example.com"]) shoes.restock(5) # email to ana@example.com: Trail shoes is back in stockusing System;using System.Collections.Generic;
// Before: the product sends the "back in stock" emails itself.public static class EmailService{ public static void Send(string to, string text) => Console.WriteLine($"email to {to}: {text}");}
public sealed class Product(string name, IReadOnlyList<string> waitingEmails){ // flaw 1: the product knows who wants to hear about it, and how to reach them. private int _stock;
public void Restock(int units) { bool wasEmpty = _stock == 0; _stock += units; if (wasEmpty && _stock > 0) { // flaw 2: push alerts, SMS, or "update the wishlist badge" each mean editing Product. foreach (var to in waitingEmails) EmailService.Send(to, $"{name} is back in stock"); } }}
public static class Program{ public static void Main() { var shoes = new Product("Trail shoes", ["ana@example.com"]); shoes.Restock(5); // email to ana@example.com: Trail shoes is back in stock }}package main
import "fmt"
func sendEmail(to, text string) { fmt.Printf("email to %s: %s\n", to, text) }
// Product, before: it sends the "back in stock" emails itself.type Product struct { Name string stock int // flaw 1: the product knows who wants to hear about it, and how to reach them. waitingEmails []string}
func (p *Product) Restock(units int) { wasEmpty := p.stock == 0 p.stock += units if wasEmpty && p.stock > 0 { // flaw 2: push alerts, SMS, or "update the wishlist badge" each mean editing Product. for _, to := range p.waitingEmails { sendEmail(to, p.Name+" is back in stock") } }}
func main() { shoes := &Product{Name: "Trail shoes", waitingEmails: []string{"ana@example.com"}} shoes.Restock(5) // email to ana@example.com: Trail shoes is back in stock}// Before: the product sends the "back in stock" emails itself.const sendEmail = (to: string, text: string) => console.log(`email to ${to}: ${text}`);
class Product { private stock = 0;
// flaw 1: the product knows who wants to hear about it, and how to reach them. constructor( readonly name: string, private readonly waitingEmails: string[], ) {}
restock(units: number): void { const wasEmpty = this.stock === 0; this.stock += units; if (wasEmpty && this.stock > 0) { // flaw 2: push alerts, SMS, or "update the wishlist badge" each mean editing Product. for (const to of this.waitingEmails) sendEmail(to, `${this.name} is back in stock`); } }}
const shoes = new Product('Trail shoes', ['ana@example.com']);shoes.restock(5); // email to ana@example.com: Trail shoes is back in stockThe product now knows who is waiting and how to reach them. Push notifications, SMS and the
dashboard each mean editing Product, a class that should only know about stock. And there’s
no way for Ana to stop waiting without someone changing the product’s list.
With Observer
Section titled “With Observer”Flip the dependency. The product keeps a list of anything that implements StockListener, and
each way of reaching a person is its own listener:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayList;import java.util.List;
// The listener interface: the only thing a product knows about the people waiting for it.// Email, push and the wishlist badge all look the same from here.interface StockListener { void backInStock(String product);}
// The subject.class Product { final String name; private int stock; private final List<StockListener> listeners = new ArrayList<>();
Product(String name) { this.name = name; }
void subscribe(StockListener l) { listeners.add(l); } void unsubscribe(StockListener l) { listeners.remove(l); }
void restock(int units) { boolean wasEmpty = stock == 0; stock += units; // Notify on the change that matters (empty -> available), not on every restock. // Otherwise a shop that restocks twice a day spams everyone twice a day. if (wasEmpty && stock > 0) { for (StockListener l : listeners) l.backInStock(name); } }
void sell(int units) { stock = Math.max(0, stock - units); }}
// Observers. Each knows how to reach one person one way; none knows about the others.class EmailAlert implements StockListener { private final String to; EmailAlert(String to) { this.to = to; } public void backInStock(String product) { System.out.println("email to " + to + ": " + product + " is back"); }}
class PushAlert implements StockListener { private final String device; PushAlert(String device) { this.device = device; } public void backInStock(String product) { System.out.println("push to " + device + ": " + product + " is back"); }}
class Main { public static void main(String[] args) { Product shoes = new Product("Trail shoes"); StockListener ana = new EmailAlert("ana@example.com"); shoes.subscribe(ana); shoes.subscribe(new PushAlert("ben-phone")); // A lambda is a listener too: here, the shop's own dashboard. shoes.subscribe(p -> System.out.println("dashboard: " + p + " available"));
shoes.restock(5); // email to ana, push to ben-phone, dashboard: all three hear it shoes.restock(5); // nothing: it was already in stock
shoes.unsubscribe(ana); // Ana bought them elsewhere shoes.sell(10); shoes.restock(3); // push to ben-phone and dashboard only }}from typing import Callable
# In Python a listener is just a callable that takes the product's name. Email, push and# the wishlist badge all look the same from the product's side.StockListener = Callable[[str], None]
# The subject.class Product: def __init__(self, name: str): self.name = name self._stock = 0 self._listeners: list[StockListener] = []
def subscribe(self, listener: StockListener) -> None: self._listeners.append(listener)
def unsubscribe(self, listener: StockListener) -> None: self._listeners.remove(listener)
def restock(self, units: int) -> None: was_empty = self._stock == 0 self._stock += units # Notify on the change that matters (empty -> available), not on every restock. # Otherwise a shop that restocks twice a day spams everyone twice a day. if was_empty and self._stock > 0: for listener in self._listeners: listener(self.name)
def sell(self, units: int) -> None: self._stock = max(0, self._stock - units)
# Observers. Each knows how to reach one person one way; none knows about the others.class EmailAlert: def __init__(self, to: str): self.to = to
def __call__(self, product: str) -> None: print(f"email to {self.to}: {product} is back")
class PushAlert: def __init__(self, device: str): self.device = device
def __call__(self, product: str) -> None: print(f"push to {self.device}: {product} is back")
if __name__ == "__main__": shoes = Product("Trail shoes") ana = EmailAlert("ana@example.com") shoes.subscribe(ana) shoes.subscribe(PushAlert("ben-phone")) shoes.subscribe(lambda p: print(f"dashboard: {p} available")) # the shop's own dashboard
shoes.restock(5) # email to ana, push to ben-phone, dashboard: all three hear it shoes.restock(5) # nothing: it was already in stock
shoes.unsubscribe(ana) # Ana bought them elsewhere shoes.sell(10) shoes.restock(3) # push to ben-phone and dashboard onlyusing System;using System.Collections.Generic;
// The listener interface: the only thing a product knows about the people waiting for it.// Email, push and the wishlist badge all look the same from here.public interface IStockListener{ void BackInStock(string product);}
// The subject.public sealed class Product(string name){ private int _stock; private readonly List<IStockListener> _listeners = [];
public void Subscribe(IStockListener l) => _listeners.Add(l); public void Unsubscribe(IStockListener l) => _listeners.Remove(l);
public void Restock(int units) { bool wasEmpty = _stock == 0; _stock += units; // Notify on the change that matters (empty -> available), not on every restock. // Otherwise a shop that restocks twice a day spams everyone twice a day. if (wasEmpty && _stock > 0) foreach (var l in _listeners) l.BackInStock(name); }
public void Sell(int units) => _stock = Math.Max(0, _stock - units);}
// Observers. Each knows how to reach one person one way; none knows about the others.public sealed class EmailAlert(string to) : IStockListener{ public void BackInStock(string product) => Console.WriteLine($"email to {to}: {product} is back");}
public sealed class PushAlert(string device) : IStockListener{ public void BackInStock(string product) => Console.WriteLine($"push to {device}: {product} is back");}
public static class Program{ public static void Main() { var shoes = new Product("Trail shoes"); var ana = new EmailAlert("ana@example.com"); shoes.Subscribe(ana); shoes.Subscribe(new PushAlert("ben-phone"));
shoes.Restock(5); // email to ana, push to ben-phone shoes.Restock(5); // nothing: it was already in stock
shoes.Unsubscribe(ana); // Ana bought them elsewhere shoes.Sell(10); shoes.Restock(3); // push to ben-phone only
// C# also has Observer built in: `public event Action<string>? BackInStock;` // with `+=` and `-=`. The Hard level shows what that version doesn't handle. }}package main
import "fmt"
// StockListener is a function: the only thing a product knows about the people waiting// for it. Email, push and the wishlist badge all look the same from here.type StockListener func(product string)
// Product is the subject.type Product struct { Name string stock int listeners map[int]StockListener // keyed by id, because Go funcs can't be compared nextID int}
func NewProduct(name string) *Product { return &Product{Name: name, listeners: map[int]StockListener{}}}
// Subscribe returns the function that undoes it, since a func can't be looked up later.func (p *Product) Subscribe(l StockListener) (unsubscribe func()) { id := p.nextID p.nextID++ p.listeners[id] = l return func() { delete(p.listeners, id) }}
func (p *Product) Restock(units int) { wasEmpty := p.stock == 0 p.stock += units // Notify on the change that matters (empty -> available), not on every restock. // Otherwise a shop that restocks twice a day spams everyone twice a day. if wasEmpty && p.stock > 0 { for _, l := range p.listeners { // map order is random: observers must not rely on order l(p.Name) } }}
func (p *Product) Sell(units int) { p.stock = max(0, p.stock-units) }
// Observers. Each knows how to reach one person one way; none knows about the others.func EmailAlert(to string) StockListener { return func(product string) { fmt.Printf("email to %s: %s is back\n", to, product) }}
func PushAlert(device string) StockListener { return func(product string) { fmt.Printf("push to %s: %s is back\n", device, product) }}
func main() { shoes := NewProduct("Trail shoes") stopAna := shoes.Subscribe(EmailAlert("ana@example.com")) shoes.Subscribe(PushAlert("ben-phone"))
shoes.Restock(5) // email to ana, push to ben-phone (in either order) shoes.Restock(5) // nothing: it was already in stock
stopAna() // Ana bought them elsewhere shoes.Sell(10) shoes.Restock(3) // push to ben-phone only}// A listener is a function: the only thing a product knows about the people waiting for// it. Email, push and the wishlist badge all look the same from here.type StockListener = (product: string) => void;
// The subject.class Product { private stock = 0; private readonly listeners = new Set<StockListener>();
constructor(readonly name: string) {}
// Returns the unsubscribe function, the usual shape in TypeScript code. subscribe(listener: StockListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
restock(units: number): void { const wasEmpty = this.stock === 0; this.stock += units; // Notify on the change that matters (empty -> available), not on every restock. // Otherwise a shop that restocks twice a day spams everyone twice a day. if (wasEmpty && this.stock > 0) { for (const listener of this.listeners) listener(this.name); } }
sell(units: number): void { this.stock = Math.max(0, this.stock - units); }}
// Observers. Each knows how to reach one person one way; none knows about the others.const emailAlert = (to: string): StockListener => (p) => console.log(`email to ${to}: ${p} is back`);const pushAlert = (device: string): StockListener => (p) => console.log(`push to ${device}: ${p} is back`);
const shoes = new Product('Trail shoes');const stopAna = shoes.subscribe(emailAlert('ana@example.com'));shoes.subscribe(pushAlert('ben-phone'));shoes.subscribe((p) => console.log(`dashboard: ${p} available`)); // the shop's own dashboard
shoes.restock(5); // email to ana, push to ben-phone, dashboard: all three hear itshoes.restock(5); // nothing: it was already in stock
stopAna(); // Ana bought them elsewhereshoes.sell(10);shoes.restock(3); // push to ben-phone and dashboard onlyWhat to notice
Section titled “What to notice”- The subject knows one interface.
ProductcallsbackInStock(name), and nothing else. SMS, a wishlist badge or a webhook is a new listener class.Productnever changes. - Notify on the change that matters. The product only notifies when stock goes from zero to something. Notifying on every restock would spam everyone twice a day. Deciding what counts as news is the subject’s job.
- Unsubscribing is part of the design. Ana can leave the list without anyone editing the
product. In Go and TypeScript,
subscribereturns the function that undoes it, because a function can’t be found in a list again later. - A function is a listener. In Python, Go and TypeScript, listeners are just callables. In Java, a lambda fills the one-method interface. Classes earn their place when a listener has settings, like an email address.
We want to add SMS alerts. What changes?
Whether the answer is “a new SmsAlert listener and one subscribe call”, with Product
untouched. The red flag is adding an SMS branch inside restock. So is forgetting that people
need a way to stop getting alerts.
The product shouldn’t know who’s waiting for it or how to reach them. I’ll give it a list of StockListeners and notify them when stock goes from zero to available. Email, push and SMS are just different listeners.
Events in a parking garage
- Events as values
- One broken listener
- Leaks and unsubscribe handles
- Changing the list mid-notification
When a car parks in a garage, three things should happen. The entrance board shows the new free count. The occupancy metrics go up. And, when the garage is nearly full, an SMS alert goes to the operator. These consumers are owned by different teams, and fail in different ways. The SMS gateway, for one, goes down now and then.
Without the pattern
Section titled “Without the pattern”The lot calls each consumer by name:
// Before: the lot calls everything that cares about a car parking, by name.class DisplayBoard { void show(int free) { System.out.println("Entrance board: " + free + " free"); }}
class OccupancyMetrics { int parked; void recordPark() { parked++; }}
class ParkingLot { // flaw 1: the lot depends on every consumer. A new one (an SMS alert, a second board) // means editing ParkingLot, which has nothing to do with boards or metrics. private final DisplayBoard board = new DisplayBoard(); private final OccupancyMetrics metrics = new OccupancyMetrics(); private int free;
ParkingLot(int spots) { free = spots; }
void park(String spotId) { free--; // flaw 2: if the board throws, metrics are never recorded and park() fails, // even though the car is already in the spot. board.show(free); metrics.recordPark(); }
// flaw 3: tests of park() drag in a real board and real metrics. OccupancyMetrics metrics() { return metrics; }}
class Main { public static void main(String[] args) { ParkingLot lot = new ParkingLot(3); lot.park("A1"); // Entrance board: 2 free System.out.println(lot.metrics().parked); // 1 }}# Before: the lot calls everything that cares about a car parking, by name.class DisplayBoard: def show(self, free: int) -> None: print(f"Entrance board: {free} free")
class OccupancyMetrics: def __init__(self): self.parked = 0
def record_park(self) -> None: self.parked += 1
class ParkingLot: def __init__(self, spots: int): # flaw 1: the lot depends on every consumer. A new one (an SMS alert, a second # board) means editing ParkingLot, which has nothing to do with boards or metrics. self.board = DisplayBoard() self.metrics = OccupancyMetrics() self._free = spots
def park(self, spot_id: str) -> None: self._free -= 1 # flaw 2: if the board raises, metrics are never recorded and park() fails, # even though the car is already in the spot. self.board.show(self._free) self.metrics.record_park() # flaw 3: tests of park() drag in a real board and real metrics.
if __name__ == "__main__": lot = ParkingLot(3) lot.park("A1") # Entrance board: 2 free print(lot.metrics.parked) # 1using System;
// Before: the lot calls everything that cares about a car parking, by name.public sealed class DisplayBoard{ public void Show(int free) => Console.WriteLine($"Entrance board: {free} free");}
public sealed class OccupancyMetrics{ public int Parked { get; private set; } public void RecordPark() => Parked++;}
public sealed class ParkingLot(int spots){ // flaw 1: the lot depends on every consumer. A new one (an SMS alert, a second board) // means editing ParkingLot, which has nothing to do with boards or metrics. private readonly DisplayBoard _board = new(); public OccupancyMetrics Metrics { get; } = new(); // flaw 3: tests of Park() drag these in private int _free = spots;
public void Park(string spotId) { _free--; // flaw 2: if the board throws, metrics are never recorded and Park() fails, // even though the car is already in the spot. _board.Show(_free); Metrics.RecordPark(); }}
public static class Program{ public static void Main() { var lot = new ParkingLot(3); lot.Park("A1"); // Entrance board: 2 free Console.WriteLine(lot.Metrics.Parked); // 1 }}package main
import "fmt"
// Before: the lot calls everything that cares about a car parking, by name.type DisplayBoard struct{}
func (DisplayBoard) Show(free int) { fmt.Printf("Entrance board: %d free\n", free) }
type OccupancyMetrics struct{ Parked int }
func (m *OccupancyMetrics) RecordPark() { m.Parked++ }
type ParkingLot struct { // flaw 1: the lot depends on every consumer. A new one (an SMS alert, a second // board) means editing ParkingLot, which has nothing to do with boards or metrics. board DisplayBoard Metrics OccupancyMetrics // flaw 3: tests of Park drag in a real board and real metrics free int}
func NewParkingLot(spots int) *ParkingLot { return &ParkingLot{free: spots} }
func (l *ParkingLot) Park(spotID string) { l.free-- // flaw 2: if the board panics, metrics are never recorded and Park fails, // even though the car is already in the spot. l.board.Show(l.free) l.Metrics.RecordPark()}
func main() { lot := NewParkingLot(3) lot.Park("A1") // Entrance board: 2 free fmt.Println(lot.Metrics.Parked) // 1}// Before: the lot calls everything that cares about a car parking, by name.class DisplayBoard { show(free: number): void { console.log(`Entrance board: ${free} free`); }}
class OccupancyMetrics { parked = 0; recordPark(): void { this.parked++; }}
class ParkingLot { // flaw 1: the lot depends on every consumer. A new one (an SMS alert, a second board) // means editing ParkingLot, which has nothing to do with boards or metrics. private readonly board = new DisplayBoard(); readonly metrics = new OccupancyMetrics(); // flaw 3: tests of park() drag these in private free: number;
constructor(spots: number) { this.free = spots; }
park(spotId: string): void { this.free--; // flaw 2: if the board throws, metrics are never recorded and park() fails, // even though the car is already in the spot. this.board.show(this.free); this.metrics.recordPark(); }}
const lot = new ParkingLot(3);lot.park('A1'); // Entrance board: 2 freeconsole.log(lot.metrics.parked); // 1Three problems. The lot depends on boards and metrics, which have nothing to do with parking.
If the board throws, metrics are never recorded and park() fails, even though the car is
already in the spot. And a unit test of park() drags in a real board.
With Observer
Section titled “With Observer”The lot publishes a ParkingEvent (what happened, which spot, how many are free now) to a list
of ParkingListeners. subscribe returns a handle that unsubscribes.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Listeners run one after another, inside park(). Break the SMS gateway and switch failure
isolation off to see what happens to the listeners after it:
- ·Entrance board
- ·SMS "lot nearly full" alert
- ·Occupancy metrics
Park a car to notify the listeners, in order.
import java.util.List;import java.util.concurrent.CopyOnWriteArrayList;
enum Kind { PARKED, LEFT }
// What happened, as an immutable value. Listeners get the facts, not a reference to the// lot, so they can't reach back in and change it in the middle of a notification.// freeSpots is the total, not "+1": a listener that misses an event still shows the truth.record ParkingEvent(Kind kind, String spotId, int freeSpots) {}
// The only thing the lot knows about its listeners. Boards, metrics and alerts are all// "something with onEvent", so adding one never touches ParkingLot.interface ParkingListener { void onEvent(ParkingEvent event);}
// Returned by subscribe, so whoever subscribed can stop. A listener that is never removed// keeps itself and everything it references alive as long as the lot lives: the classic// Observer memory leak.interface Subscription extends AutoCloseable { @Override void close();}
// The subject.class ParkingLot { // Copy-on-write: each notification loops over a snapshot, so a listener that subscribes // or unsubscribes during a notification can't break the loop. Every add or remove copies // the array, which is fine here: subscriptions are rare and events are frequent. private final List<ParkingListener> listeners = new CopyOnWriteArrayList<>(); private int free;
ParkingLot(int spots) { free = spots; }
Subscription subscribe(ParkingListener listener) { listeners.add(listener); return () -> listeners.remove(listener); }
void park(String spotId) { ParkingEvent event; synchronized (this) { free--; event = new ParkingEvent(Kind.PARKED, spotId, free); } // Notify after releasing the lock. A listener that calls back into the lot, or takes // a lock of its own, can't deadlock with us, and a slow board doesn't hold up other // cars. The cost: two threads parking at once may notify out of order. publish(event); }
void leave(String spotId) { ParkingEvent event; synchronized (this) { free++; event = new ParkingEvent(Kind.LEFT, spotId, free); } publish(event); }
private void publish(ParkingEvent event) { for (ParkingListener listener : listeners) { try { listener.onEvent(event); } catch (RuntimeException e) { // One broken listener must not stop the others, or fail a park() that has // already happened. Log it and move on. System.out.println("listener failed: " + e.getMessage()); } } }}
// Observers. Each knows about events; none knows about the others.class DisplayBoard implements ParkingListener { private final String name; DisplayBoard(String name) { this.name = name; } public void onEvent(ParkingEvent e) { System.out.println(name + " board: " + e.freeSpots() + " free"); }}
class OccupancyMetrics implements ParkingListener { int parked; public void onEvent(ParkingEvent e) { if (e.kind() == Kind.PARKED) parked++; }}
class Main { public static void main(String[] args) { ParkingLot lot = new ParkingLot(3); Subscription board = lot.subscribe(new DisplayBoard("Entrance")); OccupancyMetrics metrics = new OccupancyMetrics(); lot.subscribe(metrics); // A lambda is a listener too; this one stands in for an SMS gateway that is down. lot.subscribe(e -> { throw new IllegalStateException("SMS gateway down"); });
lot.park("A1"); // Entrance board: 2 free, then listener failed: SMS gateway down board.close(); // the board is being replaced; stop sending it events lot.park("A2"); // no board line now, only the SMS failure; metrics still count it System.out.println(metrics.parked); // 2 }}import threadingfrom dataclasses import dataclassfrom enum import Enumfrom typing import Callable
class Kind(Enum): PARKED = "parked" LEFT = "left"
# What happened, as an immutable value. Listeners get the facts, not a reference to the# lot, so they can't reach back in and change it in the middle of a notification.# free_spots is the total, not "+1": a listener that misses an event still shows the truth.@dataclass(frozen=True)class ParkingEvent: kind: Kind spot_id: str free_spots: int
# In Python a listener is just a callable. Boards, metrics and alerts are all "something# you can call with an event", so adding one never touches ParkingLot.ParkingListener = Callable[[ParkingEvent], None]
# The subject.class ParkingLot: def __init__(self, spots: int): self._listeners: list[ParkingListener] = [] self._free = spots self._lock = threading.Lock()
def subscribe(self, listener: ParkingListener) -> Callable[[], None]: with self._lock: self._listeners.append(listener)
# Returned so whoever subscribed can stop. A listener that is never removed keeps # itself and everything it references alive: the classic Observer memory leak. def unsubscribe() -> None: with self._lock: if listener in self._listeners: self._listeners.remove(listener)
return unsubscribe
def park(self, spot_id: str) -> None: with self._lock: self._free -= 1 event = ParkingEvent(Kind.PARKED, spot_id, self._free) # Copy the list while holding the lock, so a listener that subscribes or # unsubscribes during the loop below can't break it. snapshot = list(self._listeners) # Notify after releasing the lock. A listener that calls back into the lot can't # deadlock with us, and a slow board doesn't hold up other cars. The cost: two # threads parking at once may notify out of order. self._publish(event, snapshot)
def leave(self, spot_id: str) -> None: with self._lock: self._free += 1 event = ParkingEvent(Kind.LEFT, spot_id, self._free) snapshot = list(self._listeners) self._publish(event, snapshot)
@staticmethod def _publish(event: ParkingEvent, listeners: list[ParkingListener]) -> None: for listener in listeners: try: listener(event) except Exception as e: # One broken listener must not stop the others, or fail a park() that # has already happened. Log it and move on. print(f"listener failed: {e}")
# Observers. Each knows about events; none knows about the others.class DisplayBoard: def __init__(self, name: str): self.name = name
def __call__(self, event: ParkingEvent) -> None: print(f"{self.name} board: {event.free_spots} free")
class OccupancyMetrics: def __init__(self): self.parked = 0
def __call__(self, event: ParkingEvent) -> None: if event.kind is Kind.PARKED: self.parked += 1
def sms_gateway(event: ParkingEvent) -> None: raise RuntimeError("SMS gateway down") # stands in for a listener that fails
if __name__ == "__main__": lot = ParkingLot(3) stop_board = lot.subscribe(DisplayBoard("Entrance")) metrics = OccupancyMetrics() lot.subscribe(metrics) lot.subscribe(sms_gateway)
lot.park("A1") # Entrance board: 2 free, then listener failed: SMS gateway down stop_board() # the board is being replaced; stop sending it events lot.park("A2") # no board line now, only the SMS failure; metrics still count it print(metrics.parked) # 2using System;using System.Collections.Immutable;
public enum Kind { Parked, Left }
// What happened, as an immutable value. Listeners get the facts, not a reference to the// lot, so they can't reach back in and change it in the middle of a notification.// FreeSpots is the total, not "+1": a listener that misses an event still shows the truth.public sealed record ParkingEvent(Kind Kind, string SpotId, int FreeSpots);
// The only thing the lot knows about its listeners. Boards, metrics and alerts are all// "something with OnEvent", so adding one never touches ParkingLot.// (.NET's built-in `event` keyword is Observer too, but one throwing handler stops the// rest, and forgetting `-=` leaks. This version handles both on purpose.)public interface IParkingListener{ void OnEvent(ParkingEvent e);}
// The subject.public sealed class ParkingLot(int spots){ // An immutable list, replaced on every change: each notification loops over a snapshot, // so a listener that subscribes or unsubscribes mid-loop can't break it. Writes copy, // which is fine because subscriptions are rare and events are frequent. private ImmutableList<IParkingListener> _listeners = ImmutableList<IParkingListener>.Empty; private readonly object _lock = new(); private int _free = spots;
// Returns an IDisposable, so whoever subscribed can stop (often with `using`). // A listener that is never removed keeps itself and everything it references alive: // the classic Observer memory leak. public IDisposable Subscribe(IParkingListener listener) { lock (_lock) _listeners = _listeners.Add(listener); return new Unsubscriber(() => { lock (_lock) _listeners = _listeners.Remove(listener); }); }
public void Park(string spotId) => Change(-1, Kind.Parked, spotId); public void Leave(string spotId) => Change(+1, Kind.Left, spotId);
private void Change(int delta, Kind kind, string spotId) { ParkingEvent e; ImmutableList<IParkingListener> snapshot; lock (_lock) { _free += delta; e = new ParkingEvent(kind, spotId, _free); snapshot = _listeners; } // Notify after releasing the lock. A listener that calls back into the lot can't // deadlock with us, and a slow board doesn't hold up other cars. The cost: two // threads parking at once may notify out of order. foreach (var listener in snapshot) { try { listener.OnEvent(e); } catch (Exception ex) { // One broken listener must not stop the others, or fail a Park() that has // already happened. Log it and move on. Console.WriteLine($"listener failed: {ex.Message}"); } } }
private sealed class Unsubscriber(Action remove) : IDisposable { public void Dispose() => remove(); }}
// Observers. Each knows about events; none knows about the others.public sealed class DisplayBoard(string name) : IParkingListener{ public void OnEvent(ParkingEvent e) => Console.WriteLine($"{name} board: {e.FreeSpots} free");}
public sealed class OccupancyMetrics : IParkingListener{ public int Parked { get; private set; } public void OnEvent(ParkingEvent e) { if (e.Kind == Kind.Parked) Parked++; }}
// Stands in for an SMS gateway that is down.public sealed class SmsAlert : IParkingListener{ public void OnEvent(ParkingEvent e) => throw new InvalidOperationException("SMS gateway down");}
public static class Program{ public static void Main() { var lot = new ParkingLot(3); var board = lot.Subscribe(new DisplayBoard("Entrance")); var metrics = new OccupancyMetrics(); lot.Subscribe(metrics); lot.Subscribe(new SmsAlert());
lot.Park("A1"); // Entrance board: 2 free, then listener failed: SMS gateway down board.Dispose(); // the board is being replaced; stop sending it events lot.Park("A2"); // no board line now, only the SMS failure; metrics still count it Console.WriteLine(metrics.Parked); // 2 }}package main
import ( "fmt" "sync")
type Kind int
const ( Parked Kind = iota Left)
// ParkingEvent is what happened, as a value. Listeners get a copy of the facts, not a// pointer to the lot, so they can't reach back in and change it mid-notification.// FreeSpots is the total, not "+1": a listener that misses an event still shows the truth.type ParkingEvent struct { Kind Kind SpotID string FreeSpots int}
// ParkingListener is a function. Boards, metrics and alerts are all "something you can// call with an event", so adding one never touches ParkingLot.type ParkingListener func(ParkingEvent)
// ParkingLot is the subject.type ParkingLot struct { mu sync.Mutex listeners map[int]ParkingListener // keyed by id, because Go funcs can't be compared nextID int free int}
func NewParkingLot(spots int) *ParkingLot { return &ParkingLot{listeners: map[int]ParkingListener{}, free: spots}}
// Subscribe returns a function that unsubscribes. A listener that is never removed keeps// itself and everything it references alive: the classic Observer memory leak.func (l *ParkingLot) Subscribe(fn ParkingListener) (unsubscribe func()) { l.mu.Lock() defer l.mu.Unlock() id := l.nextID l.nextID++ l.listeners[id] = fn return func() { l.mu.Lock() defer l.mu.Unlock() delete(l.listeners, id) }}
func (l *ParkingLot) Park(spotID string) { l.change(-1, Parked, spotID) }func (l *ParkingLot) Leave(spotID string) { l.change(+1, Left, spotID) }
func (l *ParkingLot) change(delta int, kind Kind, spotID string) { l.mu.Lock() l.free += delta event := ParkingEvent{kind, spotID, l.free} // Copy the listeners while holding the lock, so one that subscribes or unsubscribes // during the loop below can't race with it. (Map order is random; observers must // not depend on the order they are called in.) snapshot := make([]ParkingListener, 0, len(l.listeners)) for _, fn := range l.listeners { snapshot = append(snapshot, fn) } l.mu.Unlock() // Notify after releasing the lock. A listener that calls back into the lot can't // deadlock with us, and a slow board doesn't hold up other cars. The cost: two // goroutines parking at once may notify out of order. for _, fn := range snapshot { notify(fn, event) }}
// One broken listener must not stop the others, or fail a Park that has already// happened. Recovering here turns its panic into a log line.func notify(fn ParkingListener, event ParkingEvent) { defer func() { if r := recover(); r != nil { fmt.Println("listener failed:", r) } }() fn(event)}
// Observers. Each knows about events; none knows about the others.func DisplayBoard(name string) ParkingListener { return func(e ParkingEvent) { fmt.Printf("%s board: %d free\n", name, e.FreeSpots) }}
type OccupancyMetrics struct { mu sync.Mutex // listeners can be called from many goroutines at once Parked int}
func (m *OccupancyMetrics) OnEvent(e ParkingEvent) { if e.Kind == Parked { m.mu.Lock() m.Parked++ m.mu.Unlock() }}
func main() { lot := NewParkingLot(3) stopBoard := lot.Subscribe(DisplayBoard("Entrance")) metrics := &OccupancyMetrics{} lot.Subscribe(metrics.OnEvent) // a method value is a listener too lot.Subscribe(func(ParkingEvent) { panic("SMS gateway down") })
lot.Park("A1") // Entrance board: 2 free, and listener failed: SMS gateway down (any order) stopBoard() // the board is being replaced; stop sending it events lot.Park("A2") // no board line now, only the SMS failure; metrics still count it fmt.Println(metrics.Parked) // 2}type Kind = 'parked' | 'left';
// What happened, as an immutable value. Listeners get the facts, not a reference to the// lot, so they can't reach back in and change it in the middle of a notification.// freeSpots is the total, not "+1": a listener that misses an event still shows the truth.interface ParkingEvent { readonly kind: Kind; readonly spotId: string; readonly freeSpots: number;}
// A listener is a function. Boards, metrics and alerts are all "something you can call// with an event", so adding one never touches ParkingLot.type ParkingListener = (event: ParkingEvent) => void;
// The subject. JavaScript runs one callback at a time, so there is no lock; the risks left// are a listener that throws and a listener that changes the list mid-loop.class ParkingLot { private readonly listeners = new Set<ParkingListener>(); private free: number;
constructor(spots: number) { this.free = spots; }
// Returns a function that unsubscribes. A listener that is never removed keeps itself and // everything it references alive: the classic Observer memory leak, common with UI code. subscribe(listener: ParkingListener): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); }
park(spotId: string): void { this.free--; this.publish({ kind: 'parked', spotId, freeSpots: this.free }); }
leave(spotId: string): void { this.free++; this.publish({ kind: 'left', spotId, freeSpots: this.free }); }
private publish(event: ParkingEvent): void { // Loop over a copy, so a listener that subscribes or unsubscribes during the // notification doesn't change the set we are walking. for (const listener of [...this.listeners]) { try { listener(event); } catch (e) { // One broken listener must not stop the others, or fail a park() that has // already happened. Log it and move on. console.log(`listener failed: ${(e as Error).message}`); } } }}
// Observers. Each knows about events; none knows about the others.const displayBoard = (name: string): ParkingListener => (e) => console.log(`${name} board: ${e.freeSpots} free`);
class OccupancyMetrics { parked = 0; // An arrow property keeps `this` bound when the method is passed as a listener. onEvent: ParkingListener = (e) => { if (e.kind === 'parked') this.parked++; };}
const lot = new ParkingLot(3);const stopBoard = lot.subscribe(displayBoard('Entrance'));const metrics = new OccupancyMetrics();lot.subscribe(metrics.onEvent);lot.subscribe(() => { throw new Error('SMS gateway down'); // stands in for a listener that fails});
lot.park('A1'); // Entrance board: 2 free, then listener failed: SMS gateway downstopBoard(); // the board is being replaced; stop sending it eventslot.park('A2'); // no board line now, only the SMS failure; metrics still count itconsole.log(metrics.parked); // 2What to notice
Section titled “What to notice”- One broken listener must not break the subject. The lot wraps each call, logs the failure, and
moves on. The car has parked. A listener’s failure is the listener’s problem. C#’s built-in
eventand most hand-written loops don’t do this. There, one throwing handler stops the rest, and the error shows up in the code that raised the event. - Unsubscribe handles stop leaks. The subject holds a reference to every listener. A board
that is replaced but never unsubscribed stays in memory, along with everything it references,
and keeps receiving events. This is the “lapsed listener” problem, the most common memory
leak in UI code. Returning a handle (
Subscription,IDisposable, or a function) gives the subscriber exactly what it needs to leave. - Loop over a snapshot. A listener may unsubscribe itself, or subscribe another, while it’s being
notified. Looping over the live list then throws in Java, and skips an item in Python. Any of these
fix it:
CopyOnWriteArrayList, an immutable list, or a copy taken under the lock. Listener lists are short and change rarely, so copying is cheap. - Notify outside the lock. State changes and the snapshot happen under the lock. The listeners
are called after it’s released. Then a listener that calls back into the lot, or takes a lock of
its own, can never deadlock with it. There is a cost. Two cars parking at once, on two threads, may
notify in either order. That’s why the event carries the total (
freeSpots), not “one fewer”. A later event always corrects an earlier one. - Events are immutable values. Listeners get the facts, not a reference to the lot. So they can’t change it in the middle of a notification. And the same event can later be sent to another thread or process unchanged.
A listener throws. What happens to the others, and to
park()?
Whether you isolate each call, so the event still reaches everyone and park() succeeds. And
whether you then ask: if this listener’s work is essential, should it be a listener at all? The
red flag is letting the exception escape park(), for a car that has already parked.
A live price feed
- Slow subscribers
- A mailbox per subscriber
- Choosing what to lose
- Detecting gaps
A market data feed publishes a price update (a “tick”) every few milliseconds. Its subscribers range from an audit log that keeps up easily, to a ticker, to a chart that redraws slowly on an old laptop. With plain Observer, each tick calls every listener in turn. So the feed can publish only as fast as the chart can draw, and every other subscriber waits for it.
The fix is to stop calling listeners on the feed’s thread at all. Each subscription gets its own mailbox (a bounded queue) and its own worker that empties it. The feed only drops ticks into mailboxes, which takes microseconds however slow the subscriber is.
That raises the real design question: what happens when a mailbox is full? Start the feed and try each answer:
With Observer, a mailbox per subscriber
Section titled “With Observer, a mailbox per subscriber”- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
- Color key
- Implementation
- Entity
import java.util.ArrayDeque;import java.util.ArrayList;import java.util.Deque;import java.util.List;import java.util.concurrent.CopyOnWriteArrayList;
// One price update. seq is assigned by the feed, so a subscriber can tell when it missed some.record Tick(long seq, String symbol, double price) {}
interface TickListener { void onTick(Tick tick);}
// One subscriber's private mailbox and the thread that empties it. This is the whole point// of the Hard version: the feed only ever touches the mailbox, never the listener, so a slow// or stuck listener can't slow the feed or anyone else.final class Subscription implements AutoCloseable { private final TickListener listener; private final int capacity; private final PriceFeed feed; private final Deque<Tick> queue = new ArrayDeque<>(); private long dropped; private boolean closed; private Thread worker;
Subscription(TickListener listener, int capacity, PriceFeed feed) { this.listener = listener; this.capacity = capacity; this.feed = feed; }
// Runs on the feed's thread. It never blocks and never calls listener code. synchronized void offer(Tick t) { if (closed) return; if (queue.size() == capacity) { // Full: drop the oldest. For prices only the latest matters, so a stale tick is // the cheapest thing to lose. (An order feed could never do this; see the page.) queue.pollFirst(); dropped++; } queue.addLast(t); notifyAll(); }
void start() { worker = new Thread(this::drain); worker.start(); }
private void drain() { while (true) { Tick next; synchronized (this) { while (queue.isEmpty() && !closed) { try { wait(); } catch (InterruptedException e) { return; } } if (queue.isEmpty()) return; // closed and fully drained next = queue.pollFirst(); } // Call the listener outside the lock, so the feed can keep offering meanwhile. try { listener.onTick(next); } catch (RuntimeException e) { System.out.println("listener failed: " + e.getMessage()); // one bad tick, not a dead subscriber } } }
synchronized long dropped() { return dropped; }
// Stop receiving: leave the feed, let the worker finish what is queued, then exit. @Override public void close() { feed.remove(this); synchronized (this) { closed = true; notifyAll(); } }
void awaitDone() throws InterruptedException { if (worker != null) worker.join(); }}
// The subject. Publishing costs one cheap offer per subscriber, however slow they are.final class PriceFeed { private final List<Subscription> subscriptions = new CopyOnWriteArrayList<>(); private long seq;
Subscription subscribe(TickListener listener, int capacity) { Subscription s = new Subscription(listener, capacity, this); subscriptions.add(s); return s; }
void remove(Subscription s) { subscriptions.remove(s); }
// synchronized: numbering and offering happen together, so every mailbox receives ticks // in seq order even with several publishing threads. synchronized void publish(String symbol, double price) { Tick t = new Tick(++seq, symbol, price); for (Subscription s : subscriptions) s.offer(t); }}
// A listener that notices gaps. Because ticks carry seq, a subscriber that fell behind knows// it did, and can show "stale" or fetch a snapshot instead of silently showing old data.final class Chart implements TickListener { private long lastSeq; public void onTick(Tick t) { if (t.seq() > lastSeq + 1) System.out.println("chart: missed " + (t.seq() - lastSeq - 1) + " ticks"); System.out.printf("chart: #%d %s %.2f%n", t.seq(), t.symbol(), t.price()); lastSeq = t.seq(); }}
class Main { public static void main(String[] args) throws InterruptedException { PriceFeed feed = new PriceFeed(); List<Long> seen = new ArrayList<>(); // only touched by the audit worker thread Subscription audit = feed.subscribe(t -> seen.add(t.seq()), 100); // big mailbox Subscription chart = feed.subscribe(new Chart(), 2); // tiny mailbox, slow screen
// Five ticks arrive before either subscriber has run at all: the worst case for a slow one. // (Workers start after publishing only so this demo prints the same thing every time.) for (int i = 1; i <= 5; i++) feed.publish("ACME", 100 + i);
audit.start(); audit.close(); audit.awaitDone(); System.out.println("audit saw: " + seen); // audit saw: [1, 2, 3, 4, 5]
chart.start(); chart.close(); chart.awaitDone(); // chart: missed 3 ticks, then #4 and #5 System.out.println("chart dropped: " + chart.dropped()); // chart dropped: 3 }}import threadingfrom collections import dequefrom dataclasses import dataclassfrom typing import Callable
# One price update. seq is assigned by the feed, so a subscriber can tell when it missed some.@dataclass(frozen=True)class Tick: seq: int symbol: str price: float
TickListener = Callable[[Tick], None]
# One subscriber's private mailbox and the thread that empties it. This is the whole point# of the Hard version: the feed only ever touches the mailbox, never the listener, so a slow# or stuck listener can't slow the feed or anyone else.class Subscription: def __init__(self, listener: TickListener, capacity: int, feed: "PriceFeed"): self._listener = listener self._feed = feed # deque with maxlen drops the oldest item on its own when full. For prices only the # latest matters, so a stale tick is the cheapest thing to lose. self._queue: deque[Tick] = deque(maxlen=capacity) self._ready = threading.Condition() self._closed = False self.dropped = 0 self._worker: threading.Thread | None = None
# Runs on the feed's thread. It never blocks and never calls listener code. def offer(self, tick: Tick) -> None: with self._ready: if self._closed: return if len(self._queue) == self._queue.maxlen: self.dropped += 1 # the append below pushes the oldest out self._queue.append(tick) self._ready.notify()
def start(self) -> None: self._worker = threading.Thread(target=self._drain) self._worker.start()
def _drain(self) -> None: while True: with self._ready: self._ready.wait_for(lambda: self._queue or self._closed) if not self._queue: return # closed and fully drained tick = self._queue.popleft() # Call the listener outside the lock, so the feed can keep offering meanwhile. try: self._listener(tick) except Exception as e: print(f"listener failed: {e}") # one bad tick, not a dead subscriber
# Stop receiving: leave the feed, let the worker finish what is queued, then exit. def close(self) -> None: self._feed.remove(self) with self._ready: self._closed = True self._ready.notify()
def await_done(self) -> None: if self._worker: self._worker.join()
# The subject. Publishing costs one cheap offer per subscriber, however slow they are.class PriceFeed: def __init__(self): self._subscriptions: list[Subscription] = [] self._seq = 0 self._lock = threading.Lock()
def subscribe(self, listener: TickListener, capacity: int) -> Subscription: s = Subscription(listener, capacity, self) with self._lock: self._subscriptions.append(s) return s
def remove(self, s: Subscription) -> None: with self._lock: self._subscriptions.remove(s)
# Numbering and offering happen under one lock, so every mailbox receives ticks in seq # order even with several publishing threads. def publish(self, symbol: str, price: float) -> None: with self._lock: self._seq += 1 tick = Tick(self._seq, symbol, price) for s in self._subscriptions: s.offer(tick)
# A listener that notices gaps. Because ticks carry seq, a subscriber that fell behind knows# it did, and can show "stale" or fetch a snapshot instead of silently showing old data.class Chart: def __init__(self): self._last_seq = 0
def __call__(self, t: Tick) -> None: if t.seq > self._last_seq + 1: print(f"chart: missed {t.seq - self._last_seq - 1} ticks") print(f"chart: #{t.seq} {t.symbol} {t.price:.2f}") self._last_seq = t.seq
if __name__ == "__main__": feed = PriceFeed() seen: list[int] = [] audit = feed.subscribe(lambda t: seen.append(t.seq), 100) # big mailbox chart = feed.subscribe(Chart(), 2) # tiny mailbox, slow screen
# Five ticks arrive before either subscriber has run at all: the worst case for a slow one. # (Workers start after publishing only so this demo prints the same thing every time.) for i in range(1, 6): feed.publish("ACME", 100 + i)
audit.start() audit.close() audit.await_done() print("audit saw:", seen) # audit saw: [1, 2, 3, 4, 5]
chart.start() chart.close() chart.await_done() # chart: missed 3 ticks, then #4 and #5 print("chart dropped:", chart.dropped) # chart dropped: 3using System;using System.Collections.Generic;using System.Collections.Immutable;using System.Threading;using System.Threading.Channels;using System.Threading.Tasks;
// One price update. Seq is assigned by the feed, so a subscriber can tell when it missed some.public sealed record Tick(long Seq, string Symbol, double Price);
// One subscriber's private mailbox and the task that empties it. This is the whole point of// the Hard version: the feed only ever touches the mailbox, never the listener, so a slow or// stuck listener can't slow the feed or anyone else.public sealed class Subscription{ private readonly Action<Tick> _listener; private readonly PriceFeed _feed; private readonly Channel<Tick> _mailbox; private long _dropped; private Task? _worker;
public Subscription(Action<Tick> listener, int capacity, PriceFeed feed) { _listener = listener; _feed = feed; // A bounded channel that drops the oldest item when full, counting each drop. For // prices only the latest matters, so a stale tick is the cheapest thing to lose. _mailbox = Channel.CreateBounded<Tick>( new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true }, _ => Interlocked.Increment(ref _dropped)); }
public long Dropped => Interlocked.Read(ref _dropped);
// Runs on the feed's thread. With DropOldest, TryWrite never blocks and never fails // while the channel is open. internal void Offer(Tick t) => _mailbox.Writer.TryWrite(t);
public void Start() => _worker = Task.Run(async () => { await foreach (var tick in _mailbox.Reader.ReadAllAsync()) { try { _listener(tick); } catch (Exception e) { Console.WriteLine($"listener failed: {e.Message}"); } // one bad tick, not a dead subscriber } });
// Stop receiving: leave the feed, let the worker finish what is queued, then exit. public void Close() { _feed.Remove(this); _mailbox.Writer.TryComplete(); }
public Task Done => _worker ?? Task.CompletedTask;}
// The subject. Publishing costs one cheap offer per subscriber, however slow they are.public sealed class PriceFeed{ private ImmutableList<Subscription> _subscriptions = ImmutableList<Subscription>.Empty; private readonly object _lock = new(); private long _seq;
public Subscription Subscribe(Action<Tick> listener, int capacity) { var s = new Subscription(listener, capacity, this); lock (_lock) _subscriptions = _subscriptions.Add(s); return s; }
internal void Remove(Subscription s) { lock (_lock) _subscriptions = _subscriptions.Remove(s); }
// Numbering and offering happen under one lock, so every mailbox receives ticks in seq // order even with several publishing threads. public void Publish(string symbol, double price) { lock (_lock) { var t = new Tick(++_seq, symbol, price); foreach (var s in _subscriptions) s.Offer(t); } }}
// A listener that notices gaps. Because ticks carry Seq, a subscriber that fell behind knows// it did, and can show "stale" or fetch a snapshot instead of silently showing old data.public sealed class Chart{ private long _lastSeq;
public void OnTick(Tick t) { if (t.Seq > _lastSeq + 1) Console.WriteLine($"chart: missed {t.Seq - _lastSeq - 1} ticks"); Console.WriteLine($"chart: #{t.Seq} {t.Symbol} {t.Price:F2}"); _lastSeq = t.Seq; }}
public static class Program{ public static async Task Main() { var feed = new PriceFeed(); var seen = new List<long>(); // only touched by the audit worker var audit = feed.Subscribe(t => seen.Add(t.Seq), 100); // big mailbox var chart = feed.Subscribe(new Chart().OnTick, 2); // tiny mailbox, slow screen
// Five ticks arrive before either subscriber has run at all: the worst case for a slow one. // (Workers start after publishing only so this demo prints the same thing every time.) for (int i = 1; i <= 5; i++) feed.Publish("ACME", 100 + i);
audit.Start(); audit.Close(); await audit.Done; Console.WriteLine("audit saw: " + string.Join(", ", seen)); // audit saw: 1, 2, 3, 4, 5
chart.Start(); chart.Close(); await chart.Done; // chart: missed 3 ticks, then #4 and #5 Console.WriteLine($"chart dropped: {chart.Dropped}"); // chart dropped: 3 }}package main
import ( "fmt" "sync" "sync/atomic")
// Tick is one price update. Seq is assigned by the feed, so a subscriber can tell when it// missed some.type Tick struct { Seq int64 Symbol string Price float64}
type TickListener func(Tick)
// Subscription is one subscriber's private mailbox (a buffered channel) and the goroutine// that empties it. This is the whole point of the Hard version: the feed only ever touches// the mailbox, never the listener, so a slow or stuck listener can't slow the feed or anyone else.type Subscription struct { listener TickListener feed *PriceFeed mailbox chan Tick dropped atomic.Int64 done chan struct{}}
// offer runs on the feed's goroutine, under the feed's lock (the only sender). It never// blocks: when the mailbox is full it throws away the oldest tick and tries again. For// prices only the latest matters, so a stale tick is the cheapest thing to lose.func (s *Subscription) offer(t Tick) { for { select { case s.mailbox <- t: return default: select { case <-s.mailbox: s.dropped.Add(1) default: // the worker just took one; there is room now } } }}
func (s *Subscription) Start() { go func() { defer close(s.done) for t := range s.mailbox { // ends once Close has closed the mailbox and it is drained s.deliver(t) } }()}
// deliver isolates a panicking listener: one bad tick, not a dead subscriber.func (s *Subscription) deliver(t Tick) { defer func() { if r := recover(); r != nil { fmt.Println("listener failed:", r) } }() s.listener(t)}
// Close stops receiving: leave the feed, then close the mailbox so the worker finishes// what is queued and exits. Removing first, under the feed's lock, guarantees nothing is// sent on the closed channel.func (s *Subscription) Close() { s.feed.remove(s) close(s.mailbox)}
func (s *Subscription) Wait() { <-s.done }func (s *Subscription) Dropped() int64 { return s.dropped.Load() }
// PriceFeed is the subject. Publishing costs one cheap offer per subscriber, however slow// they are.type PriceFeed struct { mu sync.Mutex subs map[*Subscription]struct{} seq int64}
func NewPriceFeed() *PriceFeed { return &PriceFeed{subs: map[*Subscription]struct{}{}} }
func (f *PriceFeed) Subscribe(l TickListener, capacity int) *Subscription { s := &Subscription{listener: l, feed: f, mailbox: make(chan Tick, capacity), done: make(chan struct{})} f.mu.Lock() f.subs[s] = struct{}{} f.mu.Unlock() return s}
func (f *PriceFeed) remove(s *Subscription) { f.mu.Lock() delete(f.subs, s) f.mu.Unlock()}
// Publish numbers and offers under one lock, so every mailbox receives ticks in seq order// even with several publishing goroutines.func (f *PriceFeed) Publish(symbol string, price float64) { f.mu.Lock() defer f.mu.Unlock() f.seq++ t := Tick{f.seq, symbol, price} for s := range f.subs { s.offer(t) }}
// Chart notices gaps. Because ticks carry Seq, a subscriber that fell behind knows it did,// and can show "stale" or fetch a snapshot instead of silently showing old data.type Chart struct{ lastSeq int64 }
func (c *Chart) OnTick(t Tick) { if t.Seq > c.lastSeq+1 { fmt.Printf("chart: missed %d ticks\n", t.Seq-c.lastSeq-1) } fmt.Printf("chart: #%d %s %.2f\n", t.Seq, t.Symbol, t.Price) c.lastSeq = t.Seq}
func main() { feed := NewPriceFeed() var seen []int64 // only touched by the audit goroutine audit := feed.Subscribe(func(t Tick) { seen = append(seen, t.Seq) }, 100) // big mailbox chart := feed.Subscribe((&Chart{}).OnTick, 2) // tiny mailbox, slow screen
// Five ticks arrive before either subscriber has run at all: the worst case for a slow one. // (Workers start after publishing only so this demo prints the same thing every time.) for i := 1; i <= 5; i++ { feed.Publish("ACME", float64(100+i)) }
audit.Start() audit.Close() audit.Wait() fmt.Println("audit saw:", seen) // audit saw: [1 2 3 4 5]
chart.Start() chart.Close() chart.Wait() // chart: missed 3 ticks, then #4 and #5 fmt.Println("chart dropped:", chart.Dropped()) // chart dropped: 3}// One price update. seq is assigned by the feed, so a subscriber can tell when it missed some.interface Tick { readonly seq: number; readonly symbol: string; readonly price: number;}
// Listeners may be async (writing to a socket, rendering a frame). A slow one is exactly// what this design protects against.type TickListener = (tick: Tick) => void | Promise<void>;
// One subscriber's private mailbox and the loop that empties it. Node has one thread, but a// listener that awaits I/O is still "slow": the feed must not wait for it. The feed only// ever touches the mailbox, never the listener.class Subscription { private readonly queue: Tick[] = []; private closed = false; private wake?: () => void; private worker?: Promise<void>; dropped = 0;
constructor( private readonly listener: TickListener, private readonly capacity: number, private readonly feed: PriceFeed, ) {}
// Called by the feed. It never awaits and never calls listener code. offer(t: Tick): void { if (this.closed) return; if (this.queue.length === this.capacity) { // Full: drop the oldest. For prices only the latest matters, so a stale tick is the // cheapest thing to lose. this.queue.shift(); this.dropped++; } this.queue.push(t); this.wake?.(); }
start(): void { this.worker = this.drain(); }
private async drain(): Promise<void> { for (;;) { if (this.queue.length === 0) { if (this.closed) return; // closed and fully drained await new Promise<void>((resolve) => (this.wake = resolve)); this.wake = undefined; continue; } const tick = this.queue.shift()!; try { await this.listener(tick); } catch (e) { console.log(`listener failed: ${(e as Error).message}`); // one bad tick, not a dead subscriber } } }
// Stop receiving: leave the feed, let the loop finish what is queued, then end. close(): Promise<void> { this.feed.remove(this); this.closed = true; this.wake?.(); return this.worker ?? Promise.resolve(); }}
// The subject. Publishing costs one cheap offer per subscriber, however slow they are.class PriceFeed { private readonly subscriptions = new Set<Subscription>(); private seq = 0;
subscribe(listener: TickListener, capacity: number): Subscription { const s = new Subscription(listener, capacity, this); this.subscriptions.add(s); return s; }
remove(s: Subscription): void { this.subscriptions.delete(s); }
publish(symbol: string, price: number): void { const tick: Tick = { seq: ++this.seq, symbol, price }; for (const s of this.subscriptions) s.offer(tick); }}
// A listener that notices gaps. Because ticks carry seq, a subscriber that fell behind knows// it did, and can show "stale" or fetch a snapshot instead of silently showing old data.class Chart { private lastSeq = 0; onTick = (t: Tick): void => { if (t.seq > this.lastSeq + 1) console.log(`chart: missed ${t.seq - this.lastSeq - 1} ticks`); console.log(`chart: #${t.seq} ${t.symbol} ${t.price.toFixed(2)}`); this.lastSeq = t.seq; };}
const feed = new PriceFeed();const seen: number[] = [];const audit = feed.subscribe((t) => void seen.push(t.seq), 100); // big mailboxconst chart = feed.subscribe(new Chart().onTick, 2); // tiny mailbox, slow screen
// Five ticks arrive before either subscriber has run at all: the worst case for a slow one.for (let i = 1; i <= 5; i++) feed.publish('ACME', 100 + i);
audit.start();await audit.close();console.log('audit saw:', seen.join(', ')); // audit saw: 1, 2, 3, 4, 5
chart.start();await chart.close(); // chart: missed 3 ticks, then #4 and #5console.log('chart dropped:', chart.dropped); // chart dropped: 3What to notice
Section titled “What to notice”- The feed never runs listener code.
publishnumbers the tick and callsofferon each subscription, which only touches a queue. A stuck listener blocks its own worker and nothing else. - Every bounded queue needs an overflow policy, and it’s a business decision. Dropping the oldest is right for prices, where only the latest value matters. Making the publisher wait is right when nothing may be lost and the publisher can slow down, like a file import. (Making it wait is called backpressure.) An order feed can do neither. That’s the point where an in-memory Observer should become a durable message queue. An unbounded queue is never right. It just delays the failure until memory runs out.
- Sequence numbers make loss visible. Every tick carries
seq, assigned under the feed’s lock. So each mailbox receives ticks in order, and a subscriber can tell exactly how many it missed. The chart can show “stale”, or fetch a fresh snapshot, instead of quietly drawing old data. - The slow subscriber pays for its own slowness. With drop-oldest, the audit log and the ticker see every tick. The chart sees the latest ones. Nobody else’s experience depends on the slowest screen.
- Closing drains, then stops.
close()leaves the feed first (so nothing new arrives), then lets the worker finish what’s queued. Removing under the feed’s lock is what lets Go close the channel safely. - The libraries already exist. .NET’s
Channel.CreateBoundedwithDropOldest, Python’sdeque(maxlen=...), Go’s buffered channels and Java’s reactive streams (Flow, Reactor’sonBackpressureLatest) are this design. Knowing the shape lets you pick the right one.
One client on a bad connection is making everyone’s prices late. Fix it.
Whether you see that synchronous notification makes everyone as slow as the slowest subscriber. Whether you give each subscriber its own bounded mailbox. And whether you pick an overflow policy with a reason (“only the latest price matters, so drop the oldest”). The red flag is an unbounded queue, or a new thread per tick.
Each subscriber gets its own bounded mailbox, so the feed never waits on anyone. When a mailbox is full, we drop the oldest tick, because for prices only the latest matters. Sequence numbers let the subscriber know it skipped some.
Tradeoffs
Section titled “Tradeoffs”Use Observer when the subject shouldn’t know its consumers, when consumers come and go, or when there are several of them owned by different people. Keep a direct call when there is one consumer that is part of the same operation, and especially when its failure should fail the operation.
In each language
Section titled “In each language”| Language | Usual form | Built in |
|---|---|---|
| Java | Listener interface or lambda, CopyOnWriteArrayList |
PropertyChangeListener, java.util.concurrent.Flow |
| Python | A list of callables | Signals in frameworks (Django, Blinker) |
| C# | event with += / -=, or IObservable<T> |
event, IObservable<T>, System.Threading.Channels |
| Go | Function values in a map, or channels | Channels, context for cancellation |
| TypeScript | A Set of callbacks, subscribe returns an unsubscribe function |
DOM addEventListener, Node EventEmitter, RxJS |
Commonly confused with
Section titled “Commonly confused with”| Pattern | Looks like Observer because | The difference |
|---|---|---|
| Publish-subscribe | Both send events to many receivers | Observer is in-process and the subject holds the list. Pub-sub puts a broker between them, so they don’t know each other and needn’t run at the same time. |
| Mediator | Both decouple objects | A mediator coordinates two-way conversations between peers and contains logic. A subject only announces. |
| Callback | A listener is a callback | A callback usually has one receiver for one operation. Observer has many receivers for a stream of events. |
| Event sourcing | Both are built on events | Event sourcing stores events as the source of truth. Observer events are notifications and are gone once delivered. |
Where it shows up
Section titled “Where it shows up”On this site:
- Parking lot: display boards subscribe to parking events.
- LRU cache: removal listeners are told why each entry left.
- Logging framework: appenders receive each record, which is Observer with filtering, and async appenders are the Hard level’s mailbox.
- Elevator: floor displays react to the car moving.
In real systems: every UI framework’s event system, React state subscriptions, database change streams, webhooks (Observer across the internet), and market data fan-out.
Interview questions
Section titled “Interview questions”Interviewer asksHow do you avoid memory leaks with observers?
Make subscribe return a handle that unsubscribes, and tie it to the listener’s lifetime: dispose
it when the screen closes or the board is replaced. Weak references are a fallback, not a fix: the
listener then disappears at a time the garbage collector picks. In code review, look for
subscribe calls with nothing that ever removes them.
Interviewer asksTwo threads change the subject at once. What can listeners see?
If you notify outside the lock (which you should, to avoid deadlocks), events from the two changes can arrive in either order. Carry totals rather than deltas, and a sequence number taken under the lock, so listeners can ignore anything older than what they’ve already seen. If strict order matters, deliver through one queue per listener, as in the Hard level.
Interviewer asksWhen would you replace Observer with a message queue?
When a consumer is in another process. When events must survive a crash. Or when nothing may be dropped, and the publisher can’t slow down. The queue adds durability, and lets the two sides run at different times. It costs a network hop, operations work, and usually duplicate delivery. So consumers must be idempotent.
Interviewer asksShould listeners be notified in a particular order?
Design so they don’t need to be. Say the board must update before metrics. That’s a hidden dependency between two listeners that are supposed to be independent. Where order truly matters, make it explicit. Use one listener that calls the steps in order. Or make priorities a documented feature, not an accident of the list.
| Decision | We chose | We gave up |
|---|---|---|
| Coupling | The subject knows one listener interface | Reading the subject no longer shows what happens next |
| What counts as news | The subject decides (empty to available) | Listeners can’t ask for every change |
| Event shape | Immutable value with totals and a sequence number | Small events; they grow as listeners need more |
| Failures | Catch and log per listener | Listeners can’t fail or veto the operation |
| Leaks | subscribe returns an unsubscribe handle |
Nothing; it’s the fix |
| Threads | Notify outside the lock, over a snapshot | Strict ordering across concurrent changes |
| Slow listeners | A bounded mailbox each, drop the oldest | Delivery when publish returns, and every tick for slow ones |
Further reading
Section titled “Further reading”- Strategy, the other interface-at-the-seam pattern
- Message queues, Observer across processes
- Thread safety basics, for why calling out under a lock deadlocks
- Reactive Streams specification, the standard for backpressure between publishers and subscribers
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.