---
title: 'Blackboard Architecture'
source: 'https://academia.sh/en/courses/architectural-styles/blackboard-architecture'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:10+00:00'
license: 'CC BY-SA 4.0'
---

# Blackboard Architecture

Resolvers that do not know each other meeting through a shared field of information: the number of names units know about each other coming out to zero in determining a fee zone from an address, the extra reads and polls spent to reach the same result, the number of files a new resolver edits, and whether the result is deterministic across twenty-four poll orders.

The previous lesson's core did not know the plugins' names, but it knew what the order was and
how the result would be summed.

This lesson removes that center too. Units recognize neither each other nor a pattern that calls
them; they write to a shared field, read from it, and the field itself provides the meeting point.
Three things are measured: the names they know, the extra work spent on the same result — how
many units read the same data, how many rounds spun empty — and whether the same input gives the
same output every run.

## Blackboard, Resolver, and Control

The **blackboard** pattern has three parts. The first is the board: the shared field of named
partial results. The second is the **resolvers**: units that read from and write to the board,
not knowing each other's names. The third is the **control loop**: the loop deciding which
resolver to poll and when, without knowing what they do.

This differs from the observer pattern's notification: in the Design Patterns course's Observer
lesson, the publisher keeps a subscriber list and sends the notification directly, while on the
blackboard the writer does not know who will read it. The distinguishing constraint is this: a
resolver can only read and write the board, never call another resolver.

## Ambiguous Zone and Four Resolvers

The zone the fee depends on cannot be determined from the incoming address alone: the postal code
is missing a digit. Four resolvers satisfy the same contract — `name` and `resolve(read)` — and
each returns a partial result along with a **confidence** degree. The province-name resolver
writes no zone claim, only the normalized province name; the postal-code resolver needs that name
when the code is incomplete.

```sh
mkdir -p blackboard/resolvers
cat > blackboard/fee.mjs <<'EOF'
// blackboard/fee.mjs — zone to fee: same shipment, coefficient by zone
const BASE = 8490;
const STEP = 50;
const COEFFICIENT = { near: 1, mid: 1.35, far: 1.8 };

export const amount = (zone) => Math.round((BASE * (COEFFICIENT[zone] ?? COEFFICIENT.far)) / STEP) * STEP;
EOF
cat > blackboard/resolvers/province-name.mjs <<'EOF'
// blackboard/resolvers/province-name.mjs — normalizes the province name; writes no zone claim
const PROVINCES = ["istanbul", "ankara", "izmir", "van"];

export const provinceName = { name: "province-name", resolve: (read) => {
  const name = read("address").province.toLowerCase();
  return PROVINCES.includes(name) ? { confidence: 0.7, write: { normalizedProvince: name } } : null;
} };
EOF
cat > blackboard/resolvers/postal-code.mjs <<'EOF'
// blackboard/resolvers/postal-code.mjs — full code directly; missing code falls to the province's farthest zone
const PREFIX = { 34: "near", "06": "mid", 35: "mid", 65: "far" };
const PROVINCE_FARTHEST = { istanbul: "mid", ankara: "far", izmir: "far", van: "far" };

export const postalCode = { name: "postal-code", resolve: (read) => {
  const code = read("address").postalCode;
  if (code.length === 5) return { confidence: 0.85, write: { zone: PREFIX[code.slice(0, 2)] ?? "far" } };
  const province = read("normalizedProvince");
  return province === undefined ? null : { confidence: 0.3, write: { zone: PROVINCE_FARTHEST[province] ?? "far" } };
} };
EOF
cat > blackboard/resolvers/coordinate.mjs <<'EOF'
// blackboard/resolvers/coordinate.mjs — maps the approximate straight-line distance to the center against thresholds
const CENTER = { latitude: 41.02, longitude: 28.98 };
const THRESHOLD = [[300, "near"], [900, "mid"]];

export const coordinate = { name: "coordinate", resolve: (read) => {
  const a = read("address");
  const dLatitude = (a.latitude - CENTER.latitude) * 111;
  const dLongitude = (a.longitude - CENTER.longitude) * 111 * Math.cos((CENTER.latitude * Math.PI) / 180);
  const km = Math.round(Math.hypot(dLatitude, dLongitude));
  return { confidence: 0.9, write: { zone: THRESHOLD.find(([s]) => km <= s)?.[1] ?? "far" } };
} };
EOF
cat > blackboard/resolvers/shipment-history.mjs <<'EOF'
// blackboard/resolvers/shipment-history.mjs — the zone of the same customer's most recent shipments
const HISTORY = { "M-118": "near", "M-204": "mid" };

export const shipmentHistory = { name: "shipment-history", resolve: (read) => {
  const z = HISTORY[read("address").customer];
  return z === undefined ? null : { confidence: 0.55, write: { zone: z } };
} };
EOF
```

## The Board, the Control Loop, and the Direct Counterpart

The board takes one of two write rules: under **first write wins**, a second write to an occupied
key is rejected; under **most confident wins**, only a write with higher confidence passes.

```js
// blackboard/board.mjs — the shared field: write rule, reads and writes counters
export const board = (rule, initial) => {
  const field = new Map(Object.entries(initial));
  const confidence = new Map();
  const readers = new Map();
  const counters = { reads: 0, writes: 0, rejected: 0 };
  return {
    read: (key, who) => {
      counters.reads += 1;
      readers.set(key, (readers.get(key) ?? new Set()).add(who));
      return field.get(key);
    },
    write: (key, value, c) => {
      const occupied = field.has(key);
      const passes = rule === "first-write" ? occupied === false : (confidence.get(key) ?? -1) < c;
      if (passes === false) { counters.rejected += 1; return; }
      field.set(key, value);
      confidence.set(key, c);
      counters.writes += 1;
    },
    value: (key) => field.get(key),
    measure: () => ({ ...counters, readersByKey: Object.fromEntries([...readers].map(([k, s]) => [k, s.size])) }),
  };
};
```

The control loop polls in the given order: a resolver that contributes leaves the pending set, one
that cannot is polled again next round, and the loop runs until it sees a round with no
contribution.

```js
// blackboard/control.mjs — control loop: polls in the given order, cycles rounds until no contribution remains
export const controlLoop = (t, resolvers) => {
  const pending = new Set(resolvers);
  let round = 0, poll = 0, contribution = 0, emptyRound = 0;
  while (true) {
    round += 1;
    let roundContribution = 0;
    for (const r of resolvers) {
      if (pending.has(r) === false) continue;
      poll += 1;
      const result = r.resolve((k) => t.read(k, r.name));
      if (result === null) continue;
      pending.delete(r);
      for (const [k, v] of Object.entries(result.write)) t.write(k, v, result.confidence);
      contribution += 1;
      roundContribution += 1;
    }
    if (roundContribution === 0) { emptyRound += 1; break; }
  }
  return { round, poll, contribution, emptyRound };
};
```

The arrangement to compare calls the same four resolvers directly: the order and the selection
rule are written in this file; the order was parameterized for measurement.

```js
// blackboard/direct.mjs — direct call: this file knows the four names, the order, and the selection rule
import { provinceName } from "./resolvers/province-name.mjs";
import { postalCode } from "./resolvers/postal-code.mjs";
import { coordinate } from "./resolvers/coordinate.mjs";
import { shipmentHistory } from "./resolvers/shipment-history.mjs";

export const ORDER = [provinceName, postalCode, coordinate, shipmentHistory];

export const directResolve = (address, order = ORDER) => {
  const data = { address };
  let reads = 0, contribution = 0, best = { confidence: -1, zone: undefined };
  for (const r of order) {
    const result = r.resolve((k) => (reads += 1, data[k]));
    if (result === null) continue;
    contribution += 1;
    Object.assign(data, result.write);
    const z = result.write.zone;
    if (z !== undefined && result.confidence > best.confidence) best = { confidence: result.confidence, zone: z };
  }
  return { ...best, round: 1, poll: order.length, contribution, reads };
};
```

```js
// blackboard/main.mjs — composition root: resolver list and the measured address
import { postalCode } from "./resolvers/postal-code.mjs";
import { provinceName } from "./resolvers/province-name.mjs";
import { coordinate } from "./resolvers/coordinate.mjs";
import { shipmentHistory } from "./resolvers/shipment-history.mjs";

export const RESOLVERS = [postalCode, provinceName, coordinate, shipmentHistory];

export const ADDRESS = { postalCode: "0680", province: "ANKARA", latitude: 39.93, longitude: 32.86, customer: "M-118" };
```

## Known Names and Work Done

The script counts how many **other** unit names appear in each file and runs both arrangements.

```js
// names-and-work.mjs — how many other unit names each unit knows, and the work both arrangements do
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { board } from "./blackboard/board.mjs";
import { controlLoop } from "./blackboard/control.mjs";
import { directResolve } from "./blackboard/direct.mjs";
import { RESOLVERS, ADDRESS } from "./blackboard/main.mjs";
import { amount } from "./blackboard/fee.mjs";

const NAMES = RESOLVERS.map((r) => r.name);
const files = readdirSync("blackboard", { recursive: true })
  .map((a) => join("blackboard", a)).filter((y) => y.endsWith(".mjs")).sort();

const table = files.map((path) => {
  const text = readFileSync(path, "utf8");
  const own = NAMES.find((a) => path.includes(a));
  return [path, NAMES.filter((a) => a !== own && text.includes(a))];
});
console.log(`file = ${table.length}, other unit name 0 = ${table.filter(([, b]) => b.length === 0).length}`);
for (const [path, b] of table.filter(([, b]) => b.length > 0)) {
  console.log(`  ${path.padEnd(23)} other unit name = ${b.length}  (${b.join(", ")})`);
}

const t = board("most-confident", { address: ADDRESS });
const o = controlLoop(t, RESOLVERS);
const m = t.measure();
const z = t.value("zone");
console.log(`blackboard     zone = ${z} amount = ${amount(z)}`
  + `  round = ${o.round}  poll = ${o.poll}  contribution = ${o.contribution}  empty round = ${o.emptyRound}`);
console.log(`  reads = ${m.reads}  writes = ${m.writes}  rejected write = ${m.rejected}`
  + `  readers by key = ${Object.entries(m.readersByKey).map(([a, n]) => `${a}:${n}`).join(" ")}`);

const d = directResolve(ADDRESS);
console.log(`direct call    zone = ${d.zone} amount = ${amount(d.zone)}`
  + `  round = ${d.round}  poll = ${d.poll}  contribution = ${d.contribution}  empty round = 0`);
console.log(`  reads = ${d.reads}  writes = none  rejected write = none`);
```

```sh
node names-and-work.mjs
```

```
file = 9, other unit name 0 = 7
  blackboard/direct.mjs   other unit name = 4  (postal-code, province-name, coordinate, shipment-history)
  blackboard/main.mjs     other unit name = 4  (postal-code, province-name, coordinate, shipment-history)
blackboard     zone = mid amount = 11450  round = 3  poll = 5  contribution = 4  empty round = 1
  reads = 7  writes = 2  rejected write = 2  readers by key = address:4 normalizedProvince:1
direct call    zone = mid amount = 11450  round = 1  poll = 4  contribution = 4  empty round = 0
  reads = 5  writes = none  rejected write = none
```

Seven of the nine files carry no other unit's name: the board, the control loop, the fee
conversion, and all four resolvers are 0. Two files know the names, both carrying 4, and they
differ in kind: on the blackboard the knowledge sits in a composition-root list; in the
direct-call arrangement it sits next to the call order and the selection rule.

The second half shows the cost. Both arrangements found the same zone and amount, but the
blackboard spent 3 rounds, 5 polls, and 7 reads; the direct call finished in 1 round with 4 polls
and 5 reads. The extra poll belongs to the postal-code resolver: on the first round the normalized
province was not yet on the board, so it was polled again on the second; one round spun empty just
to detect termination. Four units read the `address` key, since none knows what the others read.

## Same Input, Twenty-Four Orders

The control loop takes the order from outside; whether the result changes when the order changes
is a measurable question. The 24 arrangements of the four resolvers are run.

```js
// determinism.mjs — same input, 24 different poll orders: is the result and contribution order-dependent?
import { board } from "./blackboard/board.mjs";
import { controlLoop } from "./blackboard/control.mjs";
import { directResolve } from "./blackboard/direct.mjs";
import { RESOLVERS, ADDRESS } from "./blackboard/main.mjs";
import { amount } from "./blackboard/fee.mjs";

const permutation = (items) => (items.length <= 1 ? [items]
  : items.flatMap((e, i) => permutation([...items.slice(0, i), ...items.slice(i + 1)]).map((p) => [e, ...p])));
const ORDERS = permutation(RESOLVERS);

const distribution = (runs, attr) => {
  const m = new Map();
  for (const r of runs) m.set(r[attr], (m.get(r[attr]) ?? 0) + 1);
  return [...m].sort();
};

const report = (label, runs) => {
  const zones = distribution(runs, "zone");
  console.log(`${label.padEnd(27)} distinct zone = ${zones.length}:`
    + ` ${zones.map(([z, n]) => `${z} ${n} orders ${amount(z)}`).join(", ")}`);
  console.log(`  contribution = ${distribution(runs, "contribution").map(([k, n]) => `${k} (${n} orders)`).join(", ")}`);
};

for (const rule of ["first-write", "most-confident"]) {
  report(`blackboard / ${rule}`, ORDERS.map((order) => {
    const t = board(rule, { address: ADDRESS });
    const o = controlLoop(t, order);
    return { zone: t.value("zone"), contribution: o.contribution };
  }));
}
report("direct call", ORDERS.map((order) => directResolve(ADDRESS, order)));
```

```sh
node determinism.mjs
```

```
blackboard / first-write    distinct zone = 3: far 2 orders 15300, mid 11 orders 11450, near 11 orders 8500
  contribution = 4 (24 orders)
blackboard / most-confident distinct zone = 1: mid 24 orders 11450
  contribution = 4 (24 orders)
direct call                 distinct zone = 1: mid 24 orders 11450
  contribution = 3 (12 orders), 4 (12 orders)
```

The first pair of lines shows the pattern's most expensive flaw. Under the first-write rule, the
same address resolved to three different zones across the 24 orderings, and the same shipment was
fee'd at 8,500, 11,450, or 15,300 cents. What determines the result is not the address but which
resolver the control loop polls first; under this rule the pattern is **not deterministic**.

The second pair shows the flaw is in the write rule, not the board: under the most-confident rule,
all 24 orderings gave the same zone and amount. Determinism comes from having a comparable
confidence degree in the contract.

The third pair gives the blackboard's counterpart. The direct call produced the same result in
every order, but in 12 of the 24 orderings the contribution came out 3 instead of 4: the
postal-code resolver was called before the normalized province name, so it was eliminated and
never asked again. On the blackboard, the contribution is 4 in every case, since a pending
resolver is polled again next round.

## The Fifth Resolver

The fifth resolver proposes the default zone written in the customer contract. The block adds it
to a copy of the tree, lists the edited files, and loads the patched tree.

```sh
cp -r blackboard fifth
cat > fifth/resolvers/contract-default.mjs <<'EOF'
// fifth/resolvers/contract-default.mjs — fifth resolver: default zone written in the contract
const CONTRACT = { "M-118": "mid", "M-204": "near" };

export const contractDefault = { name: "contract-default", resolve: (read) => {
  const z = CONTRACT[read("address").customer];
  return z === undefined ? null : { confidence: 0.4, write: { zone: z } };
} };
EOF
python3 - <<'EOF'
from pathlib import Path

HISTORY = 'import { shipmentHistory } from "./resolvers/shipment-history.mjs";'
NEW = 'import { contractDefault } from "./resolvers/contract-default.mjs";'
PAIR = [(HISTORY, f"{HISTORY}\n{NEW}"), ("shipmentHistory];", "shipmentHistory, contractDefault];")]
for name in ["main.mjs", "direct.mjs"]:
    path = Path("fifth") / name
    text = path.read_text().replace("// blackboard/", "// fifth/")
    for old, new in PAIR:
        text = text.replace(old, new)
    path.write_text(text)
EOF
diff -rq blackboard fifth
node -e 'Promise.all([import("./fifth/main.mjs"), import("./fifth/direct.mjs")])
  .then(([k, d]) => console.log(`fifth tree: main list = ${k.RESOLVERS.length}, direct order = ${d.ORDER.length}`))'
```

```
Files blackboard/direct.mjs and fifth/direct.mjs differ
Files blackboard/main.mjs and fifth/main.mjs differ
Only in fifth/resolvers: contract-default.mjs
fifth tree: main list = 5, direct order = 5
```

One file was added and two edited: the composition root and the direct-call arrangement. The
board, the control loop, the fee conversion, and the four resolvers did not change at all. The
file count is the same in both arrangements, not the kind — one is only a list, the other the call
order plus the selection rule.

## The Trade-off's Numbers

The gain was measured in two quality attributes: in modifiability, seven of nine files carry no
other unit's name, and the fifth resolver left the board, the control loop, and the four resolvers
untouched; in integrity, the contribution is 4 in every one of the 24 orderings.

The cost has three items. Extra work: 3 rounds, 5 polls, and 7 reads against the direct call's 1
round, 4 polls, and 5 reads. Determinism: with the wrong write rule, the same address produced
three different fees. Visibility: only the board's record shows which resolver determined the
result.

## Summary

- The blackboard pattern has three parts: the shared field partial results are written to,
  resolvers that do not know each other's names, and a control loop that does not know what they
  do; since the writer does not know who reads, this differs from the observer pattern's
  notification.
- Seven of nine files carry 0 other-unit names; the two that know the names are the composition
  root and the direct-call arrangement, and both carry 4.
- For the same result the blackboard spent 3 rounds, 5 polls, and 7 reads; the direct call spent 1
  round, 4 polls, and 5 reads; 4 units read the `address` key, and 1 round spun empty.
- Under the first-write rule, the 24 orderings produced 3 zones and 3 amounts (8,500, 11,450,
  15,300); under the most-confident rule, all gave a single zone and a single amount.
- The contribution is 4 in all 24 orderings on the blackboard, and 3 in 12 orderings for the
  direct call; the fifth resolver added 1 file and edited 2.

## Next Step

This topic addressed **how units are arranged** and which way the dependency arrow points: layered
orders, inward-facing rings, distributable components, pluggable plugins, and resolvers that never
know each other. In the blackboard pattern, units wrote to a shared field, read from it, and the
field itself provided the meeting point. The next topic asks not about arrangement but about the
**conversation style**: in an interaction between two units, who initiates, who waits, and how
much must be known about the other side.
