Skip to content
academia.sh

Lesson 05 / 18

Microkernel and Plugin Architecture

Separating an unchanging small core from plugins of unknown number: the core file's known plugin-name count falling from four to zero, measuring the number of lines edited in the core when a fifth plugin is added, the core running with a one-file closure with no plugins attached, and counting how many requests a contract-breaking plugin stops in each arrangement.

Contents

The component boundary produced a unit of reuse, but those measures were all at release time. The open question is which units can be plugged in and out while the program is running. A boundary is a release decision; pluggability is a run-time decision, and the two differ.

This lesson examines the pattern that splits units into two classes: an unchanging small part that runs on its own, and parts of unknown number attached on top of it. Four things are measured: the names the small part knows about the others, the lines a new part edits, whether the core runs with none attached, and whether a broken part stops the whole.

Core, Plugin, and Contract

The microkernel pattern declares three things. The first is the core: the smallest working whole that produces a response with no plugin attached. In the Introduction to Linux course, the kernel was the operating system’s hardware-managing part; the core here is a library’s smallest working part, and the two uses differ. The second is the plugin: a unit that extends the core’s capability without the core knowing its name. The third is the plugin contract: the names a plugin must satisfy.

This contract is not new; it is a port, established in the Domain-Driven Design course’s Ports and Adapters lesson. The component boundary and its splitting principles were established in the Design Principles course’s Component Cohesion Principles and Component Coupling Principles lessons. What is described here is a pattern, not a principle: which counts change once a contract binds to a run-time registry. The constraint is one sentence — the core carries no plugin’s name, only the contract’s names.

The Library’s Pluggable Arrangement

The fee calculation splits into two parts. The core finds the base from the tier table, rounds it, and applies the minimum fee; every item added to or subtracted from the base is a plugin. Four plugins satisfy one contract: name, fields naming shipment fields it reads, and item returning cents. The last file belongs to measurement, not the library.

mkdir -p microkernel/plugins
cat > microkernel/tariff.mjs <<'EOF'
// microkernel/tariff.mjs — tier table: not a plugin, but the core's construction input
export const TIERS = [
  { weightCap: 1, fee: 4990 }, { weightCap: 5, fee: 8490 },
  { weightCap: 15, fee: 14990 }, { weightCap: 30, fee: 24990 },
];
EOF
cat > microkernel/plugins/contract-discount.mjs <<'EOF'
// microkernel/plugins/contract-discount.mjs — negative item based on the contract rate
const RATE = { none: 0, standard: 0.05, bulk: 0.12 };

export const contractDiscount = { name: "contract-discount", fields: ["contract"],
  item: (s, base) => -Math.round(base * (RATE[s.contract] ?? 0)) };
EOF
cat > microkernel/plugins/volume-difference.mjs <<'EOF'
// microkernel/plugins/volume-difference.mjs — the amount by which volumetric weight exceeds actual weight
const VOLUME_DIVISOR = 5000;
const KG_RATE = 900;

export const volumeDifference = { name: "volume-difference", fields: ["weight", "volume"],
  item: (s) => Math.round(Math.max(0, s.volume / VOLUME_DIVISOR - s.weight) * KG_RATE) };
EOF
cat > microkernel/plugins/insurance-fee.mjs <<'EOF'
// microkernel/plugins/insurance-fee.mjs — four thousandths of the declared value
const INSURANCE_RATE = 0.004;

export const insuranceFee = { name: "insurance-fee", fields: ["declaredValue"],
  item: (s) => Math.round(s.declaredValue * INSURANCE_RATE) };
EOF
cat > microkernel/plugins/zone-difference.mjs <<'EOF'
// microkernel/plugins/zone-difference.mjs — fixed additional item based on zone
const DIFFERENCE = { near: 0, mid: 1500, far: 4200 };

export const zoneDifference = { name: "zone-difference", fields: ["zone"],
  item: (s) => DIFFERENCE[s.zone] ?? DIFFERENCE.far };
EOF
cat > sample-shipments.mjs <<'EOF'
// sample-shipments.mjs — the four shipments used across all measures
export const SHIPMENTS = [
  { weight: 3, volume: 24000, zone: "mid", declaredValue: 150000, contract: "standard" },
  { weight: 0.4, volume: 1200, zone: "near", declaredValue: 0, contract: "none" },
  { weight: 12, volume: 90000, zone: "far", declaredValue: 400000, contract: "bulk" },
  { weight: 28, volume: 60000, zone: "mid", declaredValue: 25000, contract: "none" },
];
EOF

The comparison runs between two cores. The first imports four components by name and fixes their order inside itself.

// microkernel/embedded.mjs — a core that imports four components by name and fixes their order inside itself
import { contractDiscount } from "./plugins/contract-discount.mjs";
import { volumeDifference } from "./plugins/volume-difference.mjs";
import { insuranceFee } from "./plugins/insurance-fee.mjs";
import { zoneDifference } from "./plugins/zone-difference.mjs";

const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;

export const embeddedCore = (tiers) => ({
  calculate: (shipment) => {
    const tier = tiers.find((t) => shipment.weight <= t.weightCap);
    if (tier === undefined) throw new RangeError("no weight tier");
    const base = tier.fee;
    const total = base
      + contractDiscount.item(shipment, base)
      + volumeDifference.item(shipment, base)
      + insuranceFee.item(shipment, base)
      + zoneDifference.item(shipment, base);
    const net = Math.round(total / ROUNDING_STEP) * ROUNDING_STEP;
    return { amount: Math.max(net, MINIMUM_FEE), applied: 4, dropped: [] };
  },
});

The second keeps a plugin registry. A plugin that fails the contract is rejected at attach time; one that breaks at run time is dropped for that request and its name reported.

// microkernel/pluggable.mjs — the core knows only the contract's names; no plugin name appears
const MINIMUM_FEE = 3990;
const ROUNDING_STEP = 50;

export const CONTRACT = ["name", "fields", "item"];

const satisfies = (e) => typeof e?.name === "string"
  && Array.isArray(e?.fields) && typeof e?.item === "function";

export const core = (tiers) => {
  const registry = [];
  return {
    attach: (e) => (satisfies(e) ? (registry.push(e), true) : false),
    attached: () => registry.map((e) => e.name),
    calculate: (shipment) => {
      const tier = tiers.find((t) => shipment.weight <= t.weightCap);
      if (tier === undefined) throw new RangeError("no weight tier");
      const base = tier.fee;
      const dropped = [];
      let total = base;
      for (const e of registry) {
        let d = null;
        try { if (e.fields.every((f) => shipment[f] !== undefined)) d = e.item(shipment, base); }
        catch { d = null; }
        if (Number.isFinite(d)) total += d; else dropped.push(e.name);
      }
      const net = Math.round(total / ROUNDING_STEP) * ROUNDING_STEP;
      return { amount: Math.max(net, MINIMUM_FEE), applied: registry.length - dropped.length, dropped };
    },
  };
};

The only place plugin names are written is the composition root.

// microkernel/main.mjs — composition root: builds the core, attaches plugins in the declared order
import { core } from "./pluggable.mjs";
import { contractDiscount } from "./plugins/contract-discount.mjs";
import { volumeDifference } from "./plugins/volume-difference.mjs";
import { insuranceFee } from "./plugins/insurance-fee.mjs";
import { zoneDifference } from "./plugins/zone-difference.mjs";

export const PLUGINS = [contractDiscount, volumeDifference, insuranceFee, zoneDifference];

export const build = (tiers, plugins = PLUGINS) => {
  const c = core(tiers);
  const rejected = plugins.filter((e) => c.attach(e) === false).length;
  return { c, rejected };
};

The Number of Names the Core Knows

The script counts how many plugin names appear in three files’ text, extracts their closures, runs the core without plugins, and checks that both arrangements produce the same amount.

// core-measure.mjs — the plugin name count, import closure, and run without plugins for the core files
import { readFileSync } from "node:fs";
import { dirname, join, normalize } from "node:path";
import { TIERS } from "./microkernel/tariff.mjs";
import { embeddedCore } from "./microkernel/embedded.mjs";
import { core, CONTRACT } from "./microkernel/pluggable.mjs";
import { PLUGINS, build } from "./microkernel/main.mjs";
import { SHIPMENTS } from "./sample-shipments.mjs";

const NAMES = PLUGINS.map((e) => e.name);

const closure = (root, seen = new Set()) => {
  if (seen.has(root)) return seen;
  seen.add(root);
  const found = readFileSync(root, "utf8").matchAll(/^import\s.*?from\s+"(\.[^"]+)"/gm);
  for (const [, y] of found) closure(normalize(join(dirname(root), y)), seen);
  return seen;
};

console.log(`plugin = ${NAMES.length}, contract name = ${CONTRACT.length} (${CONTRACT.join(", ")})`);
for (const d of ["embedded.mjs", "pluggable.mjs", "main.mjs"]) {
  const text = readFileSync(`microkernel/${d}`, "utf8");
  console.log(`  ${d.padEnd(14)} known plugin name = ${NAMES.filter((a) => text.includes(a)).length}`
    + `  import closure = ${closure(`microkernel/${d}`).size} file`);
}

const empty = core(TIERS).calculate(SHIPMENTS[0]);
console.log(`\nunplugged core: attached = 0, amount = ${empty.amount}, applied = ${empty.applied}`);
const pairs = SHIPMENTS.map((s) => [embeddedCore(TIERS).calculate(s), build(TIERS).c.calculate(s)]);
console.log(`amounts with four plugins = ${pairs.map(([a]) => a.amount).join(", ")}`
  + `  applied = ${pairs[0][1].applied}`);
console.log(`diverging result = ${pairs.filter(([a, b]) => a.amount !== b.amount).length} / ${pairs.length}`
  + `, rejected plugin = ${build(TIERS).rejected}`);
node core-measure.mjs
plugin = 4, contract name = 3 (name, fields, item)
  embedded.mjs   known plugin name = 4  import closure = 5 file
  pluggable.mjs  known plugin name = 0  import closure = 1 file
  main.mjs       known plugin name = 4  import closure = 6 file

unplugged core: attached = 0, amount = 8500, applied = 0
amounts with four plugins = 11800, 5000, 24400, 26600  applied = 4
diverging result = 0 / 4, rejected plugin = 0

The first three lines measure the pattern. The embedded core carries four plugin names, closure five files: loading the rule means loading all four plugins too. The pluggable core carries zero names, closure one file. The names did not vanish, they moved: the composition root knows all four, closure six files. The pattern shifts the knowledge burden onto a single setup file.

The last three lines show the core running without plugins: for a 3 kg shipment, the 8,490-cent base rounds to 8,500 cents, applied items 0. With four plugins the shipment comes to 11,800 cents, and both arrangements match for all four shipments.

The Cost of a Fifth Plugin

Extension cost is the number of lines edited in existing files when a new capability is added. The fifth plugin adds a fuel difference as a percentage of the base. The block adds it to both arrangements in a copy of the tree; since the patch also fixes path comments, the count skips one line.

cp -r microkernel fifth
cat > fifth/plugins/fuel-difference.mjs <<'EOF'
// fifth/plugins/fuel-difference.mjs — fifth plugin: fuel difference as a percentage of the base
const FUEL_RATE = 0.085;

export const fuelDifference = { name: "fuel-difference", fields: ["zone"],
  item: (s, base) => Math.round(base * FUEL_RATE) };
EOF
python3 - <<'EOF'
from pathlib import Path

IMPORT = 'import { zoneDifference } from "./plugins/zone-difference.mjs";'
FUEL = 'import { fuelDifference } from "./plugins/fuel-difference.mjs";'
PATCH = {
    "embedded.mjs": [(IMPORT, f"{IMPORT}\n{FUEL}"),
                   ("      + zoneDifference.item(shipment, base);",
                    "      + zoneDifference.item(shipment, base)\n      + fuelDifference.item(shipment, base);"),
                   ("applied: 4", "applied: 5"), ("four components", "five components")],
    "main.mjs": [(IMPORT, f"{IMPORT}\n{FUEL}"), ("zoneDifference];", "zoneDifference, fuelDifference];")],
}
for path in sorted(Path("fifth").rglob("*.mjs")):
    text = path.read_text().replace("// microkernel/", "// fifth/")
    for old, new in PATCH.get(path.name, []):
        text = text.replace(old, new)
    path.write_text(text)
EOF
for d in embedded.mjs pluggable.mjs main.mjs; do
  f=$(diff <(tail -n +2 microkernel/$d) <(tail -n +2 fifth/$d))
  echo "$d  removed line = $(echo "$f" | grep -c '^<')  added line = $(echo "$f" | grep -c '^>')"
done
echo "added file = $(( $(find fifth -name '*.mjs' | wc -l) - $(find microkernel -name '*.mjs' | wc -l) ))"
cat > fifth-check.mjs <<'EOF'
// fifth-check.mjs — do both arrangements produce the same amount in the patched tree?
import { TIERS } from "./fifth/tariff.mjs";
import { embeddedCore } from "./fifth/embedded.mjs";
import { build } from "./fifth/main.mjs";
import { SHIPMENTS } from "./sample-shipments.mjs";
const both = [embeddedCore(TIERS), build(TIERS).c].map((c) => c.calculate(SHIPMENTS[0]).amount);
console.log(`with fifth plugin: embedded = ${both[0]}, pluggable = ${both[1]}`);
EOF
node fifth-check.mjs
embedded.mjs  removed line = 2  added line = 4
pluggable.mjs  removed line = 0  added line = 0
main.mjs  removed line = 1  added line = 2
added file = 1
with fifth plugin: embedded = 12500, pluggable = 12500

The same capability added one file in both arrangements and produced the same amount, but the edited lines fell in different places. In the embedded core, three places changed — the import line, the summation term, the applied count — 2 lines out, 4 in. In the pluggable core file, 0 lines changed; only the composition root changed. The measure says the core is closed: a new item type needs no retesting or re-release.

The Plugin That Breaks the Contract

The second side of pluggability: what gets plugged in can be broken. The block replaces the insurance plugin with one whose signature is right but whose behavior is broken.

cp -r microkernel broken
cat > broken/plugins/insurance-fee.mjs <<'EOF'
// broken/plugins/insurance-fee.mjs — the plugin that breaks the contract at run time: throws an error instead of a number
export const insuranceFee = { name: "insurance-fee", fields: ["declaredValue"],
  item: () => { throw new TypeError("could not read declared value"); } };
EOF
// resilience.mjs — counts how many requests a contract-breaking plugin stops in each arrangement
import { TIERS } from "./microkernel/tariff.mjs";
import { embeddedCore as soundEmbedded } from "./microkernel/embedded.mjs";
import { PLUGINS, build as soundBuild } from "./microkernel/main.mjs";
import { embeddedCore as brokenEmbedded } from "./broken/embedded.mjs";
import { build as brokenBuild } from "./broken/main.mjs";
import { SHIPMENTS } from "./sample-shipments.mjs";

const ARRANGEMENTS = [
  ["embedded  / sound ", () => soundEmbedded(TIERS)],
  ["embedded  / broken", () => brokenEmbedded(TIERS)],
  ["pluggable / sound ", () => soundBuild(TIERS).c],
  ["pluggable / broken", () => brokenBuild(TIERS).c],
];

for (const [name, build] of ARRANGEMENTS) {
  const c = build();
  let answered = 0, first = "-", dropped = "-", error = "";
  for (const s of SHIPMENTS) {
    try {
      const r = c.calculate(s);
      if (++answered === 1) { first = String(r.amount); dropped = r.dropped.join(",") || "none"; }
    } catch (e) { error = error || `${e.name}: ${e.message}`; }
  }
  console.log(`${name}  answered = ${answered}/${SHIPMENTS.length}  first amount = ${first.padStart(6)}`
    + `  ${error === "" ? `dropped = ${dropped}` : error}`);
}

const itemless = { name: "fuel-difference", fields: ["zone"] };
const { c, rejected } = soundBuild(TIERS, [...PLUGINS, itemless]);
console.log(`\nplugin that breaks the contract at attach time: rejected = ${rejected}, attached = ${c.attached().length}`);
console.log(`  attached names = ${c.attached().join(", ")}`);
console.log(`  same shipment's amount = ${c.calculate(SHIPMENTS[0]).amount}`);
node resilience.mjs
embedded  / sound   answered = 4/4  first amount =  11800  dropped = none
embedded  / broken  answered = 0/4  first amount =      -  TypeError: could not read declared value
pluggable / sound   answered = 4/4  first amount =  11800  dropped = none
pluggable / broken  answered = 4/4  first amount =  11200  dropped = insurance-fee

plugin that breaks the contract at attach time: rejected = 1, attached = 4
  attached names = contract-discount, volume-difference, insurance-fee, zone-difference
  same shipment's amount = 11800

The same broken file gives two different results. In the embedded core, all four requests went unanswered: since the component call sits inside the calculation’s body, the error stopped the whole calculation. In the pluggable core, all four were answered; the broken plugin was dropped and reported, and the amount fell to 11,200, meaning the insurance item is missing — not a correct answer but an incomplete one, and reporting it makes the gap visible.

The last three lines show the contract’s other side. The plugin with no item field was rejected at attach time: rejected 1, attached 4, amount at 11,800. Two lines of defense exist — one checks the signature, the other the returned value — and neither knows the plugin’s name.

The Trade-off’s Numbers

The gain was measured in two quality attributes. In modifiability: plugin names the core knows fell from 4 to 0, closure fell from 5 files to 1, and the fifth plugin edited 0 core lines. In availability: answered requests rose from 0/4 to 4/4.

The cost has three items. The first is relocation: the four names gathered into the composition root, whose closure became 6 files; only that file now reads the order. The second is the incomplete answer: the core staying up does not mean a complete result. The third is the contract — three names repeat in every plugin, and changing it changes five files together.

Summary

  • The microkernel pattern declares three things: a core that runs without plugins, plugins of unknown number, and a contract declaring the names a plugin must satisfy; the contract is a port.
  • The embedded core carries 4 plugin names, closure 5 files; the pluggable core carries 0 names, closure 1 file. The names moved to the composition root, closure 6 files.
  • The core without plugins produced 8,500 cents for a 3 kg shipment, 11,800 with four plugins, and both arrangements matched for all four shipments.
  • The fifth plugin added 1 file in both arrangements; the embedded core had 2 lines out and 4 in, the pluggable core file 0.
  • With a plugin that breaks the contract at run time, answered requests were 0/4 embedded and 4/4 pluggable; a plugin failing the contract at attach time was rejected.

Next Step

All of this lesson’s measures were about what the core knows. The plugins never knew each other, but the core knew all of them: the order, who to call, and how to sum the result all sat in one place. The next lesson removes that place too. Units recognize neither each other nor a calling center; they write to a shared field, read from it, and the field itself provides the meeting point. Its name is the blackboard, and three things are measured: the names units know about each other, the redundant work spent on one result — how many units read the same data, how many turns spun empty — and whether the same input gives the same output every run.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close