Microkernel (plugins)
When the same product must behave differently for different customers, regions or editions, a microkernel keeps a small stable core and moves the variation into plugins. This page prices Pedal rides in three cities through per-city plugins, refuses an incompatible one at install, and lets you see why plugin order is part of the contract.
1. In one picture
A microkernel (or plugin) architecture has two kinds of part. A small core holds what every use of the product needs and changes rarely. Plugins add or change behaviour through a contract the core owns: an interface, the data it passes, and a version number. A registry records which plugins are installed where.
It's the shape of IDEs and browsers (extensions), build tools, payment and tax engines, and many SaaS products that serve customers or regions with different rules. For Pedal, the variation is by city: each city brings its own taxes, surcharges and discounts, and Pedal wants to open cities without changing the core.
2. Pedal built this way
Three cities, one core. Each quote starts with the core's base price and passes through that city's plugins.
3. The rules
| Rule | Why it exists | Broken when… |
|---|---|---|
| The core knows no specific plugin | New cities and features never touch the core. | The core has if city == "Paris". |
| Plugins depend on the contract, not on each other | Each plugin can be added, removed or upgraded alone. | The student discount reads the e-bike plugin's internal fields. |
| The contract is versioned | Old plugins keep working; incompatible ones are refused at install. | A contract change silently breaks half the installed plugins. |
| The core stays small | A big core loses the point: it's just a monolith with hooks. | Every new idea lands in the core "because all cities might want it". |
4. How it goes wrong
5. The code
A PedalCore that prices the base fare and runs each city's installed plugins in order. London adds VAT, Paris an e-bike surcharge, Lisbon a student discount; a plugin written for a newer contract version is refused when installed.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
CONTRACT = 1 # the plugin contract version this core supports
@dataclass
class Trip:
rider: str
plan: str
minutes: int
ebike: bool = False
student: bool = False
@dataclass
class Quote:
lines: list[tuple[str, int]] = field(default_factory=list)
def total(self) -> int:
return sum(p for _, p in self.lines)
# ---- The contract every plugin implements. Owned by the core, changed rarely and versioned. ----
class PricingPlugin(ABC):
name: str = ""
contract: int = CONTRACT
@abstractmethod
def adjust(self, trip: Trip, quote: Quote) -> None: ...
# ---- The core: what every city needs. It never mentions a particular city. ----
class PedalCore:
def __init__(self) -> None:
self.plugins: dict[str, list[PricingPlugin]] = {}
def install(self, city: str, plugin: PricingPlugin) -> None:
if plugin.contract != CONTRACT:
print(f"rejected {plugin.name}: needs contract {plugin.contract}, core has {CONTRACT}")
return
self.plugins.setdefault(city, []).append(plugin)
def quote(self, city: str, trip: Trip) -> Quote:
base = max(0, trip.minutes - 30) * 10 if trip.plan == "member" else 100 + trip.minutes * 10
q = Quote([("base", base)])
for plugin in self.plugins.get(city, []):
plugin.adjust(trip, q)
return q
# ---- Plugins: built and released separately, one per variation. ----
class Vat(PricingPlugin):
name = "vat"
def __init__(self, percent: int) -> None:
self.percent = percent
def adjust(self, trip: Trip, quote: Quote) -> None:
quote.lines.append((f"VAT {self.percent}%", quote.total() * self.percent // 100))
class EbikeSurcharge(PricingPlugin):
name = "ebike_surcharge"
def adjust(self, trip: Trip, quote: Quote) -> None:
if trip.ebike:
quote.lines.append(("e-bike", trip.minutes * 15))
class StudentDiscount(PricingPlugin):
name = "student_discount"
def adjust(self, trip: Trip, quote: Quote) -> None:
if trip.student:
quote.lines.append(("student 25%", -quote.total() // 4))
class LoyaltyPoints(PricingPlugin):
name = "loyalty_points"
contract = 2 # written for a newer core
def adjust(self, trip: Trip, quote: Quote) -> None:
pass
def show(city: str, trip: Trip, q: Quote) -> str:
m = lambda p: f"{'-' if p < 0 else ''}{abs(p) // 100}.{abs(p) % 100:02d}"
return f"{city}, {trip.rider}, {trip.minutes} min: " + " | ".join(f"{n} {m(p)}" for n, p in q.lines) + f" | total {m(q.total())}"
if __name__ == "__main__":
core = PedalCore()
core.install("London", Vat(20))
core.install("Paris", EbikeSurcharge())
core.install("Lisbon", StudentDiscount())
core.install("Lisbon", LoyaltyPoints())
for city, trip in [("London", Trip("Mia", "member", 42)), ("Paris", Trip("Leo", "payg", 15, ebike=True)), ("Lisbon", Trip("Ana", "payg", 20, student=True))]:
print(show(city, trip, core.quote(city, trip)))
const CONTRACT = 1; // the plugin contract version this core supports
interface Trip { rider: string; plan: string; minutes: number; ebike?: boolean; student?: boolean; }
class Quote {
lines: [string, number][] = [];
total(): number { return this.lines.reduce((s, [, p]) => s + p, 0); }
}
// ---- The contract every plugin implements. Owned by the core, changed rarely and versioned. ----
interface PricingPlugin { name: string; contract: number; adjust(trip: Trip, quote: Quote): void; }
// ---- The core: what every city needs. It never mentions a particular city. ----
class PedalCore {
private plugins = new Map<string, PricingPlugin[]>();
install(city: string, plugin: PricingPlugin): void {
if (plugin.contract !== CONTRACT) { console.log(`rejected ${plugin.name}: needs contract ${plugin.contract}, core has ${CONTRACT}`); return; }
this.plugins.set(city, [...(this.plugins.get(city) ?? []), plugin]);
}
quote(city: string, trip: Trip): Quote {
const base = trip.plan === "member" ? Math.max(0, trip.minutes - 30) * 10 : 100 + trip.minutes * 10;
const q = new Quote();
q.lines.push(["base", base]);
for (const p of this.plugins.get(city) ?? []) p.adjust(trip, q);
return q;
}
}
// ---- Plugins: built and released separately, one per variation. ----
class Vat implements PricingPlugin {
name = "vat"; contract = CONTRACT;
constructor(private percent: number) {}
adjust(_t: Trip, q: Quote): void { q.lines.push([`VAT ${this.percent}%`, Math.floor((q.total() * this.percent) / 100)]); }
}
class EbikeSurcharge implements PricingPlugin {
name = "ebike_surcharge"; contract = CONTRACT;
adjust(t: Trip, q: Quote): void { if (t.ebike) q.lines.push(["e-bike", t.minutes * 15]); }
}
class StudentDiscount implements PricingPlugin {
name = "student_discount"; contract = CONTRACT;
adjust(t: Trip, q: Quote): void { if (t.student) q.lines.push(["student 25%", Math.floor(-q.total() / 4)]); }
}
class LoyaltyPoints implements PricingPlugin {
name = "loyalty_points"; contract = 2; // written for a newer core
adjust(): void {}
}
const money = (p: number): string => `${p < 0 ? "-" : ""}${Math.floor(Math.abs(p) / 100)}.${String(Math.abs(p) % 100).padStart(2, "0")}`;
const show = (city: string, t: Trip, q: Quote): string => `${city}, ${t.rider}, ${t.minutes} min: ` + q.lines.map(([n, p]) => `${n} ${money(p)}`).join(" | ") + ` | total ${money(q.total())}`;
const core = new PedalCore();
core.install("London", new Vat(20));
core.install("Paris", new EbikeSurcharge());
core.install("Lisbon", new StudentDiscount());
core.install("Lisbon", new LoyaltyPoints());
const trips: [string, Trip][] = [["London", { rider: "Mia", plan: "member", minutes: 42 }], ["Paris", { rider: "Leo", plan: "payg", minutes: 15, ebike: true }], ["Lisbon", { rider: "Ana", plan: "payg", minutes: 20, student: true }]];
for (const [city, t] of trips) console.log(show(city, t, core.quote(city, t)));import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
record Trip(String rider, String plan, int minutes, boolean ebike, boolean student) {}
class Quote {
final List<Map.Entry<String, Integer>> lines = new ArrayList<>();
int total() { return lines.stream().mapToInt(Map.Entry::getValue).sum(); }
void add(String name, int pence) { lines.add(Map.entry(name, pence)); }
}
// ---- The contract every plugin implements. Owned by the core, changed rarely and versioned. ----
interface PricingPlugin {
String name();
default int contract() { return PedalCore.CONTRACT; }
void adjust(Trip trip, Quote quote);
}
// ---- The core: what every city needs. It never mentions a particular city. ----
class PedalCore {
static final int CONTRACT = 1; // the plugin contract version this core supports
private final Map<String, List<PricingPlugin>> plugins = new HashMap<>();
void install(String city, PricingPlugin plugin) {
if (plugin.contract() != CONTRACT) { System.out.println("rejected " + plugin.name() + ": needs contract " + plugin.contract() + ", core has " + CONTRACT); return; }
plugins.computeIfAbsent(city, k -> new ArrayList<>()).add(plugin);
}
Quote quote(String city, Trip trip) {
int base = trip.plan().equals("member") ? Math.max(0, trip.minutes() - 30) * 10 : 100 + trip.minutes() * 10;
Quote q = new Quote();
q.add("base", base);
for (PricingPlugin p : plugins.getOrDefault(city, List.of())) p.adjust(trip, q);
return q;
}
}
// ---- Plugins: built and released separately, one per variation. ----
class Vat implements PricingPlugin {
private final int percent;
Vat(int percent) { this.percent = percent; }
public String name() { return "vat"; }
public void adjust(Trip t, Quote q) { q.add("VAT " + percent + "%", q.total() * percent / 100); }
}
class EbikeSurcharge implements PricingPlugin {
public String name() { return "ebike_surcharge"; }
public void adjust(Trip t, Quote q) { if (t.ebike()) q.add("e-bike", t.minutes() * 15); }
}
class StudentDiscount implements PricingPlugin {
public String name() { return "student_discount"; }
public void adjust(Trip t, Quote q) { if (t.student()) q.add("student 25%", Math.floorDiv(-q.total(), 4)); }
}
class LoyaltyPoints implements PricingPlugin {
public String name() { return "loyalty_points"; }
public int contract() { return 2; } // written for a newer core
public void adjust(Trip t, Quote q) {}
}
public class Main {
static String money(int p) { return (p < 0 ? "-" : "") + (Math.abs(p) / 100) + "." + String.format("%02d", Math.abs(p) % 100); }
static String show(String city, Trip t, Quote q) {
return city + ", " + t.rider() + ", " + t.minutes() + " min: " + q.lines.stream().map(e -> e.getKey() + " " + money(e.getValue())).collect(Collectors.joining(" | ")) + " | total " + money(q.total());
}
public static void main(String[] args) {
PedalCore core = new PedalCore();
core.install("London", new Vat(20));
core.install("Paris", new EbikeSurcharge());
core.install("Lisbon", new StudentDiscount());
core.install("Lisbon", new LoyaltyPoints());
System.out.println(show("London", new Trip("Mia", "member", 42, false, false), core.quote("London", new Trip("Mia", "member", 42, false, false))));
Trip leo = new Trip("Leo", "payg", 15, true, false), ana = new Trip("Ana", "payg", 20, false, true);
System.out.println(show("Paris", leo, core.quote("Paris", leo)));
System.out.println(show("Lisbon", ana, core.quote("Lisbon", ana)));
}
}Output (the same in all three languages)
rejected loyalty_points: needs contract 2, core has 1 London, Mia, 42 min: base 1.20 | VAT 20% 0.24 | total 1.44 Paris, Leo, 15 min: base 2.50 | e-bike 2.25 | total 4.75 Lisbon, Ana, 20 min: base 3.00 | student 25% -0.75 | total 2.25
6. Try it
Configure a city
Choose which plugins a city installs and who's riding; the core's base price passes through the plugins in order.
7. What it costs, and when to pick another style
What you get
- Variation without core changes: new cities, customers or features ship as plugins.
- A stable, tested core: it changes rarely, so it can be trusted.
- Independent release: a plugin can be updated without redeploying everything.
- Third-party extension: partners can add behaviour under a published contract.
What you pay
- Designing the contract is hard: too narrow and plugins can't do their job; too wide and the core can't change.
- Versioning forever: old plugins expect old contracts.
- Ordering and interaction: plugins that affect each other need rules about order.
- Testing combinations: n plugins, many combinations per city.
| If… | Consider instead |
|---|---|
| Variation is by technology, not by customer | Hexagonal ports and adapters |
| Plugins need separate scaling or teams | Plugins as services: service-based |
| Variation is just a few numbers | Configuration data, not plugins |
| Behaviour should react to events, not be called in order | Event-driven |
8. Signs you've outgrown it
- Most new work changes the core, not plugins.
- Plugins need data the contract doesn't provide, so the contract changes every release.
- Plugins need to talk to each other directly.
- Some plugins need their own servers, scaling or on-call team.
9. Remember it and say it
How this hook works: what “A stable console, new games” means
What the slogan means, phrase by phrase
- “A stable console”
- The core: what every city needs.
- “new games”
- Plugins: what differs, added without changing the core.
Why this picture fits: A games console: the slot and controller stay the same; new games plug in under published rules.
The story → the concept
| The console | Pedal |
|---|---|
| The console | PedalCore: base pricing and the ride lifecycle |
| The cartridge slot | The PricingPlugin contract, version 1 |
| Games | VAT, e-bike surcharge, student discount |
| An incompatible game | LoyaltyPoints, written for contract 2, refused |
Watch it on an example
- London adds VAT without touching the core.
- Paris's e-bike surcharge is its own plugin.
- A plugin for a newer contract is refused at install, not at the first ride.
The trap it saves you from
Letting the core grow city-specific branches "just this once".
It still works when…
Inside a monolith or a service: plugins can live in one deployable, loaded from configuration.
In an interview or design review
- Say what's core and what's plugin: "The ride lifecycle and base price are core; taxes, surcharges and discounts are per-city plugins."
- Describe the contract: the interface, the data passed, the order plugins run in, and its version.
- Address trust: internal plugins can share the process; third-party ones need isolation and limits.
10. Check yourself
Paris adds an e-bike surcharge. In a microkernel design, what changes?
Why version the plugin contract?
VAT then discount gives a different total than discount then VAT. Whose job is it to define the order?
Most changes now modify the core. What does that suggest?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture 12: Microkernel | How an architect evaluates the style. |
| LLD 03b: Open/closed | Extension points at class level: the same idea, smaller. |
| VS Code extension API | A large real plugin contract, with activation and versioning rules. |