Code an application plugin system
Open in the interactive app: practice, quiz, mock interview →In one minute
- A plugin system is a contract between a host that must stay up and code it did not write. The hard parts are not the
Plugininterface; they are deciding what a plugin may rely on (versioned capabilities), what order things start in (dependencies), and what happens to everyone else when one plugin misbehaves (isolation). - Plugins should depend on capabilities such as
storage 1.2, not on each other by name. A capability has a name and amajor.minorversion: same major means compatible, higher minor means more features. This lets two plugins swap behind the same contract. - Startup order is a topological sort of the dependency graph, with a deterministic tie-break so the same set of plugins always loads the same way. Cycles and missing dependencies reject only the plugins involved, never the whole host.
- Every call into plugin code is a trust boundary. Wrap it, record the failure, count strikes, and stop calling a plugin that keeps failing for a cooldown period. In-process isolation is only error isolation; real isolation needs a process.
- The shape is partial failure inside one program: the same design appears as admission webhooks in Kubernetes, extensions in an editor, and middleware in a web framework.
Requirements
Functional:
- A host application defines named events (for example
request.start,save) and plugins subscribe handlers to them. - A plugin declares the capabilities it provides (name and version) and requires (name and minimum version).
- The host loads plugins in dependency order, calls each plugin's load hook once, and unloads in reverse order.
- Introspection: for each plugin, a state (
registered,active,rejected,failed,skipped,stopped) and a human reason. emit(event, payload)calls every subscribed handler in load order and returns the successful results.
Non-functional:
- A plugin that throws, panics or loads badly never stops the host or unrelated plugins.
- Load order is deterministic, so a bug report can be replayed.
- A repeatedly failing plugin stops consuming the request path and is retried later.
- Time is injected, so quarantine and cooldown are testable.
Out of scope: finding plugin files on disk (discovery is sketched in Down the stack), sandboxing untrusted code, hot reload, and plugin marketplaces. Mention them, park them in Extensibility, and move on.
Entities and relationships
- PluginManager is the host's façade. It owns the registry of plugins, their states, the resolved dependency edges, the load order, and the failure log.
- Plugin is a value-like record: a unique name, the capabilities it provides and requires, a map of event name to handler, and two lifecycle hooks (
on_load,on_unload). Keep it a record with callbacks; an abstract base class with five overridable methods is the heavier, more brittle shape. - Capability is a name plus a
major.minorversion. It is the unit of compatibility, and it is why plugins do not import each other. - State is an enum with guarded transitions: a plugin goes from
registeredto exactly one ofactive,rejected,failedorskipped, and fromactivetostopped. - Failure is an immutable log entry: time, plugin, where (
on_load, an event name,on_unload) and message. - Clock supplies time for failure records and cooldowns.
Relationships: the manager aggregates plugins (they are created outside it and handed in). Dependencies are not stored on a plugin; the manager computes them by matching each requires entry to the best provides entry, so the graph is derived data that can be recomputed after any change.
Class diagram
classDiagram
class PluginManager {
-plugins: Map~string, Plugin~
-state: Map~string, State~
-order: string[]
-strikes: Map~string, int~
-quarantineUntil: Map~string, int~
+register(plugin)
+loadAll() string[]
+emit(event, payload) Result[]
+provider(capability, want) string
+unloadAll() string[]
}
class Plugin {
+name: string
+provides: Map~string, Version~
+requires: Map~string, Version~
+handlers: Map~string, Handler~
+onLoad()
+onUnload()
}
class Failure {
+at: int
+plugin: string
+where: string
+message: string
}
class Clock {
<<interface>>
+now() int
}
class Handler {
<<interface>>
+call(payload) any
}
PluginManager "1" o-- "many" Plugin
PluginManager "1" *-- "many" Failure
PluginManager --> Clock
Plugin "1" o-- "many" HandlerKey flows
Load happens once, in three passes: resolve, order, start. Emit is the hot path and wraps every handler.
sequenceDiagram
participant H as Host
participant M as PluginManager
participant A as Plugin store
participant B as Plugin audit
H->>M: register(store), register(audit)
H->>M: loadAll()
M->>M: resolve requires to best provider
M->>M: topological sort, ties by name
M->>A: onLoad()
A-->>M: ok
M->>B: onLoad()
B-->>M: raises
M->>M: record failure, state failed
H->>M: emit("save", doc)
M->>A: handler(doc)
A-->>M: result
M-->>H: [(store, result)]
H->>M: unloadAll()
M->>A: onUnload()Design patterns used and why
- Observer is the event bus. A plugin subscribes handlers to named events and the host publishes without knowing who listens. The cost of Observer is that the publisher loses control of what runs on its thread, which is why every call is wrapped (see Concurrency and edge cases).
- Strategy is what a capability is. When several plugins provide
storage 1.x, the host selects one provider and calls through the capability's contract, exactly as a context object picks a strategy. The manager'sprovider(capability, want)is the selector: highest compatible version, ties broken by name. - Template Method is the lifecycle. The manager owns the fixed skeleton (check dependencies, call
on_load, record the outcome, update state), and a plugin fills in only the hook. The skeleton is the part you never let a plugin override, because it holds the safety rules. In languages with inheritance you can expose it as a base class withon_loadandon_unload; with first-class functions, a record of callbacks gives the same inversion of control with less coupling. - Façade hides the registry, the sort and the failure log behind five methods.
- Registry plus dependency injection replaces a global plugin list. The host builds one manager, passes the clock in, and passes the manager to code that emits events. A static singleton registry makes tests share plugins.
Concurrency and edge cases
- Cycles.
arequires whatbprovides and vice versa. A topological sort cannot emit either, so after the sort, whatever remains is rejected with the reasondependency cycle. Everything unrelated still loads. - Missing or too-old capability. Resolution runs to a fixpoint, because rejecting a plugin removes the capabilities it provided, which can orphan its dependents. Reject them in the same pass, not on the next boot.
- Two providers. Choose by rule, not by registration order: highest compatible version, then lowest name. Registration order depends on the filesystem listing, and filesystems do not promise one.
on_loadfails. The plugin becomesfailed. Its dependents becomeskipped(their provider is not active), which is different fromrejected: nothing is wrong with them, and they would load on a retry.- A handler throws or panics. Catch it per handler, log it with the event name, and keep going. In Go a panic must be caught with
recoverin the same goroutine, because a panic in a goroutine you did not start cannot be recovered by the caller. - A handler hangs. In-process, you cannot kill it. Either run handlers with a deadline on a worker you can abandon, or accept that only a process boundary gives real timeouts. The manager here has no timeouts and says so.
- Flapping plugin. A plugin failing on every event costs the request path every time. After
max_failuresconsecutive failures the manager quarantines it untilnow + cooldown. When the cooldown ends, one failure puts it straight back, because the strike count was not reset; one success clears it. This is the circuit-breaker idea applied per plugin. - Unload. Run in reverse load order so a dependent stops before what it depends on. An exception in one
on_unloadis logged and the rest still run. - Concurrency. A manager shared by request threads needs a lock around state, but never around handler calls, or one slow plugin serialises the host. The Go version copies the order under the mutex, releases it, and calls handlers outside it.
In the wild
- WordPress, 2019: recovery mode for fatal plugin errors. Before WordPress 5.2, a fatal error in a plugin or theme could produce the "white screen of death" that locked administrators out of the dashboard. Version 5.2 catches the fatal error, emails the admin a secret link, and in recovery mode pauses only the failing extensions for that browser so the admin can log in, deactivate or fix the plugin, and resume it (WordPress core blog). It is the same idea as quarantine: isolate, tell a human, keep the host reachable.
- npm, 2018: the event-stream takeover. An attacker posing as a helpful contributor became maintainer of a popular package and, on 9 September 2018, added the malicious
flatmap-streamas a dependency in version 3.3.6. The payload only activated inside one wallet application's build and targeted accounts holding over 100 Bitcoin; it was deployed in Copay 5.0.2 to 5.1.0, and npm removed the packages on 26 November (npm blog). A plugin runs with the host's privileges, so an error boundary does not make it safe: capability checks and signing are a separate layer. - Kubernetes admission webhooks: fail-open or fail-closed as a setting. Each webhook declares a
failurePolicyofFail(the default, reject the request) orIgnore, and a timeout between 1 and 30 seconds, default 10, after which the call counts as a failure (Kubernetes docs). The API server cannot know whether a given plugin is a safety check or a convenience, so the plugin's author states what a failure should mean.
Down the stack
Layer 1The contract
load_all never raises for plugin faults. Afterwards every registered plugin is in exactly one of active, rejected, failed or skipped, and the returned list is the active plugins in an order where every provider precedes the plugins that require it. A requirement storage 1.2 is satisfied only by a provider with major 1 and minor at least 2; a major bump is a breaking change by the convention of Semantic Versioning. emit returns the results of handlers that succeeded, in load order, and a failure is visible only in the failure log. A quarantined plugin receives no events until its cooldown ends.
Layer 2The mechanism
The sort is Kahn's algorithm: count unmet dependencies per plugin, keep a priority queue of plugins with none, pop the smallest name, and decrement its dependents. Anything never popped is on or behind a cycle. It runs in O(P + E) over plugins and edges, plus a log factor for the queue; Python's standard library has the same algorithm as graphlib.TopologicalSorter, which raises CycleError when no order exists. Discovery is a separate layer that produces Plugin records. In Python, distributions advertise plugins as entry points in their metadata, found with importlib.metadata.entry_points(group=...) without importing anything. Editors go further and delay the import: a VS Code extension is activated only when an activation event such as onLanguage or onCommand happens (VS Code docs), so unused extensions cost no startup time. Choosing the strongest satisfying version, rather than the first, is what makes upgrades of one plugin invisible to its dependents.
Layer 3The machine
An in-process handler call is an indirect call through a map lookup and a function pointer, tens of nanoseconds in Python and a few in Go. That is why in-process plugins can sit on hot paths. The price is a shared address space: a plugin can corrupt memory, leak, spin a core or call exit. Go's own plugin package loads a shared object into the host process, and its documentation warns that crashes are likely unless the host and every plugin are built with exactly the same toolchain version, build tags and dependency sources (Go docs). The other end is HashiCorp's go-plugin library, which launches each plugin as a subprocess and talks to it over RPC, so a panic in a plugin cannot panic the host (go-plugin README). A local RPC round trip costs on the order of tens to hundreds of microseconds, three to four orders of magnitude more than a function call, plus a process worth of memory per plugin. You buy fault isolation, language independence and killable plugins; you pay latency and serialisation. Microsoft names the general idea the bulkhead pattern: partition resources so that a failure in one partition does not sink the rest.
Why it's built this way
- The host's authors cannot review every plugin, so the host must assume any call can fail, which forces a catch around every boundary crossing.
- Plugins are written by different teams at different times, so they need a stable contract that is versioned independently of the host, which is why capabilities carry versions.
- Dependencies form a graph and teams add plugins in any order, so load order must be computed, and it must be deterministic or bugs cannot be reproduced.
- A failing plugin that is called on every request turns a local fault into a global slowdown, so the host stops calling it for a while and retries later.
- In-process calls are fast and share memory with the host, so a stronger boundary has to be an explicit, costly choice, made per plugin by what you trust.
Extensibility
- Hot reload: unload a plugin and its dependents in reverse order, then reload the changed one and re-run resolution. The derived graph makes this a recompute, not a special case.
- Out-of-process plugins: keep the
Pluginrecord, but makeon_loadstart a subprocess and each handler an RPC stub. Failures, timeouts and quarantine work unchanged; only the transport differs. - Permissions: add a
permissionsset to the record and pass handlers a context that exposes only what was granted. This is the layer that the event-stream incident shows is missing from an error boundary. - Ordering within an event: add a numeric
priorityorbefore/afterhints per handler, the way middleware chains do. Resist it until two plugins actually conflict, because every ordering knob is a new coupling. - Return-value policies:
emitcollects results; a pipeline variant feeds each result to the next handler, and a first-wins variant stops at the first non-empty answer. Both are Strategy objects on the manager.
Leadership lens
- Build first: the failure policy and the deterministic loader, with a test that registers a throwing plugin next to a good one. A plugin API is public the day the first outside plugin ships, and changing a contract later costs a major version for everyone.
- Ask in review: what does a plugin failure mean for the request (fail-open or fail-closed); who sees the failure log; what is the deprecation window for a capability version; and how does a user learn which plugin is slow.
- Decide the trust model before the API. In-process, same-privilege plugins are fine for your own team's modules, but third-party code needs a process boundary, a permission model and review, which is a different budget.
- Publish a compatibility policy and test against it: the host should run old plugins against new hosts in CI, because the plugin authors will not tell you when you broke them.
- In an interview, spend the first five minutes on the contract (what is versioned, what a failure means) and the rest on ordering and isolation, not on a
Pluginclass hierarchy.
Check yourself
Why do plugins depend on capabilities rather than on other plugins by name?
Show answer
A name couples you to one implementation. A capability with a version lets any compatible provider satisfy the requirement, makes upgrades and replacement local, and lets the host choose among providers by rule.
A plugin's
on_loadraises. Should its dependents berejectedorskipped, and why does it matter?Show answer
Skipped. Their declarations are valid; they only lack an active provider. The distinction tells an operator which plugin to fix, and the dependents can be retried once it works.
Why is a quarantine cooldown better than permanently disabling a failing plugin, and why keep the strike count after the cooldown?
Show answer
Many failures are transient (a dependency was down), so the plugin should return on its own. Keeping the count means one more failure re-trips it immediately, so a still-broken plugin costs one call per cooldown instead of a full run of failures.
Catching exceptions around handlers is in place. Name two failures it cannot contain.
Show answer
A handler that hangs or burns CPU, and one that corrupts shared memory or exits the process. Both need a process boundary with a timeout and the ability to kill it.
Sources and further study
Primary
- Semantic Versioning 2.0.0: the major, minor, patch convention that the capability compatibility rule borrows.
- Python graphlib: TopologicalSorter: a reference implementation of dependency ordering and cycle detection.
- Python packaging: entry points specification: how installed distributions advertise plugins without being imported.
Systems docs
- Go plugin package: shared-object plugins and the build-matching restrictions that make them fragile.
- hashicorp/go-plugin: the subprocess-plus-RPC design, with a protocol version to invalidate old plugins, used by Terraform and Vault.
- VS Code activation events: lazy activation of extensions.
- Kubernetes dynamic admission control: per-plugin
failurePolicyand timeouts. - Azure Architecture Center: Bulkhead pattern: partitioning so that one failing consumer does not sink the rest.
Books
- Design Patterns: Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson and Vlissides: chapter 5 (Behavioral), which covers Observer, Strategy and Template Method.
- Head First Design Patterns, Freeman and Robson: chapter 2 (Observer) and chapter 8 (Template Method).
- Clean Architecture, Robert C. Martin: chapter 17 "Boundaries: Drawing Lines", which argues for treating plugins as the way policy stays independent of details.
Watch
- Observer Pattern, Design Patterns ep 2 (Christopher Okhravi): the publish and subscribe core of event dispatch.
- Strategy Pattern, Design Patterns ep 1 (Christopher Okhravi): the pattern behind swappable capability providers.
Free resources
- Template Method (Refactoring.Guru): the fixed skeleton with overridable steps, used for the load lifecycle.
- Observer (Refactoring.Guru): subscription and notification with examples in several languages.
- Strategy (Refactoring.Guru): interchangeable behaviours behind one interface.
- Topological sorting (Wikipedia): Kahn's algorithm and depth-first ordering, with complexity.
- Inversion of Control Containers and the Dependency Injection pattern (Martin Fowler): the plugin-assembly problem that motivated dependency injection.