Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 5.5 of 5.8 · Intermediate · 24 min read

Microkernel and plug-in architectures

Video lesson

4 min · narrated, with captions
Listen only (audio)
Read the transcript

Many successful products are a small core that others extend. In this lesson you'll learn how the microkernel, or plug-in, style works, where it fits, and how to keep its contracts healthy over years.

Think of a games console. The console itself does a few general things: it starts up, shows a menu, reads the controllers and draws pictures. Each game is a cartridge that slots in through a standard connector. You can add new games for years without opening the console, and a badly made game can't rewire it. A microkernel architecture builds software the same way.

Here's the shape. The core system holds the minimal, general logic: the main workflow and the points where plug-ins attach. In a food-delivery checkout, that's pricing a basket, taking payment and confirming the order. Inside the core, a registry records which plug-ins exist, their versions and the contract each implements. It lets the core ask for the tax rules for Germany without knowing which class provides them. Plug-ins connect only through contracts the core publishes. On one side, tax rules for the UK, Germany and the United States. On the other, payment methods: cards, wallets and cash on delivery. Two rules hold it together. Plug-ins never call each other, and the core never names a particular plug-in.

The style suits products with a stable workflow but many variants of particular steps. Tax and payroll rules by country, insurance rules by jurisdiction, payment methods, file importers. It also suits platforms that want an ecosystem of third-party extensions. Development tools such as Eclipse and Visual Studio Code, and web browsers, are built this way. Visual Studio Code even runs extensions in a separate process, so a misbehaving one can't freeze the editor.

Let's design one. The food-delivery platform is launching in Germany and the United States after the UK, and its checkout is already full of branches for each country. First, name the variation point. Only the tax on a priced basket varies by country. Pricing, payment and receipts stay in the core, which keeps the contract small. Next, decide what crosses the contract. Basket lines go in, each with a neutral category such as hot food or delivery fee. Total tax and a labelled breakdown come out. No orders, customers or database sessions are passed, so plug-ins can't depend on core internals. The contract becomes a small, separately versioned package. Each plug-in declares the version it needs, and the registry checks at start-up, so a mismatch shows up in deployment, not as wrong tax on a receipt. If no plug-in exists for a country, checkout refuses the order rather than charging zero tax. Finally, one shared contract test suite runs against every plug-in. Adding a fourth country is now a new package and its tests, not a change to checkout.

Here it is at run time. Checkout asks the registry for the plug-in registered for Germany, and gets it back. Then it calls that plug-in through the contract. Notice the United States plug-in. It was built for an older major version of the contract, so the registry skipped it at start-up, with a log message, instead of crashing checkout.

Three things keep a plug-in architecture healthy. First, isolation. In-process plug-ins are fast, but one with a memory leak affects everything. Your own team's tax rules can run in-process; arbitrary code from a public marketplace should not. Second, versioning. Prefer additive changes, such as new optional fields, so old plug-ins keep working. Third, Hyrum's law: with enough users, every observable behaviour of an interface will be depended on by someone. So keep the contract small, and treat anything plug-ins can see as part of it. And don't build extension points for imagined needs. Wait until you've built the same kind of variation two or three times, then extract the contract from the real cases.

The full section explains how this differs from an operating-system microkernel, lists the common anti-patterns, and shows the contract and registry in code. Next, in Section five point six: hexagonal, onion and clean architecture. See you there.

What you need to know

Definition and origin

A microkernel architecture, also called a plug-in architecture, splits an application into two kinds of part: a core system that contains the minimal, general functionality needed to run, and plug-ins that add or change behaviour without the core being modified. Plug-ins connect to the core only through published contracts, and the core finds them through a registry. Mark Richards and Neal Ford catalogue it as one of the classic architecture styles in Fundamentals of Software Architecture, and it fits the description of an architectural style given in Section 5.1: a named set of constraints on the elements (core, plug-ins) and connectors (contracts) of a system.

The idea grew up in product software: tools that are installed by many customers, each of whom wants something slightly different. Rather than maintain a separate version for each customer, the vendor ships one core and lets customers, partners or the vendor's own teams add plug-ins. Integrated development environments, web browsers, graphics editors and accounting packages all work this way. The name is borrowed from operating systems, where a microkernel is a very small kernel with everything else running outside it; the two ideas are related but should not be confused, as explained below.

A microkernel application is normally a single deployment unit, so it belongs with the monolithic styles of this chapter: one deployment unit, one process, one set of operational concerns. Plug-ins may be installed separately, but at run time they live inside, or right next to, the core.

The shape of a microkernel system

A microkernel architecture: a core system with a plug-in registry, and plug-ins that connect to the core only through published contractsOne deployable application (plug-ins loaded at start-up or run time)Core systemminimal, general logicPlug-in registryid, version, contractcontractcontractPlug-in AUK tax rulesPlug-in BGerman tax rulesPlug-in CUS sales taxPlug-in DCard paymentsPlug-in EWallet paymentsPlug-in FCash on deliveryPlug-ins never call each other; the core never names a plug-inArrows: each plug-in implements a contract the core publishes
The core system owns the general workflow and a registry; each plug-in implements a contract the core publishes, and plug-ins never depend on each other.

How it works: core, plug-ins, registry and contracts

The core system contains what every installation needs: the main workflow, shared services such as logging or configuration, and the extension points where plug-ins attach. Richards and Ford describe it as the happy path through the application with little or no custom processing; in a food-delivery checkout, that is pricing a basket, taking payment and confirming the order. A good core is deliberately boring. Every special case that varies by customer, country or product line is a candidate to move out of it.

Plug-ins are independent, self-contained modules that contain specialised processing. They should depend only on the contract, not on each other and not on the core's internal classes. When one plug-in needs something another provides, the need should be met by the core (as a service it exposes through the contract) or the two plug-ins should be merged; plug-in-to-plug-in dependencies quietly turn a clean star into a tangle.

The registry records which plug-ins are available, what each is called, which version it is, which contract it implements and how to reach it. It can be as simple as a dictionary filled at start-up, a directory the core scans for manifests, entries in a package's metadata (as with Python entry points), a database table, or a remote service that lists plug-ins deployed elsewhere. The registry is what lets the core say "give me the tax rules for Germany" without knowing that a class called GermanTax exists.

Contracts define what a plug-in must provide and what it may rely on: an interface with method signatures, the data structures that cross it, the error behaviour and a version. In Eclipse a contract is an extension point declared in a plug-in manifest with a schema; in Visual Studio Code it is the extension API plus the contribution points listed in an extension's package.json; in your own code it is usually an interface (or a Python Protocol) in a small package of its own. Where the plug-in comes from another organisation and cannot follow your contract, an adapter translates between the two, exactly as in hexagonal architecture.

Plug-ins can be connected in three ways. Point-to-point, in-process calls are the norm: the core calls a method on the plug-in object. Separate-process plug-ins run in their own process and talk to the core through inter-process messages, which protects the core from crashes. Remote plug-ins sit behind an HTTP or messaging interface; this allows plug-ins in other languages or owned by other teams, but it adds network latency and failure modes, and the system starts to look like the distributed styles of Chapter 6.

Real examples

Eclipse. The Eclipse IDE is the textbook case. Its core is little more than a workbench and a plug-in runtime (built on Equinox, an implementation of the OSGi framework specification). Almost everything a developer sees, from the Java tooling to version-control integration, is a plug-in. Each plug-in has a manifest that declares what it needs, which extension points it offers to others, and which extensions it plugs into. Plug-ins are activated lazily, only when something actually needs them, so that hundreds of installed plug-ins do not slow start-up.

Visual Studio Code. Extensions declare activation events (open a Markdown file, run a command) so they load only when needed, and contribute commands, languages, themes and views through their manifest. Extensions run in a separate extension host process, so a misbehaving extension cannot freeze the editor's user interface. This is a good example of choosing process isolation for a plug-in ecosystem that includes thousands of third-party authors.

Web browsers. Browser extensions use a standard set of extension APIs and must declare the permissions they need (which sites they may read, whether they may see tabs or downloads) in a manifest. The browser enforces those permissions, because extension authors cannot be trusted by default.

Business rules engines. An insurer's claims system may keep the claims workflow in the core and put each jurisdiction's rules (what is covered, which documents are needed, statutory deadlines) in plug-ins. Tax engines, payroll systems and e-commerce platforms with payment-method plug-ins follow the same pattern: a stable process, with rules or providers that vary by country, product or customer.

Developer tools. The pytest test framework is built around the pluggy plug-in library; many of its own features are implemented as plug-ins using the same hooks that third parties use. Build tools, linters and static-site generators commonly do the same.

Application microkernel versus operating-system microkernel

AspectOperating-system microkernelApplication microkernel (plug-in) architecture
What is minimalThe kernel: address spaces, threads, scheduling and inter-process communicationThe core system: the general workflow of one application
What is outside the coreDevice drivers, file systems and network stacks, run as separate user-space serversVariable business behaviour or features, such as tax rules, payment methods or editor languages
Main goalsReliability, security and a small trusted computing baseExtensibility, customisation and a third-party ecosystem
How parts talkMessage passing between isolated processesUsually in-process calls through an interface; sometimes a separate process or remote call
ExamplesMach, the L4 family (including the formally verified seL4), QNX, MINIX 3Eclipse, Visual Studio Code, web browsers, pytest, insurance and tax rules engines

Plug-in isolation, versioning and compatibility

Three questions decide whether a plug-in architecture stays healthy over years: how isolated plug-ins are, how the contract evolves, and how incompatibility is detected.

Isolation. In-process plug-ins share the core's memory, threads and permissions. That is fast and simple, but one plug-in with an infinite loop, a memory leak or a security hole affects everything. The options, in increasing order of isolation and cost, are: in-process with defensive calls (timeouts, catching exceptions per plug-in, resource limits); a separate process or worker per plug-in or group of plug-ins; a sandbox such as a WebAssembly runtime or a restricted interpreter, which limits what plug-in code can touch; and a remote service. Choose by asking who writes the plug-ins. Your own team's tax rules can run in-process; arbitrary code from a public marketplace should not.

Versioning. The contract is an API, and the rules of Section 0.6 about breaking changes apply. Version the contract separately from the core, usually with semantic versioning: a new major version for breaking changes, a minor version for backward-compatible additions. Let each plug-in declare which contract version it was built for, and let the core declare which versions it supports. Additive change (new optional fields, new optional hooks) keeps old plug-ins working; when a breaking change is unavoidable, support the old and new major versions side by side for a published deprecation period.

Compatibility checks. Check compatibility when plug-ins are loaded, not when a customer first triggers them. A registry that refuses an incompatible plug-in at start-up, with a clear log message, turns a run-time bug into a deployment failure. Hyrum Wright's observation, known as Hyrum's law, is worth remembering here: with enough users, every observable behaviour of an interface will be depended on by someone, whatever the documentation says. Keep the contract small, keep core internals out of reach (Eclipse marks non-API packages as internal for this reason), and treat anything visible to plug-ins as part of the contract.

Characteristics of the microkernel style

CharacteristicRatingReason
Extensibility and modifiability (in the planned directions)HighNew behaviour arrives as a new plug-in; the core and the other plug-ins do not change.
SimplicityMediumThe idea is simple, but contracts, a registry, loading and version checks are real machinery.
TestabilityMedium to highEach plug-in can be tested alone against the contract; the core can be tested with fake plug-ins. Combinations still need testing.
DeployabilityMediumPlug-ins can often be added without redeploying the core; a contract change forces coordinated releases.
PerformanceMedium to highIn-process plug-ins are ordinary function calls; isolation by process or sandbox adds overhead.
Scalability and elasticityLowIt is usually one deployment unit, so it scales as a whole, like any monolith.
Fault toleranceLow to mediumAn in-process plug-in can crash or slow the host; process isolation improves this at a cost.
Overall costHigh (cheap)One codebase and one runtime to operate; the main cost is designing and evolving contracts.

When to use it, and when not

Good fits. Product software that many customers install and customise. Systems with a stable workflow but many variants of one step: tax and payroll by country, insurance rules by jurisdiction, payment methods, file-format importers, shipping carriers. Platforms that want a third-party ecosystem. Systems where a regulator or customer changes rules often and you want those changes to be small, separately tested releases. The style also works inside a larger system: one service in a distributed architecture can itself be a microkernel.

Poor fits. Systems where you do not yet know what varies. Extension points designed in advance for imagined needs are speculative generality: they add machinery, slow the team down and are usually in the wrong place when the real need arrives (see the discussion of reversibility in Section 1.6). Systems that need very high scalability or fault isolation, since the whole thing scales and fails as one unit. Systems where plug-ins would need to coordinate closely with each other, which defeats the style's main benefit.

A practical rule: wait until you have built the same kind of variation two or three times, then extract the contract from the real cases. A contract derived from three working tax implementations will be far better than one guessed before the first.

Worked example

Worked example: designing a plug-in contract for country-specific tax rules

The food-delivery platform used throughout this course is launching in Germany and the United States after the United Kingdom. Each country taxes food, alcohol and delivery fees differently, and US sales tax also varies by state and city. The checkout code already contains if country == "GB" branches. The architect proposes a microkernel: checkout is the core, and each country's rules are a plug-in. These are the decisions, in the order they were made.

1. Name the variation pointOnly the calculation of tax on a priced basket varies by country. Pricing, discounts, payment and receipts stay in the core. A narrow variation point keeps the contract small.
2. Decide what crosses the contractInputs: basket lines with a neutral category (hot-food, alcohol, delivery-fee), net amounts as Decimal and an optional region. Output: total tax and a labelled breakdown for the receipt. No core objects (orders, customers, database sessions) are passed, so plug-ins cannot depend on core internals.
3. Make the contract a separate packagetax_contract contains only data classes and the TaxRules protocol, versioned with semantic versioning. Core and plug-ins both depend on it; neither depends on the other.
4. Define compatibilityA plug-in declares the contract version it needs, such as (2, 1). The core accepts it if the major version matches and the core's minor version is at least as high. Adding an optional field is a minor change; renaming or removing one is a major change.
5. Choose discoveryPlug-ins are ordinary Python packages that register under the entry-point group fooddelivery.tax_rules. Installing a package is enough; no core code lists the countries.
6. Decide failure behaviourIf no plug-in is registered for a country, checkout refuses the order with a clear error rather than charging zero tax. A plug-in that raises an exception fails that checkout only, and the error is reported with the plug-in's name and version.
7. Decide isolationPlug-ins are written by the company's own country teams and are trusted, so they run in-process for speed. If outside tax vendors were allowed to supply plug-ins, the design would move them behind a remote call with a timeout.
8. Specify the testsA shared contract test suite (rounding, empty baskets, negative refund lines, unknown categories) runs against every plug-in in its own pipeline. Each country team adds its own cases from published tax guidance.

The core no longer changes when a country is added, so the launch of a fourth country is a new package plus its tests, not a change to checkout.

The hardest decision is step 2. A category list that is too coarse forces plug-ins to guess; one that is too detailed couples the contract to one country's law. Expect to revise it, and plan the major-version migration when you do.

The registry rejects incompatible plug-ins at start-up, so a version mismatch shows up in a deployment check, not as wrong tax on a customer's receipt.

# package: tax_contract  (version 2.1.0)
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol

CONTRACT_VERSION = (2, 1)

@dataclass(frozen=True)
class TaxLine:
    sku: str
    category: str      # "hot-food", "cold-food", "alcohol", "delivery-fee"
    net: Decimal       # never float for money

@dataclass(frozen=True)
class TaxResult:
    total_tax: Decimal
    breakdown: dict[str, Decimal]   # label shown on the receipt

class TaxRules(Protocol):
    country: str                    # ISO 3166-1 alpha-2, e.g. "DE"
    requires_contract: tuple[int, int]
    def calculate(self, lines: list[TaxLine], region: str | None) -> TaxResult: ...
The contract package, versioned and published separately from both the core and the plug-ins.
from importlib.metadata import entry_points
from tax_contract import CONTRACT_VERSION

class UnsupportedCountry(Exception): pass

class TaxRegistry:
    def __init__(self, log):
        self._by_country = {}
        for ep in entry_points(group="fooddelivery.tax_rules"):
            plugin = ep.load()()
            major, minor = plugin.requires_contract
            if major != CONTRACT_VERSION[0] or minor > CONTRACT_VERSION[1]:
                log.warning("skipping %s: needs contract %s", ep.name, plugin.requires_contract)
                continue
            self._by_country[plugin.country] = plugin

    def for_country(self, country):
        if country not in self._by_country:
            raise UnsupportedCountry(country)   # fail loudly, never guess a rate
        return self._by_country[country]
The registry in the core: discovers installed plug-ins, checks contract compatibility and looks them up by country.

The tax plug-ins at run time

Worked example: checkout asks the tax registry for the plug-in registered for the customer's country and calls it through the TaxRules contractCheckoutcore use caseTaxRegistrycountry to plug-in1 for("DE")2 plug-inTaxRulescontract v2.13 calculate(lines)gb_tax 2.0.4needs v2.0de_tax 1.3.0needs v2.1us_tax 3.1.0needs v1.4skipped: wrongmajor versionLoading: at start-up the registry reads installed packages (entry points), checks eachplug-in's required contract version and registers it by country; incompatible ones arelogged and skipped instead of crashing checkout.registered bycountry code
Checkout asks the registry for the plug-in registered for a country and calls it through the contract; a plug-in built for an incompatible contract version is skipped at start-up.

Enforcing the style in code

A plug-in architecture only stays one if its rules are checked automatically. The structure that works in most languages has three kinds of package: a small contract package with interfaces and data types only; the core, which depends on the contract; and one package per plug-in, each depending on the contract and nothing else of yours.

Then enforce the dependency rules with tooling of the kind covered in Section 3.4: in Python, import-linter can forbid the core from importing any plug-in package and forbid plug-ins from importing each other or the core; in Java, ArchUnit tests or module systems (JPMS, OSGi) can do the same. Fail the build when a rule is broken.

Add a contract test suite: a set of tests written once against the contract and run against every plug-in in that plug-in's own pipeline. It checks behaviour the contract promises (rounding, empty input, error types), not each country's tax law, which the plug-in's own tests cover. Finally, add a start-up check or a deployment smoke test that loads every installed plug-in and verifies its contract version, so that incompatibilities are caught before users find them.

Questions people ask

Is a microkernel just the strategy pattern?

At code level the strategy pattern is the same mechanism: choose an implementation of an interface at run time. The microkernel is the architectural version: plug-ins are separately built, versioned and often separately installed, a registry discovers them, and the contract is a published API with compatibility rules.

Can plug-ins have their own data?

Yes, and often they should, but treat it like the module-owned data of a modular monolith: a plug-in's tables or files belong to it, and the core reaches them only through the contract.

Should configuration replace plug-ins?

For small, data-like differences (a rate, a threshold, a label), yes: configuration is cheaper and safer. Plug-ins are for differences that need logic. Many systems use both: a plug-in per country plus configuration for each country's current rates.

Check your understanding

What three things must a plug-in architecture define besides the core itself?

The plug-ins, the contracts they implement, and a registry that lets the core discover and look up plug-ins without naming them in code.

Why should plug-ins not depend on each other?

Because then they can no longer be installed, removed, tested and versioned independently, which is the main benefit of the style; shared needs belong in the core or the contract.

Why does Visual Studio Code run extensions in a separate process?

So that a slow or crashing extension cannot freeze or crash the editor's user interface: it trades some performance and complexity for isolation from thousands of third-party authors.

When is configuration better than a plug-in?

When the difference between variants is data, such as a rate, a limit or a label, rather than logic. Configuration is simpler and safer to change.

Next · 5.6Hexagonal and cleanPorts and adapters, onion and clean architecture: keeping business logic at the centre with dependencies pointing inwards, driving and driven ports, a Python example with a real adapter and a test double, and the costs.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “We sell a field-service app to 40 customers and each wants custom rules for job scheduling. Should we build a plug-in architecture, or is configuration enough?”