The Second Pattern: Blackboard
A pipeline works until one step both needs and improves the same piece of information. That is a cycle, and a topological sort has exactly one contract: there are no cycles.
Before a consignment can be quoted properly, five things need working out about it. The address wants normalising into something a carrier will accept. It wants geocoding, because rural postcodes change the price. Anything crossing a border needs a customs commodity code. Dimensional weight has to be calculated, since carriers charge on whichever of volume and mass is larger. And a delivery risk score gets attached, which is what decides whether we ask for a signature.
The obvious way to arrange this is a pipeline, and that is what we built.
final readonly class EnrichConsignment{ public function handle(Consignment $consignment): Consignment { $consignment = $this->normaliseAddress->handle($consignment); $consignment = $this->geocode->handle($consignment); $consignment = $this->classifyCommodity->handle($consignment); $consignment = $this->calculateDimensionalWeight->handle($consignment);
return $this->scoreRisk->handle($consignment); }}That order is not arbitrary. Geocoding needs the normalised address. Risk scoring is much better with coordinates. Commodity classification does not care about either, but it ended up third because that is where somebody put it.
Then the request comes in to add fraud signals, which need the risk score, and also improve the risk score once they have run.
There is nowhere to put it. Before scoreRisk it has no score to work with.
After scoreRisk its output arrives too late to affect anything. What you
actually want is for it to run after risk scoring and then for risk scoring to
run again, which a pipeline cannot express without you calling scoreRisk twice
and hoping nobody asks why.
That is the moment the shape is wrong. Not when the pipeline got long, but when a step turned out to both need and improve the same piece of information.
The obvious move
Sort the dependencies. Each step declares what it requires and what it produces, and something works out a valid order at runtime.
final readonly class GeocodeAddress implements EnrichmentStep{ public function requires(): array { return ['normalisedAddress']; }
public function produces(): array { return ['coordinates']; }}This is a topological sort over a dependency graph, and for most enrichment problems it is the correct answer. It handles insertion without reordering, it fails loudly on a missing dependency, and it is a well-understood thing that any engineer can read.
It also cannot express what we just asked for. Fraud signals require the risk
score and improve the risk score, so the graph has a cycle, and a topological
sort’s entire contract is that there are no cycles. You can break the cycle by
splitting risk scoring into two steps, scoreRiskInitial and scoreRiskFinal,
and this works, and I have seen codebases where that fiction was maintained
across nine steps until nobody could say what the difference between the two
phases was.
The cycle is not a modelling error you should refactor away. It is the truth about the problem. Some of these contributors improve information that other contributors also improve.
The pattern
The Blackboard comes out of Hearsay-II, a speech recognition system built at Carnegie Mellon in the 1970s, and it was written up as an architectural pattern by Buschmann and colleagues in Pattern-Oriented Software Architecture. Speech recognition had exactly this shape: acoustic analysis, phoneme identification, word matching and grammar all contribute to one interpretation, none of them can run to completion alone, and what any of them can do depends on what the others have already worked out.
The structure is three parts. There is a blackboard, a shared workspace holding the current best understanding. There are knowledge sources, independent contributors that each read the blackboard, decide whether they have anything useful to add right now, and write back if they do. And there is a control component, which repeatedly asks the contributors whether they can act and stops when none of them can.
The word doing the work is opportunistic. No contributor is scheduled. Each one is offered the current state and answers for itself, so a contributor can run early, late, more than once, or never, and the sequence that emerges is a consequence of the data rather than a decision anybody wrote down.
That is what dissolves the fraud signals problem. Fraud signals sit there declining to act until a risk score appears, then contribute. Risk scoring observes that new signals exist that it has not accounted for and contributes again. The loop runs until nothing changes, and neither contributor knows the other exists.
When this is worth it
The condition to look for is contributors that improve each other’s output rather than only filling in gaps. Filling gaps is a dependency graph. Mutual improvement is a blackboard, and it is the cycle in the graph that tells you which one you have.
It also fits when contributors are genuinely optional. If half of them do not apply to a given consignment, domestic parcels needing no customs work at all, then a pipeline spends its time in guard clauses while a blackboard just does not offer those contributors anything they can act on.
The third case is when contributors arrive and leave often. Nine of them, changing every few months as somebody adds a data provider, and the cost of reordering a pipeline each time starts to outweigh the cost of a control loop.
When it is not
Skip all of it if your sequence is fixed. Where the order has held for two years and nothing has pushed against it, that first pipeline is better code than anything I have written since, and it should be left alone.
An acyclic dependency graph does not need it either, and this is the important one. If your steps genuinely form a DAG, sort it. You get a deterministic order, a clear failure on a missing dependency, and none of the difficulty that comes next. Reach for a blackboard only once you have a real cycle you cannot honestly remove.
I would also avoid it where the ordering must be auditable. Because the sequence emerges from the data, two consignments can be enriched in two different orders, and if you are in a regulated context where somebody has to explain why a decision was reached, “it depended on what was available at the time” is a hard answer to defend.
Building it in Tempest
The blackboard itself is the least interesting part, which is a good sign.
final class EnrichmentBoard{ private array $facts = [];
private int $revision = 0;
public function has(string $key): bool { return array_key_exists($key, $this->facts); }
public function get(string $key): mixed { return $this->facts[$key] ?? null; }
public function contribute(string $key, mixed $value, int $confidence): bool { $existing = $this->facts[$key] ?? null;
if ($existing !== null && $existing['confidence'] >= $confidence) { return false; }
$this->facts[$key] = ['value' => $value, 'confidence' => $confidence]; $this->revision++;
return true; }
public function confidenceOf(string $key): int { return $this->facts[$key]['confidence'] ?? -1; }
public function revision(): int { return $this->revision; }}The confidence check is what stops this running forever. Two contributors that improve each other’s output will otherwise do so indefinitely, and you do not want to discover what an unbounded loop looks like inside a relay process. A contribution only lands if it beats what is already on the board, so the supply of improvements runs out and the thing settles.
The revision counter alongside it is what I got wrong on my first attempt, and
I would rather explain it than quietly fix it. My control loop originally
decided that progress had been made whenever a contributor said it could act. But
a contributor can act and still have its contribution rejected as no better than
what was there, so the loop would keep going on the strength of contributors
merely being willing. The confidence check was doing nothing, because nothing was
looking at its answer. Progress has to be measured on the board, not on the
contributors.
A contributor is small:
interface Contributor{ public function canContribute(EnrichmentBoard $board): bool;
public function contribute(EnrichmentBoard $board): void;}final readonly class GeocodeAddress implements Contributor{ public function __construct( private Geocoder $geocoder, ) {}
public function canContribute(EnrichmentBoard $board): bool { return $board->has('normalisedAddress') && ! $board->has('coordinates'); }
public function contribute(EnrichmentBoard $board): void { $board->contribute( key: 'coordinates', value: $this->geocoder->locate($board->get('normalisedAddress')), confidence: 90, ); }}canContribute() is the whole design. It is the only place a contributor states
its relationship to the rest, and it manages to do that without naming any of
them.
That one is a gap filler, though, and gap fillers are the easy case. Here is the one the article is actually about:
final readonly class ScoreDeliveryRisk implements Contributor{ public function canContribute(EnrichmentBoard $board): bool { return $board->has('coordinates') && $board->confidenceOf('deliveryRisk') < $this->availableConfidence($board); }
public function contribute(EnrichmentBoard $board): void { $board->contribute( key: 'deliveryRisk', value: $this->score($board), confidence: $this->availableConfidence($board), ); }
private function availableConfidence(EnrichmentBoard $board): int { return $board->has('fraudSignals') ? 95 : 60; }}Risk scoring runs early on coordinates alone and puts a score on the board at middling confidence. Fraud signals then act, because a score now exists for them to work from. Risk scoring becomes willing again, since it can now do better than its own earlier answer, and replaces it at higher confidence. Then nobody can improve on anything and the loop ends.
Neither class mentions the other. The cycle that broke the topological sort is expressed here as two contributors each answering a question about the board, and the second pass happens because the data changed rather than because anybody scheduled it.
Registration, which is where Tempest helps
Every contributor has to be found, and this is where a framework normally makes you maintain a list. Discovery means you do not:
use Tempest\Discovery\Discovery;use Tempest\Discovery\DiscoveryLocation;use Tempest\Discovery\IsDiscovery;use Tempest\Reflection\ClassReflector;
final class ContributorDiscovery implements Discovery{ use IsDiscovery;
public function __construct( private readonly EnrichmentConfig $config, ) {}
public function discover(DiscoveryLocation $location, ClassReflector $class): void { if (! $class->implements(Contributor::class)) { return; }
$this->discoveryItems->add($location, $class->getName()); }
public function apply(): void { foreach ($this->discoveryItems as $className) { $this->config->addContributor($className); } }}Writing a class that implements Contributor is the entire act of adding one.
Nothing central to edit, no ordering to reconsider, no list to forget. This is
the same mechanism from article one, and it is the third time it has come up in
this series, which is the point I was making then about it being worth learning
properly.
Note that it stores class names and lets the container resolve them, because contributors have dependencies and discovery runs at boot.
The control loop
final readonly class EnrichmentController{ public function __construct( private Container $container, private EnrichmentConfig $config, ) {}
public function run(EnrichmentBoard $board, int $maxPasses = 10): void { for ($pass = 0; $pass < $maxPasses; $pass++) { $before = $board->revision();
foreach ($this->config->contributors as $className) { $contributor = $this->container->get($className);
if (! $contributor->canContribute($board)) { continue; }
$contributor->contribute($board); }
if ($board->revision() === $before) { return; } }
throw new EnrichmentDidNotSettle($board, $maxPasses); }}Twenty lines, and every one of them matters.
The maxPasses ceiling is a second defence behind the confidence check. If a
contributor is written badly enough to keep claiming it can act, this turns an
infinite loop into an exception with the board attached, which is a debuggable
Tuesday rather than a pager at four in the morning.
Throwing when it fails to settle, rather than returning what it has, is a deliberate choice and arguably the wrong one for your context. A consignment enriched by a process that did not finish is a consignment whose quote may be wrong, and I would rather fail than price something incorrectly. If partial enrichment is acceptable to you, return the board and record that it did not settle, but decide it rather than defaulting into it.
What it costs
The order is no longer knowable by reading the code, and that is not a small thing. With a pipeline you can see what happens. Here you have contributors and a loop, and answering the question of why this consignment got a signature requirement means reconstructing what ran and in what order. Recording each contribution as it happens is not optional at this level of indirection, and without it you have built something you cannot support.
Testing gets bimodal. Individual contributors become lovely to test, because they are pure functions of a board and you can construct exactly the board you want. The system as a whole becomes harder, since you are asserting on emergent behaviour and a new contributor can change an outcome without any existing test mentioning it.
There is a performance shape you should look at before committing. Every pass
asks every contributor whether it can act, so a settle taking four passes with
nine contributors is thirty-six canContribute() calls. That is fine when those
are cheap array checks and quite bad if somebody puts an API call in one. Keep
canContribute() free of side effects and free of network, and make that a rule
rather than a preference.
The one I would watch for is contributors quietly acquiring knowledge of one
another. Six months in, somebody writes canContribute() that checks for a value
only one specific other contributor produces, and now they are coupled without
either declaring it. It still works. It is also no longer a blackboard, it is a
pipeline with extra steps, and it will be discovered when somebody removes the
first contributor and the second silently stops running.
Where this leaves us
Blackboard is a narrow pattern and I would not want this article to send anybody towards it who has a working pipeline. The specific thing it addresses is contributors that improve each other’s output, where there is no correct order because the order depends on what has been worked out so far. If your steps form a clean DAG, sort them and move on.
If you do have the cycle, this is the arrangement that stops you lying about it. Contributors declare when they can act rather than where they belong, the sequence emerges, and adding a tenth one is writing a class rather than renegotiating a sequence.
Next in the series, and last: the patterns I do not think earn their keep in PHP, and why saying so is what the other nine were for.
The Second Pattern
You are reading Part 9 of 10 in this learning series.
Keep Reading
The Second Pattern: The Ones I Left Out
Five patterns I know well, that come up constantly, and would not reach for in PHP. Not because they are bad ideas, but because of what the runtime does and does not give you.
Sept 2026 · 12 min read
PHPThe Second Pattern: Event-Carried State Transfer
A consumer that receives an ID and immediately asks you for the record has not been decoupled from you. It has been given a slightly slower way to call your API.
Sept 2026 · 12 min read
PHPThe Second Pattern: Process Manager
Everyone calls it a Saga. Most of the time it is a Process Manager, and the muddle is why teams adopt the word and then never write the compensations it never promised them.
Sept 2026 · 14 min read