InterviewKata
Architecture styles · 05 of 16 · Inside one application

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 games console is a small, stable machine; the games are what change. Each new game plugs into the same slot and uses the same controller, and the console maker publishes exactly what a game may and may not do. Nobody rebuilds the console for a new game.

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

RuleWhy it existsBroken when…
The core knows no specific pluginNew cities and features never touch the core.The core has if city == "Paris".
Plugins depend on the contract, not on each otherEach plugin can be added, removed or upgraded alone.The student discount reads the e-bike plugin's internal fields.
The contract is versionedOld plugins keep working; incompatible ones are refused at install.A contract change silently breaks half the installed plugins.
The core stays smallA 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

A fat coreFeatures accumulate in the core until plugins are a thin decoration around a monolith.
A leaky contractPassing the core's internal objects to plugins, so any core refactor breaks them.
Plugin order bugsVAT applied before or after a discount gives different totals; the contract must say what order means.
Untrusted plugins with full accessThird-party plugins running with the core's permissions; isolate or review them.

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.

Plugins
Rider
Minutes
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 customerHexagonal ports and adapters
Plugins need separate scaling or teamsPlugins as services: service-based
Variation is just a few numbersConfiguration data, not plugins
Behaviour should react to events, not be called in orderEvent-driven

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "A stable console, new games"Keep the core small and stable; let plugins carry the variation through a versioned contract.
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 consolePedal
The consolePedalCore: base pricing and the ride lifecycle
The cartridge slotThe PricingPlugin contract, version 1
GamesVAT, e-bike surcharge, student discount
An incompatible gameLoyaltyPoints, written for contract 2, refused

Watch it on an example

  1. London adds VAT without touching the core.
  2. Paris's e-bike surcharge is its own plugin.
  3. 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

10. Check yourself

Paris adds an e-bike surcharge. In a microkernel design, what changes?

Variation lives in plugins; the core stays closed.

Why version the plugin contract?

Old and new plugins can coexist safely.

VAT then discount gives a different total than discount then VAT. Whose job is it to define the order?

Interaction between plugins is part of the contract.

Most changes now modify the core. What does that suggest?

A microkernel's core should change rarely.

11. Go deeper

ResourceWhy
Architecture 12: MicrokernelHow an architect evaluates the style.
LLD 03b: Open/closedExtension points at class level: the same idea, smaller.
VS Code extension APIA large real plugin contract, with activation and versioning rules.