// ───────────────────────────────────────────────────────────────────────────── // OBSERVER [Behavioral] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Define a one-to-many dependency between objects so that when one object changes state, all its // dependents are notified and updated automatically. // PROBLEM : When an order ships, email + inventory + analytics must all react — and the order code must not // know about (or import) any of them. // SAY : Participants: Subject (attach/detach/notify), ConcreteSubject (holds state, fires on change), // Observer (update(subject, event) interface), ConcreteObserver. Pitch: "subscribe to a subject; // it pushes changes to everyone listening; unsubscribe to stop." // SEEN IN : Node `EventEmitter` (streams, `http.Server`, `process`); DOM `addEventListener` & `AbortSignal`; // RxJS Observables; `MutationObserver` / `IntersectionObserver` / `ResizeObserver`; Redux // `store.subscribe`, Zustand, React `useSyncExternalStore`; Vue `watch`; WebSocket `onmessage`. // VS : Pub/Sub adds a broker/channel between publisher and subscribers (they never know each other; // often async, cross-process); Observer is direct — the subject holds the observer list. Mediator // centralizes LOGIC between peers; Observer just broadcasts "I changed". // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) class Subject { #observers = new Set(); attach(observer) { this.#observers.add(observer); return () => this.detach(observer); } detach(observer) { this.#observers.delete(observer); } notify(event) { for (const o of [...this.#observers]) o.update(this, event); } // copy: safe if one detaches mid-notify get observerCount() { return this.#observers.size; } } class Order extends Subject { // ConcreteSubject constructor(id) { super(); this.id = id; this.status = 'created'; } setStatus(status) { const prev = this.status; this.status = status; this.notify({ type: 'status', prev, next: status }); } } class EmailNotifier { // ConcreteObserver constructor() { this.sent = []; } update(order, e) { if (e.next === 'shipped') this.sent.push(`order ${order.id} shipped`); } } class InventoryUpdater { // ConcreteObserver constructor() { this.decrements = 0; } update(order, e) { if (e.next === 'paid') this.decrements += 1; } } class AuditLog { // ConcreteObserver constructor() { this.lines = []; } update(order, e) { this.lines.push(`${order.id}: ${e.prev} → ${e.next}`); } } const order = new Order('o-1'); const email = new EmailNotifier(); const inventory = new InventoryUpdater(); const audit = new AuditLog(); const unsubscribeAudit = order.attach(audit); order.attach(email); order.attach(inventory); assert.equal(order.observerCount, 3); order.setStatus('paid'); order.setStatus('shipped'); assert.equal(inventory.decrements, 1); assert.deepEqual(email.sent, ['order o-1 shipped']); assert.deepEqual(audit.lines, ['o-1: created → paid', 'o-1: paid → shipped']); unsubscribeAudit(); // detach → no more deliveries order.detach(inventory); order.setStatus('delivered'); assert.equal(audit.lines.length, 2); assert.equal(inventory.decrements, 1); assert.equal(order.observerCount, 1); order.attach(email); // Set dedupes: attaching twice doesn't double-notify order.setStatus('shipped'); assert.equal(email.sent.length, 2); const once = { calls: 0, update(o) { o.detach(once); once.calls += 1; } }; // detaches itself mid-notify order.attach(once); order.setStatus('returned'); order.setStatus('refunded'); assert.equal(once.calls, 1); // ── Idiomatic JS shape: EventEmitter (Node's built-in Observer) + the minimal subscribe() store const { EventEmitter } = require('node:events'); const bus = new EventEmitter(); const seen = []; const onPaid = (o) => seen.push(`paid:${o.id}`); bus.on('order:paid', onPaid); bus.once('order:paid', (o) => seen.push(`first-paid:${o.id}`)); bus.emit('order:paid', { id: 1 }); bus.emit('order:paid', { id: 2 }); assert.deepEqual(seen, ['paid:1', 'first-paid:1', 'paid:2']); bus.off('order:paid', onPaid); assert.equal(bus.emit('order:paid', { id: 3 }), false); // emit returns false when nobody is listening assert.equal(seen.length, 3); // The 10-line store every state library is built on (Redux / Zustand / useSyncExternalStore shape) const createStore = (initial) => { let state = initial; const listeners = new Set(); return { getState: () => state, setState: (patch) => { state = { ...state, ...patch }; listeners.forEach((l) => l(state)); }, subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); }, }; }; const store = createStore({ cart: 0 }); const renders = []; const unsub = store.subscribe((s) => renders.push(s.cart)); store.setState({ cart: 1 }); store.setState({ cart: 2 }); unsub(); store.setState({ cart: 3 }); assert.deepEqual(renders, [1, 2]); // unsubscribed before the third update assert.equal(store.getState().cart, 3); console.log('ok: design-patterns/behavioral/observer.js');