Engineering Atlas › Low-Level Design

Code an application plugin system

15 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

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

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" Handler

Key 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

Concurrency and edge cases

In the wild

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Leadership lens

Check yourself

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

  2. A plugin's on_load raises. Should its dependents be rejected or skipped, 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.

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

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

Systems docs

Books

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →