Skip to content
academia.sh

Lesson 15 / 30

Observer

Comparing calling the four jobs that run on a shipment state change directly with notifying them through subscription: the number of outgoing dependencies, the size of the import closure, which layer the edited file falls in when a fifth job is added, and the pattern's cost as the static call count dropping to zero.

Contents

The tariff object carried a calculation, and the caller called it directly: the dependency was one-directional and visible. Elsewhere in the library, the relationship has to be built in the opposite direction. When a shipment’s state changes, four jobs must run: a log line must be written, a notification sent to the customer, a metrics counter incremented, a billing record updated. If the shipment module calls these four itself, it has to import all four.

The Observer pattern lets the object announcing a state change stay ignorant of who receives the announcement: listeners register themselves, and the announcer notifies only the registered ones. This lesson’s treatment is in-process object notification; the same idea appeared twice more in the Backend Development curriculum — domain events in the Data Access Layer and Business Logic course, the publish–subscribe arrangement in the Messaging topic. Queues, delivery guarantees, and durability belong there; here, notification happens in the same process, on the same call stack. The numbers to measure are the outgoing dependency count, the size of the import closure, and which layer the edited file falls in.

Four Jobs, Two Designs

The listener modules are identical in both versions; each reports its job with a single line.

mkdir -p direct observer
for k in direct observer; do
  cat > "$k/log.mjs" <<'SON'
export const writeLog = (event) => console.log(`  log       ${event.code} ${event.state}`);
SON
  cat > "$k/notification.mjs" <<'SON'
export const sendNotification = (event) => console.log(`  notify    ${event.code} ${event.state}`);
SON
  cat > "$k/metric.mjs" <<'SON'
export const incrementMetric = (event) => console.log(`  metric    ${event.code} ${event.state}`);
SON
  cat > "$k/billing.mjs" <<'SON'
export const updateBilling = (event) => console.log(`  billing   ${event.code} ${event.state}`);
SON
done

In the first design, the shipment imports all four and calls them in sequence.

// direct/shipment.mjs — imports and calls all four jobs itself
import { writeLog } from "./log.mjs";
import { sendNotification } from "./notification.mjs";
import { incrementMetric } from "./metric.mjs";
import { updateBilling } from "./billing.mjs";

export const shipment = (code) => {
  let state = "created";
  return {
    code,
    get state() {
      return state;
    },
    changeState(next) {
      state = next;
      const event = { code, state: next };
      writeLog(event);
      sendNotification(event);
      incrementMetric(event);
      updateBilling(event);
    },
  };
};

In the second design, the shipment imports no job at all. The subscribe method takes a listener and returns a function that ends the subscription; changeState only walks the registered listeners.

// observer/shipment.mjs — imports no job, only notifies
export const shipment = (code) => {
  let state = "created";
  const subscribers = [];
  return {
    code,
    get state() {
      return state;
    },
    subscribe(listener) {
      subscribers.push(listener);
      return () => subscribers.splice(subscribers.indexOf(listener), 1);
    },
    changeState(next) {
      state = next;
      const event = { code, state: next };
      for (const listener of subscribers) listener(event);
    },
  };
};

The imports did not disappear; they moved. The one file that knows all four is now the file that wires the subscriptions.

// observer/main.mjs — subscriptions are wired only here
import { shipment } from "./shipment.mjs";
import { writeLog } from "./log.mjs";
import { sendNotification } from "./notification.mjs";
import { incrementMetric } from "./metric.mjs";
import { updateBilling } from "./billing.mjs";

export function wiredShipment(code) {
  const s = shipment(code);
  for (const listener of [writeLog, sendNotification, incrementMetric, updateBilling]) s.subscribe(listener);
  return s;
}

Behavioral Equality and a Lost Path

The measurement is meaningful only if both designs do the same job. The driver script finds the number of jobs triggered on a state change by counting the lines the listeners write. The third run calls the bare shipment without setting up any subscription.

// run.mjs — counts the number of jobs triggered on a state change
import { shipment as directShipment } from "./direct/shipment.mjs";
import { shipment as bareShipment } from "./observer/shipment.mjs";
import { wiredShipment } from "./observer/main.mjs";

const original = console.log;
let tally = 0;
console.log = (...a) => {
  tally += 1;
  original(...a);
};

const count = (label, create) => {
  tally = 0;
  original(`${label}:`);
  create().changeState("in_transit");
  original(`  ${label.padEnd(26)} jobs triggered = ${tally}`);
};

count("direct", () => directShipment("TS-1"));
count("observer (subscribed)", () => wiredShipment("TS-2"));
count("observer (not subscribed)", () => bareShipment("TS-3"));
direct:
  log       TS-1 in_transit
  notify    TS-1 in_transit
  metric    TS-1 in_transit
  billing   TS-1 in_transit
  direct                     jobs triggered = 4
observer (subscribed):
  log       TS-2 in_transit
  notify    TS-2 in_transit
  metric    TS-2 in_transit
  billing   TS-2 in_transit
  observer (subscribed)      jobs triggered = 4
observer (not subscribed):
  observer (not subscribed)  jobs triggered = 0

The first two runs are four to four: the designs carry the same behavior. The third run gives the harshest item of the pattern’s cost. When no subscription is set up, the state change completes silently, no error appears, and none of the four jobs run. In the directly calling design, no such path exists; if the import line is there, so is the call.

Dependency Count and Import Closure

The second measure is static. The script counts, for each root, the number of files it directly imports, the size of the import closure, and the number of event calls visible in its body.

// graph.mjs — outgoing dependency count, import closure, and static call count
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";

const imports = (path) =>
  [...readFileSync(path, "utf8").matchAll(/from "(\.[^"]+)"/g)]
    .map((m) => normalize(join(dirname(path), m[1])));

function closure(root) {
  const seen = new Set([root]);
  const stack = [root];
  while (stack.length > 0) {
    for (const k of imports(stack.pop())) {
      if (seen.has(k) === false) {
        seen.add(k);
        stack.push(k);
      }
    }
  }
  return seen;
}

const staticCalls = (path) => (readFileSync(path, "utf8").match(/^ {6}\w+\(event\);$/gm) ?? []).length;

for (const root of ["direct/shipment.mjs", "observer/shipment.mjs", "observer/main.mjs"]) {
  console.log(
    `${root.padEnd(22)} outgoing deps=${imports(root).length}  import closure=${closure(root).size}  static calls=${staticCalls(root)}`,
  );
}
direct/shipment.mjs    outgoing deps=4  import closure=5  static calls=4
observer/shipment.mjs  outgoing deps=0  import closure=1  static calls=0
observer/main.mjs      outgoing deps=5  import closure=6  static calls=0

The shipment module’s outgoing dependency count dropped from 4 to 0, and its import closure from 5 to 1. This is the pattern-level counterpart of the dependency-direction gain measured in the Design Principles course: the domain object no longer knows the log, notification, metric, and billing modules, so it does not need to be recompiled or have its test set up a fake dependency when they change. The cost shows up in the closure total: the four dependencies did not vanish, they moved to the composition root, where there are now five.

The third column gives the reading side of the cost. In the direct design, anyone looking at the changeState body can count four calls; in the observer design, the number of calls countable in the same body is zero. Learning what happens on a state change is not enough by reading the body — you have to find the file that wires the subscriptions. This is the same level of indirection measured in the Strategy lesson; the candidate implementation count there corresponds here to the number of registered listeners, and it is unknown until run time.

Adding a Fifth Job

Checking contracted-customer discounts is requested as the fifth job. The script copies both trees, makes the addition, and runs both versions.

// add.mjs — adds a fifth listener to both versions and runs both
import { cpSync, readFileSync, writeFileSync } from "node:fs";

cpSync("direct", "direct-new", { recursive: true });
cpSync("observer", "observer-new", { recursive: true });

const NEW = "export const checkDiscount = (event) => console.log(`  discount  ${event.code} ${event.state}`);\n";
writeFileSync("direct-new/discount.mjs", NEW);
writeFileSync("observer-new/discount.mjs", NEW);

const IMPORT = 'import { updateBilling } from "./billing.mjs";';
const replace = (path, search, next) =>
  writeFileSync(path, readFileSync(path, "utf8").replace(search, next));

replace("direct-new/shipment.mjs", IMPORT, `${IMPORT}\nimport { checkDiscount } from "./discount.mjs";`);
replace("direct-new/shipment.mjs", "      updateBilling(event);", "      updateBilling(event);\n      checkDiscount(event);");
replace("observer-new/main.mjs", IMPORT, `${IMPORT}\nimport { checkDiscount } from "./discount.mjs";`);
replace("observer-new/main.mjs", "updateBilling]", "updateBilling, checkDiscount]");

const { shipment } = await import("./direct-new/shipment.mjs");
const { wiredShipment } = await import("./observer-new/main.mjs");
const original = console.log;
let tally = 0;
console.log = () => {
  tally += 1;
};
shipment("TS-4").changeState("out_for_delivery");
const a = tally;
tally = 0;
wiredShipment("TS-5").changeState("out_for_delivery");
const b = tally;
console.log = original;
console.log(`direct-new jobs triggered = ${a}`);
console.log(`observer-new jobs triggered = ${b}`);
direct-new jobs triggered = 5
observer-new jobs triggered = 5
for k in direct observer; do
  edited=$(diff -rq "$k" "$k-new" | grep -c '^Files')
  added=$(diff -rq "$k" "$k-new" | grep -c '^Only in')
  echo "$k: files edited=$edited  files added=$added  edited files=$(diff -rq "$k" "$k-new" | grep '^Files' | sed 's|.*/\([a-z.]*\.mjs\) and.*|\1|' | tr '\n' ' ')"
done
direct: files edited=1  files added=1  edited files=shipment.mjs
observer: files edited=1  files added=1  edited files=main.mjs

The number of edited files is one in both. The gain is not in the count but in which file: the domain object in the direct design, the composition root in the observer design. Editing the domain object for every new side job raises that object’s number of reasons to change by the number of side jobs; the edit that moves to the composition root, by contrast, matches a file that already has just one reason to change. The difference shows up once the measure is not “how many files” but “how many reasons to change”: the shipment module’s reasons to change drops from 5 to 1.

Summary

  • The Observer pattern lets the object announcing a state change stay ignorant of its listeners; in this lesson notification happens in the same process, on the same call stack.
  • The shipment module’s outgoing dependency count dropped from 4 to 0 and its import closure from 5 to 1; the dependencies did not vanish, they moved to the composition root, where there are now 5.
  • Both designs carry the same behavior: the number of jobs triggered on a state change came out to 4 in both, and to 5 in both after the fifth job was added.
  • Adding the fifth job edited 1 file in both designs; the difference is whether the edited file is the domain object or the composition root, and the measure is the number of reasons to change.
  • The pattern’s cost: when no subscription is set up, the state change completes silently and the number of triggered jobs comes out to 0; the directly calling design has no such path.

Next Step

The Observer announces something that already happened: by the moment the notification is sent, the state has already changed, and there is no going back. The library’s workflow needs the opposite. Applying a discount to a shipment, changing its route, and canceling it are done by an operator, and a wrongly applied operation needs to be undone. Today, these three operations are direct calls that change the domain, and undoing them requires writing the reverse by hand at every call site. The next lesson turns the request into an object and measures the number of operations that can be undone and the number of files edited when undo is added.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close