KitDesk now prepares ordered reservations and can export a manifest. During preparation, volunteers mark individual kits ready. A status panel and a ready-count badge both need to react. Hard-coding their concrete methods into the reservation board couples the state owner to every presentation detail. Observer introduces a subscription boundary, but the useful design work lies in defining delivery, failure and lifetime rules.
The problem: independent views react to one state change
The reservation board owns readiness. The panel needs the changed reservation; the badge can rebuild a count from the current board. A later diagnostic view might need the same notification. The board should not import each view. Conversely, a view should be able to close without remaining attached to a long-lived board.
The forces are independent subscribers, understandable update order and bounded object lifetimes. A callback list reduces direct dependencies but creates new questions: when does the state change, what if a listener throws, can a listener change the board again, and who unsubscribes? The name Observer answers none of those questions on its own.
For this local, single-threaded teaching implementation, notification is synchronous, state is committed before callbacks, and a snapshot of the subscription list determines the current delivery. Listener failures are collected so later listeners still run. Listener-initiated mutation during delivery is rejected. Notifications are optional reactions to an accepted change; they cannot veto readiness.
Make notification semantics part of the subject's contract
class ReservationBoard {
#rows;
#listeners = new Set();
#notifying = false;
#revision = 0;
constructor(rows) {
if (new Set(rows.map(row => row.id)).size !== rows.length) {
throw new TypeError("reservation IDs must be unique");
}
this.#rows = new Map(rows.map(row => [row.id, Object.freeze({ ...row })]));
}
snapshot() {
return Object.freeze([...this.#rows.values()]);
}
subscribe(listener) {
if (typeof listener !== "function") throw new TypeError("listener required");
// A distinct subscription survives independent unsubscribe calls.
const subscription = { listener };
this.#listeners.add(subscription);
return () => this.#listeners.delete(subscription);
}
markReady(id) {
if (this.#notifying) throw new Error("mutation during notification is forbidden");
const current = this.#rows.get(id);
if (!current) throw new Error("unknown reservation");
if (current.ready) return Object.freeze({ changed: false, errors: Object.freeze([]) });
const row = Object.freeze({ ...current, ready: true });
this.#rows.set(id, row);
const event = Object.freeze({ type: "reservation-ready", row,
revision: ++this.#revision });
const errors = [];
this.#notifying = true;
try {
for (const { listener } of [...this.#listeners]) {
try { listener(event); }
catch (error) { errors.push(error); }
}
} finally {
this.#notifying = false;
}
return Object.freeze({ changed: true, errors: Object.freeze(errors) });
}
}
const board = new ReservationBoard([
{ id: "R33", kit: "SANDER", ready: false, requestedAt: 5, pickupAt: 40 }
]);
const shown = [];
const stopPanel = board.subscribe(event => shown.push(event.row.id));
const stopBadge = board.subscribe(() => {
const count = board.snapshot().filter(row => row.ready).length;
console.log("ready:", count);
});
const result = board.markReady("R33"); // ready: 1
console.log(shown, result.changed, result.errors.length); // ["R33"], true, 0
stopPanel();
stopBadge();
The board accepts validated, flat reservation records, as produced by the cabinet Adapter. Freezing a copied row prevents these listeners from changing its top-level fields. It is not a general deep-immutability mechanism; nested mutable values would need a different snapshot policy. The code keeps its data deliberately flat.
Each call to subscribe() creates an independent registration. Calling its returned function twice is harmless. Reusing the same callback in two registrations does not make one view's cleanup remove another registration. That is a small lifetime detail with a large effect when views mount and unmount independently.
Java's PropertyChangeSupport is a primary API example of managing listeners and dispatching change events. Its add/remove operations make subscription lifetime explicit. KitDesk's failure collection and reentrancy rule are original choices for this example, not claims about that API's behavior.
Trace the state change, including a broken listener
The subject is ReservationBoard. The observers are the panel and badge callbacks, connected through a listener convention rather than view-specific method names. The composition code creates both subscriptions and retains their cleanup functions. Keep all of them inside one application boundary in the structural diagram; there is no broker or durable message log.
On markReady("R33"), the board first checks the command, creates the replacement row and stores it. It increments the revision and constructs the event. It then snapshots the subscription set and invokes callbacks in registration order. The badge's call to snapshot() sees the committed ready row. The command returns only after synchronous listeners finish.
If the panel throws, the badge still runs and the caller receives a report containing the error. The row stays ready. The caller should surface or record that presentation failure without automatically repeating the state-changing command. A second identical mark produces changed: false and no notification. Confusing “the view update failed” with “readiness did not change” would give the caller the wrong recovery action.
A listener added during delivery starts with the next event. A listener removed during delivery can still receive this event because it is already in the snapshot. Subscribers must not rely on delivery order for business correctness. If two actions require an order to maintain an invariant, put that work in the state-changing operation itself.
Live model · Sequence view
Follow state acceptance and listener delivery
The KitDesk model shows ReservationBoard owning readiness and independent panel and badge subscriptions. Readiness is committed before synchronous notification; callback failures do not undo it.
Explore scenarios in this model
- Architecture view
- Prepare by deadline
- Load cabinet reservations
- Render CSV manifest
- Mark ready and notify views
- Render annotated preview
These links open the same public model in a selected view. They do not change its source.
Open this viewer on its own ↗ if the frame is unavailable. The explanation remains readable without the viewer.
Exploration is local to your viewer. Reload or Reset view returns to the source layout.
Trade-offs and when a direct call is clearer
Synchronous listeners delay the caller. A slow panel therefore delays the mark-ready action, and this implementation is appropriate only for short local reactions. The listener contract is synchronous: passing an async function returns a promise whose rejection this try/catch does not collect. Supporting async observers would require an explicit await, scheduling and failure policy; do not add them without changing the contract.
The reentrancy guard prevents one listener from marking another row while an event is being delivered. Without a rule, nested notifications can create surprising revision order or loops. A view that needs a follow-up action can hand an explicit command back to the application after delivery. The guard is a deliberately restrictive choice, not a property inherent to Observer.
A subscription retains its callback and anything the callback captures. A closed panel that forgets its cleanup function can remain reachable through the board. Make cleanup part of the view's lifetime and test it. Weak references would change lifetime semantics and are not a substitute for deciding who owns a subscription.
Keep direct calls when one stable view is the only consumer and the state owner is already its controller. Avoid Observer for validation that must approve a change, for a required export step whose failure must fail the operation, or for reliable communication across processes. This local callback list has no persistence, retry, replay or cross-process delivery guarantees.
Practice: prove delivery and cleanup behavior
- Register a failing listener before the badge. Mark R33 ready and verify that the badge sees one ready row,
changedis true, and one error is returned. Repeat the command and expect no new deliveries. - Register a listener that unsubscribes another during delivery. Confirm the removed observer still receives the current event but not a later one. Use two initially held reservations to produce two distinct changes.
- Try a nested
markReady()from a listener. Expect an error in the report, with the nested row unchanged. Then close a view, call its cleanup twice and verify its callback is no longer called.
Your review should distinguish state acceptance from reaction success and identify who owns every unsubscribe function. Continue with Decorator: compose manifest options without a subclass explosion. The KitDesk path and Design Patterns hub connect the complete series.