---
title: 'Microkernel and Plugin Architecture'
source: 'https://academia.sh/en/courses/architectural-styles/microkernel-and-plugin-architecture'
course: 'Architectural Styles'
language: en
updated: '2026-08-23T07:01:11+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```sh
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.

```js
// 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.

```js
// 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.

```js
// 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.

```js
// 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}`);
```

```sh
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.

```sh
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.

```sh
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
```

```js
// 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}`);
```

```sh
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.
