Plugin architecture
Low-level design of a plugin system -- plugin interface contract, lifecycle management (load/start/stop/unload), dependency resolution, hook points, sandboxed execution, and version compatibility.
The Problem
Your team ships a Java application that customers want to customize. Every quarter, the product backlog fills with one-off feature requests: custom export formats, third-party integrations, specialized validation rules. Each request means a code change, a rebuild, and a full deploy. The core team spends more time on bespoke customer logic than on the actual product.
A plugin architecture solves this by defining a stable contract that external code can implement. Plugins live outside the main codebase, get discovered and loaded at runtime, and hook into well-defined extension points. The host application stays lean. New behavior ships as a plugin JAR dropped into a directory, not a pull request against the core.
Design the core classes for a plugin system that supports a plugin interface contract, lifecycle management (discover, load, start, stop, unload), dependency resolution between plugins, hook points for extending host behavior, sandboxed execution, and version compatibility checks.
Requirements
Clarifying Questions
Before jumping into class design, ask questions to turn the vague prompt into a concrete specification. Cover four areas: core actions, error handling, boundaries, and future extensions.
You: "How are plugins discovered? Are they JAR files in a directory, or registered through configuration?"
Interviewer: "Plugins are JAR files dropped into a plugins directory. Each JAR contains a manifest with the plugin's metadata: name, version, entry class, and dependencies."
Directory-based discovery with a manifest file. That means we scan a folder at startup, read each JAR's manifest, and instantiate the declared entry class.
You: "Can plugins depend on other plugins? If plugin A requires plugin B, should B load first?"
Interviewer: "Yes. Plugins declare dependencies by plugin ID. The system must resolve load order so dependencies start before dependents."
Dependency resolution means topological sort. We also need to detect and reject circular dependencies.
You: "What lifecycle states does a plugin go through?"
Interviewer: "Discovered, loaded, started, stopped, unloaded. A plugin can also be in a failed state if any transition throws."
Six states: DISCOVERED, LOADED, STARTED, STOPPED, UNLOADED, FAILED. That calls for a state machine with guarded transitions.
You: "How do plugins extend host behavior? Are there predefined hook points?"
Interviewer: "The host defines named hook points. Plugins register handlers for hooks they care about. When the host reaches a hook point, it executes all registered handlers in priority order."
Hook points with priority ordering. The host calls executeHook("beforeSave", context) and every plugin that registered a handler for beforeSave runs in priority order. This is the Observer pattern with prioritized dispatch.
You: "Should a crashing plugin take down the host or other plugins?"
Interviewer: "Never. Wrap each plugin call. If a plugin throws, log the error, transition it to FAILED, and continue. Other plugins must not be affected."
Error containment is a hard requirement. Every plugin invocation is wrapped in a try-catch. A failing plugin transitions to FAILED state and gets skipped on subsequent hook executions.
You: "Do we need version compatibility checks? Can a plugin declare a minimum host version?"
Interviewer: "Yes. Each plugin declares a required host version range using semantic versioning. If the host version is outside the range, the plugin is rejected at load time."
Semantic version checking at load time. We parse the plugin's requiredHostVersion field and compare it against the running host version.
You: "Is hot reload in scope? Can we unload a plugin, swap the JAR, and reload without restarting the host?"
Interviewer: "Not for the initial design, but mention it as an extension. Focus on the clean lifecycle first."
Good. Hot reload is out of scope for now but influences our design decisions (we still want clean resource cleanup). That is our extensibility story.
Final Requirements
Functional Requirements:
- Discover plugins by scanning a directory of JAR files and reading each manifest
- Manage plugin lifecycle through six states: DISCOVERED, LOADED, STARTED, STOPPED, UNLOADED, FAILED
- Resolve plugin dependencies via topological sort and reject circular dependencies
- Provide a hook-point system where plugins register prioritized handlers for named extension points
- Enforce semantic version compatibility at load time
- Expose a sandboxed
PluginContextas the only API surface available to plugins
Non-Functional Requirements:
- A crashing plugin must never crash the host or other plugins
- The design must support adding new hook points without modifying existing plugins
- Thread safety for concurrent hook execution
Out of Scope:
- Hot reload (extension topic)
- Plugin marketplace or remote download
- UI for plugin management
- Class-loader isolation (mention in extensibility)
- Persistence of plugin state across restarts
30-Second Design Summary
The host reads PluginDescriptor metadata before instantiating code, resolves dependencies, and tracks each plugin in a host-owned PluginWrapper. PluginManager controls lifecycle transitions; HookRegistry dispatches prioritized handlers; and PluginContext is the narrow capability boundary passed to plugin code. The design treats failure as a per-plugin state transition, so a bad callback is contained without corrupting dependency order or stopping unrelated hooks.
5-Minute Walkthrough
- Set the scope. The core covers directory discovery, manifest validation, lifecycle states, dependency ordering, version checks, hooks, and a constrained context. Hot reload, class-loader isolation, marketplaces, and persistence are extensions.
- Inspect metadata first. Read descriptors, validate host-version requirements, normalize dependencies, and reject malformed or circular graphs before running plugin callbacks.
- Load in dependency order. Topological sorting determines which plugin can load first. Each wrapper transitions through guarded states so the manager, not plugin code, owns lifecycle truth.
- Dispatch hooks safely. Copy-on-write handler lists give readers a stable snapshot while registration changes the next dispatch. Sort by priority, invoke handlers behind an exception boundary, and mark the owning plugin failed when policy requires it.
- Keep the host stable.
PluginContextexposes only approved services, cleanup runs on stop/unload, and failure propagation skips dependents without disabling independent plugins. New hooks or discovery strategies should use existing contracts.
Example Inputs and Outputs
Scenario 1: Loading two plugins with a dependency
- Input:
plugins/directory containsmetrics-plugin.jar(no dependencies) anddashboard-plugin.jar(depends onmetrics-plugin) - Expected: PluginManager discovers both, resolves load order (metrics first, dashboard second), loads both, starts both. Both reach STARTED state.
- Why: validates dependency resolution and correct lifecycle ordering
Scenario 2: Version incompatibility rejection
- Input:
export-plugin.jardeclaresrequiredHostVersion: ">=3.0.0", but the host runs version2.5.1 - Expected: PluginManager discovers the JAR, reads the manifest, checks version compatibility, and transitions to FAILED with a clear error message. No load attempt.
- Why: validates version gating before any plugin code executes
Scenario 3: Hook execution with a failing plugin
- Input: Three plugins registered for the
beforeSavehook. The second plugin throws aNullPointerExceptionduring execution. - Expected: First plugin handler runs successfully. Second plugin handler throws, gets caught, plugin transitions to FAILED. Third plugin handler runs normally. The host save operation continues.
- Why: validates error containment and isolation between plugins
Try it yourself
Before reading the solution, spend 15-20 minutes sketching the plugin lifecycle state machine and the core entities. Focus on how the PluginManager orchestrates discovery, loading, and starting. Think about where dependency resolution fits in the flow. Compare your approach with the walkthrough below.
Step 1: Identify Core Entities
Start by asking: what are the main "things" in this system? Look at the nouns in your requirements: plugins, a manager that orchestrates them, descriptors that carry metadata, lifecycle states, hook points, and a context that sandboxes what plugins can access.
A common mistake is lumping everything into a single PluginManager god class. Good design splits coordination from data, and policy from mechanism. Each class below has a single, clear job.
| Entity | Responsibility | Key attributes |
|---|---|---|
Plugin | The contract. Defines lifecycle callbacks that plugin authors implement. | onLoad(), onStart(), onStop(), onUnload() |
PluginDescriptor | Metadata read from the JAR manifest. Pure data, no behavior. | id, version, entryClass, dependencies, requiredHostVersion |
PluginState | Enum tracking where a plugin sits in its lifecycle. | DISCOVERED, LOADED, STARTED, STOPPED, UNLOADED, FAILED |
PluginWrapper | Runtime container pairing a plugin instance with its descriptor and state. | plugin, descriptor, state, registeredHooks |
PluginManager | The orchestrator. Discovers, loads, starts, stops plugins in the right order. | registry (map of wrappers), hookRegistry |
HookRegistry | Manages named hook points and dispatches to registered handlers. | hooks (map of hook name to handler list) |
HookHandler | A single handler registered by a plugin for a specific hook point. | pluginId, priority, callback |
PluginContext | The sandboxed API surface passed to plugins. Limits what they can access. | logger, config, hookRegistry (register only) |
Notice we separated PluginDescriptor from Plugin. The descriptor is read from the manifest before any plugin code executes. This lets us do version checks and dependency resolution on metadata alone, without instantiating untrusted code. PluginWrapper exists because the host needs to track state alongside the plugin instance, but the plugin itself should not control its own state transitions.
Step 2: Define Relationships and Class Design
Class Diagram
Plugin Lifecycle State Machine
The state machine enforces valid transitions. A plugin cannot jump from DISCOVERED to STARTED. Each transition corresponds to a lifecycle callback.
Deriving PluginManager State and Methods
PluginManager is the orchestrator. It coordinates every phase.
Deriving state from requirements:
| Requirement | What PluginManager must track |
|---|---|
| "Discover plugins from a directory" | Path to plugins dir, map of discovered wrappers |
| "Resolve load order" | All descriptors for dependency graph |
| "Start/stop individual plugins" | Registry keyed by plugin ID |
| "Execute hooks" | Reference to HookRegistry |
| "Version compatibility" | The host's own version string |
Deriving methods from needs:
| Need | Method |
|---|---|
| Scan directory and read manifests | discoverAll(Path pluginsDir) |
| Topological sort + load in order | loadAll() |
| Start all loaded plugins | startAll() |
| Stop a single plugin and its dependents | stopPlugin(String id) |
| Clean up and release resources | unloadPlugin(String id) |
Deriving HookRegistry State and Methods
HookRegistry is the extension-point dispatcher.
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.