The Plugin Registry: Managing Plugin Lifecycle
Implement and manage the registry that controls plugin discovery, activation, and deactivation.
Chapter 5: The Plugin Registry — Managing Plugin Lifecycle
In chapter 4 we defined the three core contracts: the manifest that describes a plugin before it runs, the lifecycle that governs when it runs, and the SDK that controls what it can do. Those contracts are the rules. The registry is the referee — the runtime system that enforces them.
Every plugin passes through the registry. It is where a discovered manifest is validated before code is loaded, where the dependency graph is resolved before any lifecycle hooks are called, where state transitions are tracked and logged, and where the decision to load eagerly or lazily is made. A well-designed registry is invisible when things go well and informative when they do not.
This chapter builds one. We will look at how production systems approach discovery and registration, how the registry tracks plugin state as a formal state machine, how dependency ordering is resolved, and how the registry drives performance through lazy activation and memory management.
5.1 Registry Architecture
The registry has two core responsibilities: storing what it knows about plugins, and orchestrating what happens to them. These are usefully kept separate. The registry itself is the in-memory store and query API; a PluginLoader handles the actual code fetching and instantiation; a DiscoveryService handles finding plugins in the first place.
How Production Systems Approach Discovery
The choice of discovery mechanism is largely driven by the environment. VS Code scans the filesystem — it needs to find extensions that users have installed in a local directory, without knowing in advance what is there:
class ExtensionsScannerService {
async scanExtensions(): Promise<IExtensionDescription[]> {
const locations = [
userExtensionsDir,
builtinExtensionsDir,
devExtensionsDir,
];
const extensions = await Promise.all(
locations.map(loc => this.scanLocation(loc))
);
return extensions.flat();
}
private async scanLocation(dir: string): Promise<IExtensionDescription[]> {
const pkgJsons = await glob('**/package.json', { cwd: dir });
return pkgJsons.map(path => this.parseExtension(path));
}
}Kibana takes an asynchronous, stream-based approach. Rather than blocking until all plugins are discovered, it emits each discovered plugin as a separate event on an observable stream, with a parallel error stream for failed discoveries. This means one malformed manifest cannot block others from loading:
discover(paths: string[]) {
return {
plugin$: new Subject<PluginManifest>(),
error$: new Subject<PluginError>()
};
}
const { plugin$, error$ } = discover([pluginDir]);
plugin$.subscribe(manifest => registry.register(manifest));
error$.subscribe(err => logger.error('Discovery failed:', err));Backstage takes the opposite approach to discovery overhead: it does not scan at all. It leverages the existing package.json metadata that npm workspace packages already carry, treating the package manager's own package graph as the plugin index:
async discoverPackages() {
const packages = await getWorkspacePackages();
return packages.filter(pkg =>
pkg.manifest.backstage?.role === 'backend-plugin'
);
}Babel and Vite dispense with discovery entirely. Plugins are explicit in configuration — there is no scanning, no automatic detection, and no surprises. For build tools this is the right call: the developer controls their build configuration and implicit behaviour only adds confusion.
{
"plugins": [
"transform-arrow-functions",
"@babel/plugin-proposal-optional-chaining"
]
}The lesson is that discovery complexity should match the environment's needs. Filesystem scanning is appropriate when plugins are installed externally. Workspace metadata works when plugins are internal packages. Explicit configuration works when the set of plugins is the developer's own decision.
The Registry Interface
Building on those patterns, here is the core registry interface:
export interface PluginRegistry {
/** Core operations */
register: (definition: PluginDefinition) => Promise<void>;
unregister: (pluginId: string) => Promise<void>;
get: (pluginId: string) => PluginDefinition | undefined;
getAll: () => PluginDefinition[];
/** State queries */
getState: (pluginId: string) => PluginState;
getByState: (state: PluginState) => PluginDefinition[];
/** Dependency queries */
getDependents: (pluginId: string) => string[];
getDependencies: (pluginId: string) => string[];
getLoadOrder: () => string[];
/** Lifecycle control */
load: (pluginId: string) => Promise<void>;
activate: (pluginId: string) => Promise<void>;
deactivate: (pluginId: string) => Promise<void>;
reload: (pluginId: string) => Promise<void>;
/** Event subscriptions */
onStateChange: (
pluginId: string,
callback: (state: PluginState) => void
) => () => void;
/** Performance */
preload: (pluginIds: string[]) => Promise<void>;
pruneUnused: () => Promise<void>;
}
export interface PluginDefinition {
manifest: PluginManifest;
module?: PluginModule;
state: PluginState;
metadata: PluginMetadata;
dependencies: {
required: string[];
optional: string[];
peers: Map<string, string>;
};
metrics: {
loadTime?: number;
activationTime?: number;
memoryUsage?: number;
};
errors: PluginError[];
}
export type PluginState =
| 'discovered' // Manifest found, not yet validated
| 'registered' // Manifest validated, ready to load
| 'loading' // Code being fetched
| 'loaded' // Code loaded, init() not called
| 'activating' // Calling setup() or start()
| 'active' // Running and available
| 'deactivating' // Calling stop()
| 'inactive' // Deactivated but code still loaded
| 'error' // Failed at some stage
| 'unregistered'; // Removed from registryThe Plugin Wrapper (Kibana Pattern)
Kibana wraps each plugin in an object that owns its lifecycle transitions. This keeps the registry itself simple — it stores wrappers and delegates lifecycle calls to them — while giving each plugin an isolated place to track its own state:
class PluginWrapper {
public readonly id: string;
public readonly manifest: PluginManifest;
public readonly opaqueId: symbol;
private instance?: PluginModule;
private state: PluginState = 'registered';
private setupContract?: unknown;
private startContract?: unknown;
constructor(manifest: PluginManifest) {
this.id = manifest.id;
this.manifest = manifest;
this.opaqueId = Symbol(manifest.id);
}
async load(): Promise<void> {
if (this.state !== 'registered') {
throw new Error(`Cannot load plugin ${this.id} in state ${this.state}`);
}
this.state = 'loading';
try {
const moduleDef = await import(this.manifest.entry);
this.instance = moduleDef.plugin();
this.state = 'loaded';
} catch (error) {
this.state = 'error';
throw error;
}
}
async setup(context: PluginSetupContext): Promise<unknown> {
if (!this.instance?.setup) return undefined;
this.state = 'activating';
try {
this.setupContract = await this.instance.setup(context);
return this.setupContract;
} catch (error) {
this.state = 'error';
throw error;
}
}
async start(context: PluginStartContext): Promise<unknown> {
if (!this.instance?.start) {
this.state = 'active';
return undefined;
}
try {
this.startContract = await this.instance.start(context);
this.state = 'active';
return this.startContract;
} catch (error) {
this.state = 'error';
throw error;
}
}
async stop(): Promise<void> {
if (this.state !== 'active') return;
this.state = 'deactivating';
try {
await this.instance?.stop?.();
this.state = 'inactive';
} catch (error) {
console.error(`Plugin ${this.id} failed to stop:`, error);
// Log but continue — shutdown must not be blocked
}
}
}Discovery Implementation
A production discovery service needs to handle several sources simultaneously — filesystem, remote registry, explicit configuration — and survive individual failures gracefully:
interface PluginDiscoveryService {
scanDirectory(dir: string): AsyncIterable<PluginManifest>;
fetchFromRegistry(url: string): Promise<PluginManifest[]>;
loadFromConfig(config: PluginConfig[]): PluginManifest[];
watch(dir: string): Observable<PluginEvent>;
}
class ProductionDiscoveryService implements PluginDiscoveryService {
async *scanDirectory(dir: string): AsyncIterable<PluginManifest> {
const manifestPaths = await glob('**/plugin-manifest.json', {
cwd: dir,
absolute: true,
});
for (const path of manifestPaths) {
try {
const content = await fs.readFile(path, 'utf-8');
const manifest = JSON.parse(content);
const validated = PluginManifestSchema.parse(manifest);
yield validated;
} catch (error) {
// Emit error but continue scanning
this.emit('discovery:error', { path, error });
}
}
}
async fetchFromRegistry(url: string): Promise<PluginManifest[]> {
const response = await fetch(url);
const registry = await response.json();
return registry.plugins.map((p: unknown) =>
PluginManifestSchema.parse(p)
);
}
loadFromConfig(config: PluginConfig[]): PluginManifest[] {
return config.map(c => this.resolveConfig(c));
}
watch(dir: string): Observable<PluginEvent> {
return new Observable(subscriber => {
const watcher = chokidar.watch('**/plugin-manifest.json', {
cwd: dir,
ignoreInitial: true,
});
watcher.on('add', path => subscriber.next({ type: 'added', path }));
watcher.on('change', path => subscriber.next({ type: 'changed', path }));
watcher.on('unlink', path => subscriber.next({ type: 'removed', path }));
return () => watcher.close();
});
}
}Plugin Metadata
Each registered plugin carries metadata beyond what the manifest declares. This runtime metadata enables smart preloading based on usage patterns, security auditing of which permissions were actually granted versus requested, and debugging through a full error history with phase context:
interface PluginMetadata {
source: 'filesystem' | 'registry' | 'config' | 'npm';
discoveredAt: Date;
discoveryPath?: string;
hostVersion: string;
sdkVersion: string;
compatible: boolean;
compatibilityErrors?: string[];
permissions: {
requested: string[];
granted: string[];
denied: string[];
};
sandbox: 'none' | 'logical' | 'worker' | 'process' | 'iframe';
trustLevel: 'trusted' | 'verified' | 'untrusted';
metrics: {
loadTime?: number;
setupTime?: number;
startTime?: number;
memoryUsage?: number;
lastActivation?: Date;
activationCount: number;
};
errors: Array<{
phase: 'discovery' | 'load' | 'setup' | 'start' | 'runtime';
timestamp: Date;
message: string;
stack?: string;
}>;
environment: {
server: boolean;
browser: boolean;
worker: boolean;
};
}5.2 Lifecycle State Management
A plugin's journey through the registry is a formal state machine. Each state is a distinct condition with a defined set of valid transitions. The registry enforces those transitions — attempting to call setup() on a plugin that is not yet in the loaded state is an error, not a silent no-op.
State Machine Design
The most careful production state machine in the systems we have studied is Kibana's, which carries the active contracts through the state type itself rather than storing them separately:
// Kibana's approach: state carries its associated data
type PluginState =
| { type: 'uninitialized' }
| { type: 'initializing' }
| { type: 'initialized'; setupContract: unknown }
| { type: 'starting' }
| { type: 'started'; startContract: unknown }
| { type: 'stopping' }
| { type: 'stopped' }
| { type: 'error'; error: Error };The advantage is that accessing setupContract on a plugin that has not completed setup is a type error — not a runtime undefined. Here is a fuller implementation building on this pattern:
type PluginState =
| { status: 'discovered'; manifest: PluginManifest }
| { status: 'registered'; manifest: PluginManifest; validated: true }
| { status: 'loading'; manifest: PluginManifest; startTime: number }
| { status: 'loaded'; module: PluginModule; loadTime: number }
| { status: 'setup'; module: PluginModule; setupPromise: Promise<unknown> }
| { status: 'setupComplete'; setupContract: unknown; setupTime: number }
| { status: 'starting'; setupContract: unknown; startPromise: Promise<unknown> }
| { status: 'active'; contracts: { setup: unknown; start: unknown } }
| { status: 'stopping'; stopPromise: Promise<void> }
| { status: 'inactive' }
| { status: 'error'; phase: string; error: Error; previousState?: PluginState };
class PluginStateMachine {
private state: PluginState;
private listeners = new Set<(state: PluginState) => void>();
constructor(initialState: PluginState) {
this.state = initialState;
}
transition(newState: PluginState): void {
if (!this.isValidTransition(this.state, newState)) {
throw new Error(
`Invalid transition from ${this.state.status} to ${newState.status}`
);
}
const oldState = this.state;
this.state = newState;
this.listeners.forEach(listener => listener(newState));
this.recordTransition(oldState, newState);
}
private isValidTransition(from: PluginState, to: PluginState): boolean {
const transitions: Record<string, string[]> = {
discovered: ['registered', 'error'],
registered: ['loading', 'error'],
loading: ['loaded', 'error'],
loaded: ['setup', 'error'],
setup: ['setupComplete', 'error'],
setupComplete: ['starting', 'error'],
starting: ['active', 'error'],
active: ['stopping', 'error'],
stopping: ['inactive', 'error'],
inactive: ['loading', 'unregistered'],
error: ['registered', 'unregistered'],
};
return transitions[from.status]?.includes(to.status) ?? false;
}
onStateChange(callback: (state: PluginState) => void): () => void {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
getState(): PluginState {
return this.state;
}
}Transition Validation in Practice
The lifecycle manager wraps state machine transitions with the actual async work, surfacing clear errors when preconditions are not met:
class PluginLifecycleManager {
async load(pluginId: string): Promise<void> {
const plugin = this.registry.get(pluginId);
if (!plugin) {
throw new Error(`Plugin ${pluginId} not found`);
}
if (plugin.state.status !== 'registered') {
throw new Error(
`Cannot load plugin ${pluginId} in state ${plugin.state.status}. ` +
`Expected state: registered`
);
}
try {
plugin.stateMachine.transition({
status: 'loading',
manifest: plugin.manifest,
startTime: Date.now(),
});
const module = await this.loader.load(plugin.manifest.entry);
plugin.stateMachine.transition({
status: 'loaded',
module,
loadTime: Date.now() - startTime,
});
} catch (error) {
plugin.stateMachine.transition({
status: 'error',
phase: 'load',
error,
previousState: plugin.state,
});
throw error;
}
}
}Error Handling
Errors during lifecycle transitions need rich context to be actionable. Kibana wraps every lifecycle error with the plugin name and phase so the log entry is self-contained:
try {
await plugin.setup(context);
} catch (error) {
throw new Error(
`Setup lifecycle of "${pluginName}" plugin wasn't completed. ` +
`Error: ${error.message}`,
{ cause: error }
);
}A centralised error handler adds further context and routes errors to the appropriate destinations:
class PluginErrorHandler {
handleError(pluginId: string, phase: string, error: Error): void {
const enriched = {
pluginId,
phase,
error: {
message: error.message,
stack: error.stack,
cause: error.cause,
},
timestamp: new Date().toISOString(),
environment: {
nodeVersion: process.version,
platform: process.platform,
},
pluginState: this.registry.getState(pluginId),
};
this.logger.error('Plugin lifecycle error', enriched);
const plugin = this.registry.get(pluginId);
plugin.metadata.errors.push({
phase,
timestamp: new Date(),
message: error.message,
stack: error.stack,
});
this.errorService.capture(enriched);
if (plugin.manifest.environments?.browser) {
this.notifyUser(pluginId, phase, error);
}
}
}VS Code tracks failed activations in a dedicated map separate from normal activation records. This lets the editor surface a "extension failed to activate" notification without that failure corrupting the general extension state.
Rollback and Recovery
Some failures are transient and worth retrying. A recovery manager with a bounded retry count handles this without risking infinite loops:
class PluginRecoveryManager {
private readonly MAX_RETRIES = 3;
private retryCount = new Map<string, number>();
async attemptRecovery(pluginId: string): Promise<boolean> {
const retries = this.retryCount.get(pluginId) || 0;
if (retries >= this.MAX_RETRIES) {
logger.error(`Plugin ${pluginId} exceeded max retries`);
return false;
}
try {
await this.cleanup(pluginId);
const plugin = this.registry.get(pluginId);
plugin.stateMachine.transition({
status: 'registered',
manifest: plugin.manifest,
validated: true,
});
await this.lifecycleManager.load(pluginId);
await this.lifecycleManager.activate(pluginId);
this.retryCount.delete(pluginId);
return true;
} catch (error) {
this.retryCount.set(pluginId, retries + 1);
logger.warn(`Recovery attempt ${retries + 1} failed for ${pluginId}`);
return false;
}
}
private async cleanup(pluginId: string): Promise<void> {
try {
const plugin = this.registry.get(pluginId);
if (plugin.state.status === 'active') {
await plugin.module?.stop?.();
}
delete plugin.module;
plugin.metadata.errors = [];
} catch (error) {
logger.warn(`Cleanup failed for ${pluginId}:`, error);
}
}
}VS Code limits extension host restarts to five within sixty seconds before giving up, which prevents a persistently crashing extension from consuming resources indefinitely. The same principle applies here: recovery attempts should be bounded and logged.
5.3 Dependency Management
When plugins depend on other plugins, load order matters. A plugin cannot call its dependency's setup contract until that contract exists, which means the dependency must complete setup first. Getting this right requires resolving the dependency graph before any lifecycle hooks are called.
Topological Sorting
The standard algorithm for dependency ordering is a depth-first topological sort. It visits each plugin's dependencies before marking the plugin itself as visited, and raises an error if it encounters a plugin it is already in the process of visiting — which indicates a cycle:
class DependencyResolver {
sortPlugins(plugins: Map<string, PluginManifest>): string[] {
const sorted: string[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
const visit = (id: string) => {
if (visited.has(id)) return;
if (visiting.has(id)) {
throw new Error(`Circular dependency detected: ${id}`);
}
visiting.add(id);
const manifest = plugins.get(id);
if (!manifest) {
throw new Error(`Plugin ${id} not found`);
}
for (const depId of manifest.requiredPlugins || []) {
visit(depId);
}
visiting.delete(id);
visited.add(id);
sorted.push(id);
};
for (const id of plugins.keys()) {
visit(id);
}
return sorted;
}
}Kibana's production implementation extends this with batching — plugins that do not depend on each other can be set up in parallel, which matters when you have dozens of plugins and each setup phase makes async calls:
private getTopologicallySortedPluginNames() {
const pluginDependencies = new Map<PluginName, PluginName[]>();
for (const [pluginName, plugin] of this.plugins) {
pluginDependencies.set(
pluginName,
[
...plugin.requiredPlugins,
...plugin.optionalPlugins.filter(dep => this.plugins.has(dep)),
]
);
}
return Array.from(
topologicallyBatchPluginNames(pluginDependencies)
).flat();
}Circular dependencies should fail fast with a clear error message that includes the full cycle path:
if (visiting.has(pluginName)) {
const path = [...visiting, pluginName].join(' -> ');
throw new Error(`Circular plugin dependencies detected: ${path}`);
}Optional vs Required Dependencies
The type system can reflect the optionality of dependencies directly, eliminating the need for runtime null checks inside setup functions:
interface PluginDependencies {
requiredPlugins: string[]; // Must exist and be loaded first
optionalPlugins: string[]; // Used if available, skipped if not
runtimePluginDependencies?: string[]; // Resolved at runtime, not part of load order
}
export class MyPlugin implements Plugin<MySetup, MyStart> {
setup(
core: CoreSetup,
plugins: {
data: DataSetup; // Required — always present
share?: ShareSetup; // Optional — may be undefined
}
) {
plugins.data.search.registerStrategy('custom', new CustomStrategy());
if (plugins.share) {
plugins.share.register(/* ... */);
}
}
}For optional dependencies that are resolved lazily — where a plugin only needs another plugin's services under certain runtime conditions — a lazy resolver avoids loading the dependency until it is actually accessed:
class LazyDependencyResolver {
resolve<T>(pluginId: string, depId: string): T | undefined {
const dep = this.registry.get(depId);
if (!dep || dep.state.status !== 'active') {
return undefined;
}
return dep.contracts.start as T;
}
}Version Compatibility
Version mismatches between a plugin and its dependencies — or between a plugin and the host — are one of the most common causes of runtime failures in plugin ecosystems. Checking them before any code runs is far preferable to discovering them at activation time.
Backstage tracks service versions through its ServiceRef system and validates them at startup. Vendure uses a decorator-level compatibility declaration:
@VendurePlugin({
compatibility: '^3.0.0',
})
export class MyPlugin {
// Fails to load if Vendure version is incompatible
}A version validator that checks both host compatibility and peer dependencies:
class PeerDependencyValidator {
validate(plugin: PluginManifest): ValidationResult {
const errors: string[] = [];
if (plugin.compatibility) {
const hostVersion = this.getHostVersion();
if (!semver.satisfies(hostVersion, plugin.compatibility)) {
errors.push(
`Plugin ${plugin.id} requires host version ${plugin.compatibility}, ` +
`but current version is ${hostVersion}`
);
}
}
for (const [peerId, versionRange] of plugin.peerDependencies || []) {
const peer = this.registry.get(peerId);
if (!peer) {
errors.push(`Peer dependency ${peerId} not found`);
continue;
}
if (!semver.satisfies(peer.manifest.version, versionRange)) {
errors.push(
`Plugin ${plugin.id} requires ${peerId}@${versionRange}, ` +
`but ${peer.manifest.version} is installed`
);
}
}
return { valid: errors.length === 0, errors };
}
}Installation, Updates, and Rollback
In a live system, plugins are not static. The registry needs to handle the full lifecycle of a plugin as a versioned artefact — installation, update, and rollback.
Installation follows a straightforward sequence: fetch the manifest, validate it against the schema and the host's compatibility range, download and verify the plugin bundle, call registry.register(), and then optionally activate immediately or defer to the configured activation strategy. The fetch and verify step is where security scanning lives — checking signatures, validating permissions, and ensuring the bundle matches the manifest's declared entry point.
Updates require more care. A plugin that is currently active cannot simply be replaced — its running state, any resources it holds, and any subscriptions it has registered need to be cleaned up first. The safest update path is to deactivate the old version, replace the bundle, and re-activate. For non-critical UI plugins, a more aggressive approach is to hot-swap: unmount the UI, load the new version, and remount, without touching the underlying plugin state.
Rollback is straightforward if you keep previous bundles cached. If the new version fails to activate, revert to the previous bundle and re-register the old manifest. Maintaining a version history — not just the current version — also supports auditing: you can always answer what was running at any given time.
5.4 Performance Optimisation
The registry is the primary lever for performance in a plugin system. The decisions it makes about when to load, when to unload, and what to keep in memory have more impact than any optimisation inside the plugins themselves.
Lazy Activation
Loading a plugin only when it is needed is the single most effective performance strategy in a large ecosystem. VS Code's numbers illustrate this concretely: a typical installation has 20–50 extensions, but only around five activate at startup. The rest wait for their activation event — a file of their language being opened, a command they own being invoked, a view becoming visible:
interface PluginManifest {
activationEvents?: Array<
| `onLanguage:${string}`
| `onCommand:${string}`
| `onView:${string}`
| `onFileSystem:${string}`
| 'onStartupFinished'
| '*' // Discouraged — activates immediately
>;
}
class ActivationEventService {
async activateByEvent(event: string): Promise<void> {
const matching = this.registry
.getAll()
.filter(p => p.manifest.activationEvents?.includes(event));
await Promise.all(
matching.map(p => this.activator.activate(p.id))
);
}
}The * wildcard — which activates immediately regardless of events — should be treated as a last resort. In a large ecosystem, widespread use of * would eliminate the benefit of lazy activation entirely.
Memory Management
A plugin that has been inactive for a long time and is not required by any currently active plugin is a candidate for unloading. The registry can track this through the lastActivation timestamp in plugin metadata and periodically prune idle optional plugins:
class PluginMemoryManager {
async unloadUnused(): Promise<void> {
const candidates = this.registry
.getByState('active')
.filter(p => {
const idle = Date.now() - p.metadata.metrics.lastActivation! > IDLE_THRESHOLD;
const optional = !p.manifest.required;
return idle && optional;
});
for (const plugin of candidates) {
await this.deactivate(plugin.id);
delete plugin.module; // Keep manifest, release code
}
}
}Keeping the manifest while releasing the code is an important distinction. The registry still knows the plugin exists and can reload it on demand. The expensive part — the parsed and executed JavaScript — is what gets freed.
Predictive Preloading
Usage analytics can inform preloading decisions: if a plugin is consistently activated within seconds of login, it makes sense to begin loading it during the idle period after initial render, rather than waiting for the user to trigger its activation event:
class PluginPreloader {
async preloadLikely(): Promise<void> {
const analytics = await this.getUsageAnalytics();
const likely = analytics
.filter(a => a.probability > 0.7)
.map(a => a.pluginId);
requestIdleCallback(() => {
this.registry.preload(likely);
});
}
}requestIdleCallback is the right mechanism here — preloading should never compete with user-initiated work for CPU time.
Resource Pooling
Some resources are expensive to create and safe to share: HTTP clients, WebSocket connections, database connection pools. Rather than each plugin creating its own, these should be provided through the SDK as shared singletons:
class SharedHTTPClient {
private static instance: HTTPClient;
static getInstance(): HTTPClient {
if (!this.instance) {
this.instance = new HTTPClient({
timeout: 30000,
retries: 3,
});
}
return this.instance;
}
}
// Plugins access the shared client through the SDK
sdk.data.http.get(url);The SDK is the natural place for this. Plugins that access resources through sdk.data.http automatically get the shared client without needing to know it exists.
Conclusion
The registry is what turns the contracts from chapter 4 into a working system. Without it, a manifest is just a JSON file and a lifecycle hook is just a function signature. The registry is what makes the manifest mean something — it reads it, validates it, resolves the dependencies it declares, and uses the activation events it specifies to decide when to wake the plugin up.
The design choices here compound. A registry that uses streaming discovery does not block on individual manifest failures. One that models state as a formal machine catches invalid lifecycle sequences before they cause confusing bugs. One that separates required from optional dependencies in both the type system and the resolver produces plugin code that handles absence cleanly. And one that makes lazy activation the default rather than an afterthought can support an ecosystem of thousands of plugins without that scale affecting startup time.
In the next chapter, we will look at how the registry's load step is actually implemented — the mechanics of dynamic module loading, the differences between ESM, CommonJS, and UMD, and the strategies for handling cross-origin plugin code in the browser.