Web Loom logo
Plugin BookCore Contracts and Interfaces: Building the Plugin Foundation
Part I: Foundations and Theory

Core Contracts and Interfaces: Building the Plugin Foundation

Define the essential contracts and interfaces for a plugin system.

Chapter 4: Core Contracts and Interfaces — Building the Plugin Foundation

Chapter 3 ended with a promise: to take the principles of framework-agnostic design and turn them into a concrete set of interfaces. This chapter keeps that promise. What we are building here is the constitution of the plugin ecosystem — three artifacts that govern every interaction between the host and its plugins, and that must stay stable even as everything else evolves.

The first is the manifest: a static declaration of what a plugin is, what it needs, and what it contributes, readable before a single line of plugin code runs. The second is the lifecycle contract: the set of hooks that define when plugin code executes and in what order. The third is the SDK: the controlled surface area through which plugins interact with the host. Get these three right and the rest of the system has a solid foundation to build on. Get them wrong and you will be paying the debt with every release.


4.1 Plugin Manifest Design

The manifest's most important property is that it can be read, validated, and acted upon without executing any plugin code. That single constraint shapes almost everything about how it is designed.

A host that can inspect a manifest before loading a plugin can make meaningful decisions: whether this plugin's declared permissions are acceptable, whether its declared dependencies are satisfied, whether its compatibility range matches the current host version, and whether it should be loaded eagerly at startup or lazily when a specific event occurs. All of that happens before import() is ever called. This is what makes marketplace security scanning feasible, what makes lazy activation reliable, and what makes dependency resolution deterministic.

Production systems approach manifests differently based on their constraints. VS Code uses package.json with extension-specific fields, which integrates naturally with npm's ecosystem while adding editor-specific metadata like activation events and contribution points. Kibana uses dedicated kibana.json files with strict schema validation, deliberately separating plugin metadata from package management. Backstage uses package.json roles (backend-plugin, frontend-plugin) for automatic discovery while staying compatible with standard Node.js tooling.

Here is a comprehensive manifest interface informed by all three:

export interface PluginManifest {
  /** Unique plugin identifier (scoped naming recommended: @vendor/plugin-name) */
  id: string;
  /** Human-readable name */
  name: string;
  /** Semantic version (must follow semver) */
  version: string;
  /** Optional description */
  description?: string;
  /** Author/vendor info */
  author?: string;
  /** Entry file path or URL */
  entry: string;
  /** Optional plugin icon */
  icon?: string;
 
  /** Execution environment targets */
  environments?: {
    server?: boolean;
    browser?: boolean;
    worker?: boolean;
  };
 
  /** Required host version range (semver) */
  compatibility?: string;
 
  /** Plugin dependencies */
  requiredPlugins?: string[];
  optionalPlugins?: string[];
 
  /** Activation strategy */
  activation?: {
    /** When to load the plugin */
    events?: ActivationEvent[];
    /** Always load at startup */
    eager?: boolean;
  };
 
  /** Routes contributed by the plugin */
  routes?: PluginRouteDefinition[];
  /** Menu items contributed by the plugin */
  menuItems?: PluginMenuItem[];
  /** Dashboard widgets contributed by the plugin */
  widgets?: PluginWidgetDefinition[];
 
  /** Permissions required by the plugin */
  permissions?: {
    network?: boolean;
    fileSystem?: { read?: boolean; write?: boolean };
    storage?: boolean;
  };
 
  /** Arbitrary custom metadata */
  metadata?: Record<string, unknown>;
}
 
/** Activation events inspired by VS Code */
export type ActivationEvent =
  | { type: 'onStartup' }
  | { type: 'onCommand'; command: string }
  | { type: 'onView'; viewId: string }
  | { type: 'onLanguage'; language: string }
  | { type: 'onEvent'; event: string };

A few design decisions here are worth explaining.

The activation field with its events array is the VS Code pattern applied in typed form. Rather than eagerly loading every plugin, the host uses these events to decide when a plugin is actually needed. A plugin that declares onLanguage: typescript costs nothing until the user opens a TypeScript file. At scale, this is not a minor optimisation — it is the difference between a usable and an unusable startup experience.

The compatibility semver range enables the host to detect mismatches before loading. Kibana uses exactly this pattern — a plugin declaring "^3.0.0" will refuse to load against a host running version 2 or 4, failing loudly at startup rather than silently misbehaving at runtime.

The permissions block is borrowed from the mobile app and browser extension world. Declaring required capabilities upfront enables security scanning workflows: a marketplace can surface what data access a plugin is requesting before a user installs it. Treat PluginManifest as immutable once loaded — its values are used by the dependency resolver, the cache, and the registry, and mutation after the fact introduces consistency bugs that are very hard to trace.


4.2 Plugin Lifecycle Contracts

The lifecycle contract answers a deceptively simple question: when does plugin code run? The answer turns out to require several distinct phases, each with different guarantees about what is available.

Why Multiple Phases Matter

The simplest possible lifecycle — one init function, one destroy function — works for simple plugins. It starts breaking down when plugins depend on each other. If plugin A needs to call a service that plugin B provides, you need a guarantee that B's service is ready before A's init runs. Achieving that guarantee with a single init hook requires the host to solve dependency ordering before calling any hooks, which works but gives plugins no way to participate in the setup sequence.

The multi-phase approach solves this more cleanly. In a setup phase, plugins register their capabilities and declare what they provide. No code is executing yet — plugins are building their service registries. Only once all plugins have set up does the host move to the start phase, where services become active and plugins begin making calls. Each plugin's setup contract is available to other plugins during start. The separation is the point: you cannot call another plugin's live services during setup, because they do not exist yet.

Kibana's three-phase model (setupstartstop) is the canonical example of this. VS Code's event-driven activation takes a different approach — it delays plugin loading entirely until a triggering event occurs, which is even more conservative. Backstage maintains the same lifecycle shape for both browser and server plugins, reducing the cognitive load for developers who work in both environments.

The Lifecycle Interface

Here is a comprehensive lifecycle contract incorporating patterns from all three:

export interface PluginModule {
  /** Optional plugin configuration */
  config?: PluginConfig;
 
  /**
   * Pre-initialisation hook for modifying host configuration.
   * Runs before dependency resolution.
   * Used by: Vendure, Babel
   */
  configure?: (hostConfig: HostConfig) => HostConfig | Promise<HostConfig>;
 
  /**
   * Setup phase — register capabilities, do not start execution.
   * Core services and plugin dependencies are injected here.
   * Used by: Kibana, Backstage
   */
  setup?: (context: PluginSetupContext) => PluginSetupContract | Promise<PluginSetupContract>;
 
  /**
   * Start phase — activate services, begin processing.
   * Setup contracts from dependencies are available here.
   * Used by: Kibana, Backstage
   */
  start?: (context: PluginStartContext) => PluginStartContract | Promise<PluginStartContract>;
 
  /**
   * Init hook for simple plugins that do not need phased setup.
   * Called once when the plugin is loaded.
   */
  init?: (sdk: PluginSDK) => void | Promise<void>;
 
  /**
   * Mount hook for UI plugins.
   * Called when the plugin's UI should appear.
   */
  mount?: (sdk: PluginSDK) => void | Promise<void>;
 
  /**
   * Unmount hook for UI cleanup.
   * Called when the plugin's UI is removed.
   */
  unmount?: () => void | Promise<void>;
 
  /**
   * Stop/shutdown hook. Called in reverse dependency order.
   * Used by: Kibana, Backstage, VS Code
   */
  stop?: () => void | Promise<void>;
}
 
export interface PluginSetupContext {
  /** Core services available during setup */
  core: CoreSetup;
  /** Setup contracts from required plugins */
  plugins: Record<string, unknown>;
  /** Plugin's own configuration */
  config: unknown;
  /** Logger instance scoped to this plugin */
  logger: Logger;
}
 
export interface PluginStartContext {
  /** Core services available at runtime */
  core: CoreStart;
  /** Start contracts from required plugins */
  plugins: Record<string, unknown>;
  /** Access to setup-phase contract (if needed) */
  setup?: unknown;
}

Execution Order

The host executes these phases in a precise order. Understanding the order is important both for plugin authors and for anyone implementing the registry:

1. DISCOVERY
   └─ Scan for plugins
   └─ Parse manifests
   └─ Build dependency graph

2. COMPATIBILITY VALIDATION
   └─ Check version ranges (Vendure pattern)
   └─ Validate required plugins exist
   └─ Detect circular dependencies (Kibana pattern)

3. TOPOLOGICAL SORT
   └─ Order plugins by dependencies
   └─ Ensure dependencies activate first

4. CONFIGURATION PHASE
   └─ Execute configure() hooks sequentially
   └─ Plugins modify host configuration (Vendure pattern)

5. SETUP PHASE
   └─ Execute setup() in dependency order
   └─ Collect setup contracts
   └─ No side effects or active execution yet

6. START PHASE
   └─ Execute start() in dependency order
   └─ Activate services
   └─ Begin processing

7. RUNTIME
   └─ Handle events, requests, user interactions
   └─ Lazy load additional plugins (VS Code pattern)

8. SHUTDOWN
   └─ Execute stop() in REVERSE order
   └─ Dependents stop before dependencies
   └─ Graceful error handling (continue on failure)

Lifecycle Best Practices

Separate configuration from execution. Vendure's configure hook modifies the system before initialisation — adding custom fields, registering strategies, altering other plugins' settings — without triggering any live behaviour. This separation means the system can reach a stable configured state before anything starts executing, making the startup sequence predictable.

Enforce timeouts. Kibana enforces 10-second timeouts for setup and start, and 15 seconds for stop. A hung plugin should not be able to block the entire system indefinitely:

const result = await withTimeout({
  promise: plugin.setup(context),
  timeoutMs: 10_000,
  errorMessage: `Plugin "${id}" setup timeout`,
});

Shut down in reverse order. If plugin A depends on plugin B, A must stop before B. Reversing the startup order guarantees this automatically:

// If Plugin A depends on B, shutdown order must be: A → B
for (let i = plugins.length - 1; i >= 0; i--) {
  await plugins[i].stop();
}

Fail fast during setup, be tolerant during shutdown. A plugin that cannot set up cleanly should block the whole system from starting — the alternative is a running system with unpredictable missing functionality. But during shutdown, a plugin that fails to stop cleanly should be logged and skipped so other plugins can still shut down:

try {
  await plugin.stop();
} catch (error) {
  logger.warn(`Plugin ${id} failed to stop cleanly:`, error);
  // Continue stopping other plugins
}

Make lifecycle hooks idempotent. VS Code's extension host can crash and restart. If a hook is called twice — because the host recovered from a crash, or because a test is re-using a plugin instance — calling it a second time should not corrupt state.

Always return Promise<T> or handle both. All lifecycle hooks should be awaited by the host, even if a specific plugin returns synchronously. Treating all hooks as potentially async prevents subtle bugs when plugins are later updated to perform async work.


4.3 The SDK — The Host's Public API

The PluginSDK is the only interface through which plugins touch the host. Everything a plugin needs — routing, events, storage, authentication, UI services — is accessed through this object. Everything the plugin cannot do is simply not on this object.

This sounds obvious, but the design discipline required to maintain it is not. The temptation to let plugins import host modules directly, or to expose the host's internal state through the SDK, grows as the system matures. Resisting it is what keeps the SDK stable as the host evolves internally.

Dependency Injection vs Service Locator

Before looking at the SDK interface itself, it is worth understanding the two patterns production systems use for service access, because both have real merits.

Dependency injection, as used by Backstage, Kibana, and Vendure, makes dependencies explicit in the plugin's function signature. The host reads the signature, resolves the declared dependencies, and passes them in:

export class MyPlugin {
  setup(core: CoreSetup, plugins: { data: DataSetup }) {
    // Dependencies injected — type-safe and explicit
    plugins.data.search.registerStrategy('custom', new CustomStrategy());
  }
}

The benefit is that every dependency is visible, mockable in tests, and validated at compile time. The cost is more ceremony — a plugin must declare every service it wants to use.

The service locator pattern, used by VS Code, provides a context object with access methods. Plugins pull what they need:

export function activate(context: vscode.ExtensionContext) {
  const config = vscode.workspace.getConfiguration();
  const commands = vscode.commands;
}

This is a simpler API surface and allows plugins to access services they did not know they would need when they were first written. The trade-off is that dependencies are implicit and harder to discover or mock.

In practice, dependency injection is the better choice for backend and server-side plugins where compile-time safety is valuable and the performance overhead of the framework is acceptable. For UI plugins and browser environments, the service locator approach tends to produce simpler, more discoverable APIs. Many systems use both: explicit injection for the setup/start lifecycle, service locator for the runtime SDK.

The SDK Interface

Here is a comprehensive SDK incorporating patterns from production systems:

export interface PluginSDK {
  /** Core system information */
  readonly system: {
    version: string;
    environment: 'development' | 'production' | 'test';
    platform: {
      isServer: boolean;
      isBrowser: boolean;
      isWorker: boolean;
    };
  };
 
  /** Plugin metadata */
  readonly plugin: {
    id: string;
    manifest: PluginManifest;
    config: Record<string, unknown>;
  };
 
  /** Logging service (scoped to plugin) */
  readonly logger: {
    debug: (message: string, meta?: Record<string, unknown>) => void;
    info: (message: string, meta?: Record<string, unknown>) => void;
    warn: (message: string, meta?: Record<string, unknown>) => void;
    error: (message: string, error?: Error, meta?: Record<string, unknown>) => void;
  };
 
  /** Manage routes */
  readonly routes: {
    add: (route: PluginRouteDefinition) => void;
    remove: (path: string) => void;
    navigate: (path: string, options?: NavigateOptions) => void;
  };
 
  /** Manage navigation menus */
  readonly menus: {
    addItem: (item: PluginMenuItem) => void;
    removeItem: (id: string) => void;
    updateItem: (id: string, updates: Partial<PluginMenuItem>) => void;
  };
 
  /** Manage dashboard widgets */
  readonly widgets: {
    add: (widget: PluginWidgetDefinition) => void;
    remove: (id: string) => void;
    update: (id: string, updates: Partial<PluginWidgetDefinition>) => void;
  };
 
  /** Event bus for pub/sub communication */
  readonly events: {
    on: <T = unknown>(event: string, handler: (payload: T) => void) => () => void;
    once: <T = unknown>(event: string, handler: (payload: T) => void) => () => void;
    off: (event: string, handler: (payload?: unknown) => void) => void;
    emit: <T = unknown>(event: string, payload?: T) => void;
  };
 
  /** Shared UI services */
  readonly ui: {
    showModal: (content: unknown, options?: { title?: string; size?: 'sm' | 'md' | 'lg' }) => Promise<void>;
    showToast: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
    showConfirm: (message: string, options?: { title?: string; okText?: string; cancelText?: string }) => Promise<boolean>;
  };
 
  /** Data services */
  readonly data: {
    http: {
      get: <T>(url: string, options?: RequestOptions) => Promise<T>;
      post: <T>(url: string, body: unknown, options?: RequestOptions) => Promise<T>;
      put: <T>(url: string, body: unknown, options?: RequestOptions) => Promise<T>;
      delete: <T>(url: string, options?: RequestOptions) => Promise<T>;
      patch: <T>(url: string, body: unknown, options?: RequestOptions) => Promise<T>;
    };
 
    /** Plugin-scoped storage, inspired by VS Code's tiered storage model */
    storage: {
      /** Cleared on workspace change */
      workspace: {
        get: <T>(key: string) => Promise<T | undefined>;
        set: <T>(key: string, value: T) => Promise<void>;
        remove: (key: string) => Promise<void>;
      };
      /** Persists across workspaces */
      global: {
        get: <T>(key: string) => Promise<T | undefined>;
        set: <T>(key: string, value: T) => Promise<void>;
        remove: (key: string) => Promise<void>;
      };
      /** Secure storage for credentials */
      secrets: {
        get: (key: string) => Promise<string | undefined>;
        set: (key: string, value: string) => Promise<void>;
        remove: (key: string) => Promise<void>;
      };
    };
  };
 
  /** Authentication and authorisation */
  readonly auth: {
    getUser: () => Promise<{ id: string; name: string; email?: string; roles: string[] }>;
    hasRole: (role: string) => boolean;
    hasPermission: (permission: string) => boolean;
  };
 
  /** Extension points for plugin-to-plugin communication */
  readonly extensions: {
    register: <T>(id: string, implementation: T) => void;
    get: <T>(pluginId: string, extensionId: string) => T | undefined;
  };
 
  /** Lifecycle utilities */
  readonly lifecycle: {
    onStartup: (callback: () => void | Promise<void>) => void;
    onShutdown: (callback: () => void | Promise<void>) => void;
  };
}
 
export interface RequestOptions {
  headers?: Record<string, string>;
  params?: Record<string, string | number | boolean>;
  timeout?: number;
  signal?: AbortSignal;
}

The readonly modifier on every top-level property is not cosmetic. It prevents plugins from replacing SDK services with their own implementations — a subtle form of tampering that could otherwise go unnoticed.

Multi-Environment SDK Design

If your host targets multiple environments — browser, server, and worker — the SDK should reflect that. A server plugin has no business calling sdk.ui.showModal, and a browser plugin should not receive database service references. Backstage and Vite both handle this by splitting the base interface:

/** Base SDK available in all environments */
export interface BasePluginSDK {
  system: SystemInfo;
  plugin: PluginInfo;
  logger: Logger;
  events: EventBus;
}
 
/** Browser-specific extensions */
export interface BrowserPluginSDK extends BasePluginSDK {
  ui: UIServices;
  routes: RouteServices;
  dom: DOMServices;
}
 
/** Server-specific extensions */
export interface ServerPluginSDK extends BasePluginSDK {
  http: HTTPServer;
  database: DatabaseService;
  jobs: JobQueueService;
}
 
/** Worker-specific extensions */
export interface WorkerPluginSDK extends BasePluginSDK {
  messaging: MessagePort;
}

Plugins declare their target environment in the manifest, and the host constructs and types the appropriate SDK variant. A plugin that receives BrowserPluginSDK gets a type error if it tries to access database — the mismatch is caught at compile time rather than at runtime in production.

Type-Safe Service References (Backstage)

For the most demanding type-safety requirements, Backstage's service reference pattern lets TypeScript infer service types automatically from dependency declarations, eliminating manual type annotations:

export interface ServiceRef<TService> {
  id: string;
  scope: 'root' | 'plugin';
}
 
export const coreServices = {
  database: createServiceRef<DatabaseService>({
    id: 'core.database',
    scope: 'plugin', // One instance per plugin
  }),
  logger: createServiceRef<LoggerService>({
    id: 'core.logger',
    scope: 'plugin',
  }),
  cache: createServiceRef<CacheService>({
    id: 'core.cache',
    scope: 'root', // Shared across all plugins
  }),
};
 
// Type helper extracts service types from dependency declarations
type DepsToInstances<T> = {
  [K in keyof T]: T[K] extends ServiceRef<infer TService> ? TService : never;
};
 
// Usage — TypeScript infers all types automatically
env.registerInit({
  deps: {
    database: coreServices.database,
    logger: coreServices.logger,
  },
  async init(deps) {
    // deps.database: DatabaseService (inferred)
    // deps.logger: LoggerService (inferred)
  },
});

The scope field determines whether plugins share an instance (root) or each get their own (plugin). Logger is typically plugin-scoped so each plugin's log output can be tagged and filtered independently.


4.4 Plugin Context and Isolation

Every plugin system has a trust model, and that model determines how much isolation is warranted. The right answer depends on who is writing the plugins, what data the host processes, and what the cost of a plugin failure would be.

The Spectrum of Trust

At one end, systems like Vendure, Kibana, and Backstage run all plugin code in the same process as the host, with no runtime enforcement of boundaries. This is the trusted-code model: plugins are first-party code, vetted before deployment, and performance matters more than runtime isolation. Babel and Vite operate the same way — build tools trust the developer's own build configuration. The benefit is maximum performance and the simplest possible implementation. The risk is that a buggy plugin can affect the host directly.

Moving along the spectrum, Backstage introduces logical isolation without runtime enforcement. Each plugin gets a namespaced HTTP router, database table prefix, and configuration scope. Plugins cannot accidentally collide, but the boundary is a convention rather than a hard wall:

// Each plugin gets a scoped router
router.get(`/api/${pluginId}/data`, handler);
 
// Database tables are namespaced by convention
const table = `${pluginId}_records`;

At the other end, VS Code runs each extension in a separate OS process with all communication over an RPC channel. This is the maximum isolation model. An extension crash does not crash the editor. Extensions cannot reach into each other's memory. The overhead — spawning processes, serialising API calls — is real, but at 40,000 extensions the guarantee is worth it. For web environments, VS Code web extensions run in a Web Worker, which provides the same logical isolation without OS process overhead.

IFrame sandboxing, as used by Beekeeper Studio, sits between Web Workers and process isolation in terms of security guarantee and implementation complexity. The browser's sandbox attribute restricts what the iframe can do:

const frame = document.createElement('iframe');
frame.sandbox = 'allow-scripts';
frame.src = pluginUrl;
 
window.addEventListener('message', (event) => {
  if (event.source === frame.contentWindow) {
    // Handle plugin message
  }
});

The practical rule of thumb: if you know and control who writes the plugins, run them in-process. If third parties write plugins and those plugins will process sensitive data or run in an environment where a crash is costly, invest in isolation.

Permission Systems

For sandboxed environments, granular permissions let the host enforce the declared capabilities at runtime:

export interface PluginPermissions {
  /** Network access */
  network?: {
    allowedDomains?: string[];
    unrestricted?: boolean;
  };
 
  /** File system access (Node.js environments) */
  fileSystem?: {
    read?: string[];
    write?: string[];
  };
 
  /** Storage quotas */
  storage?: {
    maxBytes?: number;
  };
 
  /** Compute limits */
  compute?: {
    maxExecutionTime?: number;
    maxMemory?: number;
  };
}
 
class SandboxedPluginRunner {
  async execute(plugin: Plugin, operation: () => Promise<void>) {
    const permissions = plugin.manifest.permissions;
 
    const timeout = permissions?.compute?.maxExecutionTime ?? 5000;
    await withTimeout(operation, timeout);
 
    if (permissions?.compute?.maxMemory) {
      const usage = process.memoryUsage().heapUsed;
      if (usage > permissions.compute.maxMemory) {
        throw new Error('Plugin exceeded memory limit');
      }
    }
  }
}

Content Security Policy

For plugins that render web UI, a Content Security Policy is the browser-native mechanism for enforcing what content a plugin can load and where it can connect:

export interface PluginWebviewConfig {
  csp?: {
    scriptSrc?: string[];
    styleSrc?: string[];
    imgSrc?: string[];
    connectSrc?: string[];
  };
}
 
const webview = createWebview({
  csp: {
    scriptSrc: ['self', 'https://cdn.example.com'],
    connectSrc: ['self', 'https://api.example.com'],
  },
});

Regardless of the isolation level chosen, the SDK remains the only bridge between plugin code and the host environment. Even fully trusted plugins should access host capabilities through the SDK — maintaining that discipline is what allows the host to evolve its internals without breaking plugins.


4.5 Contract Evolution and Versioning

The hardest constraint any plugin SDK lives under is backward compatibility. Once third-party plugins are built against your interfaces, a breaking change costs real developer time to fix across an ecosystem you do not control. The type system is your most powerful tool for managing this over time.

Adding to the SDK Without Breaking Plugins

The safest way to extend the SDK is to add optional fields. Existing plugins do not need to handle them; new plugins can take advantage of them:

export interface PluginSDK {
  // Existing APIs — unchanged
  routes: RouteService;
  events: EventBus;
 
  // New API — optional for backward compatibility
  notifications?: NotificationService;
 
  // Deprecated API — marked for removal in the next major version
  /** @deprecated Use routes instead */
  router?: LegacyRouter;
}

The @deprecated JSDoc tag appears in IDE autocomplete and IDE diagnostics, giving plugin authors a clear signal to migrate before the API is removed. Combined with a lint rule that flags usage of deprecated APIs, you can drive ecosystem migration without forcing it.

API Proposals for Experimental Features

VS Code uses an explicit opt-in mechanism for experimental APIs. A plugin declares which experimental APIs it wants access to in its manifest, and the host only exposes them to plugins that have asked:

export interface PluginManifest {
  enabledApiProposals?: string[];
}
 
// Host validates at activation
if (plugin.manifest.enabledApiProposals?.includes('newFeature')) {
  sdk.experimental.newFeature; // Allowed
} else {
  sdk.experimental.newFeature; // Type error — not available
}

This is a clean way to ship features that are not yet stable. Plugin authors who want to try the experimental API opt in knowingly, accepting that the API might change.

Version Negotiation Across Major Versions

When a breaking change is unavoidable, version negotiation lets old and new plugins coexist during a transition:

export interface DataPluginV1 {
  search(query: string): Promise<Result[]>;
}
 
export interface DataPluginV2 {
  search(request: SearchRequest): Promise<SearchResponse>;
}
 
function getDataPlugin(version: '1' | '2'): DataPluginV1 | DataPluginV2 {
  // Return the appropriate version based on what the requesting plugin declared
}

The host reads the requesting plugin's declared SDK version from its manifest and provides the appropriate interface. Old plugins keep working. New plugins use the new interface. Both coexist until the old version can be retired.

Contract-Based Type Safety (Kibana / Backstage)

Beyond the SDK itself, plugins can expose typed contracts to each other through the setup and start phases. This is how complex dependency graphs stay type-safe even as plugins evolve independently:

// Plugin defines its own contract
export interface DataSetup {
  search: {
    registerStrategy: (name: string, strategy: SearchStrategy) => void;
  };
}
 
export interface DataStart {
  search: {
    search: <T>(request: SearchRequest) => Promise<SearchResponse<T>>;
  };
}
 
// Host enforces the contract at compile time
export class MyPlugin {
  setup(
    core: CoreSetup,
    plugins: { data: DataSetup }
  ): MySetup {
    plugins.data.search.registerStrategy('custom', new CustomStrategy());
    return { /* my setup contract */ };
  }
 
  start(
    core: CoreStart,
    plugins: { data: DataStart }
  ): MyStart {
    const results = await plugins.data.search.search(request);
    return { /* my start contract */ };
  }
}

The pattern forces plugin authors to be explicit about what they expose and what they consume. When a plugin's contract changes, TypeScript surfaces every consumer that needs to update — before anything is deployed.

A Few Practices Worth Keeping

Keep the core SDK surface small. Kibana's core services interface has roughly fifteen methods; everything else lives in namespaced extensions. This makes the core stable and easy to document thoroughly.

Use readonly on all SDK properties. It prevents plugins from substituting their own implementations for SDK services — a category of bug that is easy to introduce accidentally and hard to diagnose.

Write TSDoc comments on every SDK method. The comments appear in plugin authors' IDE autocomplete and are often their primary documentation:

/**
 * Register a new route in the application.
 *
 * @param route - Route definition including path, component, and metadata
 * @throws {Error} If route path conflicts with an existing route
 * @example
 * ```ts
 * sdk.routes.add({
 *   path: '/my-plugin/dashboard',
 *   component: Dashboard,
 * });
 * ```
 */
add(route: PluginRouteDefinition): void;

Provide type guards to help plugins narrow types safely at runtime:

export interface PluginError {
  type: 'validation' | 'network' | 'permission';
  message: string;
}
 
export function isValidationError(
  error: PluginError
): error is PluginError & { type: 'validation' } {
  return error.type === 'validation';
}

Conclusion

The manifest, lifecycle, and SDK are not three separate design problems — they are three aspects of the same constraint: how to give plugins enough capability to be useful while maintaining enough control to keep the host stable and secure.

The manifest makes it possible to reason about plugins before running them. The lifecycle contract makes it possible to compose plugins with complex dependencies without race conditions or use-after-free errors. The SDK makes it possible to evolve the host's internals without breaking every plugin that was ever written against it. Together, they are the layer everything else in the system rests on.

Production systems reinforce this. VS Code's extension ecosystem scaled to 40,000 extensions because the contribution model in the manifest is well-designed. Kibana handles complex plugin dependency graphs reliably because its multi-phase lifecycle enforces clean ordering. Backstage's service reference pattern keeps inter-plugin dependencies type-safe across teams that have never met.

In the next chapter, we will look at the plugin registry — the runtime machinery that uses these contracts to discover, load, and manage plugins throughout their lifecycle.

Was this helpful?
Web Loom logo
Copyright © Web Loom. All rights reserved.