The Plugin SDK: Designing Developer-Friendly APIs
Create SDKs and APIs that make plugin development easy and robust.
Chapter 7: The Plugin SDK — Designing Developer-Friendly APIs
The previous chapter solved the technical problem of getting plugin code into memory. A module arrives, its dependencies resolve, and its factory function runs. But then what? The plugin needs to actually do something useful — add a route, subscribe to an event, display a notification — and the question of how it does that cleanly is what this chapter is about.
The SDK is the answer. It is the single, curated interface through which every plugin interacts with the host application. Everything a plugin can do, it does through the SDK. Everything it cannot do, it simply has no access to. If the registry is the control tower and the loader is the runway, the SDK is the cockpit: it gives plugin developers everything they need to fly without letting them tamper with the engines.
What separates a good SDK from a bad one is rarely technical capability. Badly designed SDKs can usually do everything a well-designed one can. The difference is ergonomics — how much friction the SDK introduces for the developer who writes against it every day. A great SDK is one where the right thing to do is also the easiest thing to do, where type errors surface misuse before it reaches production, and where the error message when something goes wrong actually explains the problem.
This chapter draws on the real-world patterns from Babel, VS Code, Backstage, Kibana, Vendure, and NocoBase. These systems have each solved a piece of the SDK design puzzle, and together they paint a complete picture.
7.1 SDK Architecture Overview
The SDK occupies a peculiar position in the architecture: it must be simultaneously restrictive and expressive. Restrictive, because the host cannot allow plugins to reach into arbitrary internals — that path leads to tight coupling, security vulnerabilities, and upgrade nightmares. Expressive, because a plugin that cannot do interesting things is useless. The SDK design challenge is drawing that boundary thoughtfully.
Design Principles
Six principles, distilled from the systems studied, guide this balance.
The first is minimal core with extensible modules. Keep the core SDK small — only the capabilities that every plugin needs. New features arrive as opt-in domain modules, not by expanding the core. Babel demonstrates why this matters: its plugin API is essentially just visitor, pre, and post. That minimal surface has supported thousands of transformation use cases for over a decade. A kitchen-sink API, by contrast, becomes a maintenance burden and a cognitive wall.
The second principle is progressive disclosure. Group related APIs under nested namespaces — sdk.routes, sdk.menus, sdk.services — so that a developer picking up the SDK for the first time can discover what is available through IDE autocomplete rather than by reading a hundred-page reference. VS Code organises its extension API into logical namespaces (vscode.window, vscode.workspace, vscode.languages) and this single decision has made that enormous API approachable.
Third: backwards compatibility as a hard constraint. Once an API is published and plugins are compiled against it, you own it. Never remove or change the signature of an existing method without a migration path. Backstage's service references and extension points are designed around TypeScript interfaces that can grow through optional properties; plugins compiled against an older interface continue working as new methods are added. That is interface extension — adding without breaking — rather than modification.
Fourth: framework neutrality. The SDK should not know whether the host renders with React, Vue, or Svelte. UI component types should be typed as unknown at the core level, with framework-specific adapters providing the concrete types. Kibana's dual-environment architecture shows how the same plugin patterns can work symmetrically across the server (Node.js) and the browser without the core SDK encoding either environment's assumptions.
Fifth: declarative over imperative. The SDK should let plugin authors declare what they want to extend, not implement the mechanics of how that extension integrates. Babel's visitor pattern is the canonical example — a plugin says "I care about ArrowFunctionExpression nodes" and Babel handles traversal, scoping, and execution order entirely. Your SDK should take responsibility for the integration plumbing so that plugin authors can focus on their unique logic.
Sixth: rich context objects that encapsulate complexity. Rather than handing plugin authors raw primitives, provide context objects that bundle related information and operations together. Babel's Path object wraps an AST node with its parent relationships, scope information, and a full suite of transformation methods. Vendure's Injector gives strategy implementations access to the entire dependency injection container. These abstractions make common tasks trivial and guide developers towards idiomatic usage without prescribing every detail.
7.2 Core SDK Components
The full PluginSDK interface reflects these principles. Every capability is namespaced by domain, every method is typed, and the surface is deliberately limited to what plugins genuinely need:
export interface PluginSDK {
/** Access to app-wide routing */
routes: {
add: (route: PluginRouteDefinition) => void;
remove: (path: string) => void;
};
/** Access to navigation menus */
menus: {
addItem: (item: PluginMenuItem) => void;
removeItem: (label: string) => void;
};
/** Access to dashboard widgets */
widgets: {
add: (widget: PluginWidgetDefinition) => void;
remove: (id: string) => void;
};
/** Publish/subscribe event bus */
events: {
on: (event: string, handler: (payload?: unknown) => void) => void;
off: (event: string, handler: (payload?: unknown) => void) => void;
emit: (event: string, payload?: unknown) => void;
};
/** Shared UI services */
ui: {
showModal: (content: unknown, options?: { title?: string }) => void;
showToast: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
};
/** Shared app services */
services: {
apiClient: {
get: <T>(url: string, params?: Record<string, unknown>) => Promise<T>;
post: <T>(url: string, body: unknown) => Promise<T>;
};
auth: {
getUser: () => Promise<{ id: string; name: string; roles: string[] }>;
hasRole: (role: string) => boolean;
};
storage: {
get: <T>(key: string) => T | undefined;
set: <T>(key: string, value: T) => void;
remove: (key: string) => void;
};
};
/** Plugin metadata */
plugin: {
id: string;
manifest: PluginManifest;
};
}The UI contribution APIs — routes, menus, widgets — cover the most common integration points. The events namespace provides a framework-neutral pub/sub mechanism so plugins can communicate with the host and with each other without importing each other's code directly. ui.showModal and ui.showToast give plugins access to host-styled UI components without coupling them to the host's internal component tree. The services namespace provides typed access to the HTTP layer, the authentication context, and isolated key-value storage. And plugin.manifest lets a plugin read its own metadata — useful for feature flags or conditional behaviour based on declared capabilities.
The services.storage namespace deserves a note: each plugin's storage should be automatically namespaced by plugin ID, so that two plugins using the same key string do not overwrite each other. This is the kind of detail that, if handled in the SDK implementation rather than documented as a developer responsibility, prevents an entire category of subtle bugs.
7.3 Developer Experience Features
A functional SDK is the floor, not the ceiling. The systems that have built durable plugin ecosystems — VS Code in particular — have invested heavily in the experience of using the SDK, not just its capabilities.
TypeScript Integration
Full typing is non-negotiable. Every method, every parameter, every return type should be expressed in TypeScript so that the IDE can catch misuse before the code runs. But good typing goes beyond preventing errors — it communicates intent. A well-typed SDK teaches developers how to use it through autocomplete suggestions and inline documentation.
Backstage's DepsToInstances pattern takes this further. When a plugin declares its dependencies as typed service references, the system infers the concrete types automatically:
// Backstage's DepsToInstances pattern
env.registerInit({
deps: {
database: coreServices.database,
logger: coreServices.logger,
},
async init(deps) {
// deps.database: DatabaseService (automatically typed!)
// deps.logger: LoggerService (automatically typed!)
}
});The developer never writes a type annotation. The types are inferred from the service references, which are themselves typed values. The ceremony disappears, but the safety remains. This is what good TypeScript design looks like: zero boilerplate, full coverage.
IntelliSense and Auto-completion
Namespacing APIs under nested objects (sdk.routes.add, sdk.services.auth.hasRole) serves a purpose beyond organisation — it makes the SDK navigable through autocomplete. A developer who does not know whether menu items live under menus or navigation types sdk. and reads the list. VS Code treats its TypeScript definitions as first-class documentation: every API method includes a description, parameter explanations with examples, return value documentation, and links to relevant guides. JSDoc comments in the SDK source appear directly in IDE tooltips, which means the documentation is always exactly where the developer is looking.
Error Messages and Debugging
Validation at the SDK boundary, with descriptive errors in development mode, pays dividends far beyond what the effort costs. Consider what Babel does when a plugin name is misspelled:
Error: Cannot find plugin 'arrow-functions'
- Did you mean "@vendor/plugin-transform-arrow-functions"?
- Did you accidentally pass a preset as a plugin?
- Check your plugin configuration for typos
The system tries likely alternatives and surfaces them. That error message eliminates a debugging session that might otherwise take twenty minutes. Your SDK methods should validate their inputs and produce similarly actionable errors — not just "invalid argument" but "expected a route definition with a path and component property; received an object with path but no component."
VS Code extends this thinking to runtime errors. Activation failures are logged with full stack traces and surfaced to the user with actionable messages. Errors thrown inside a plugin's event handler are caught and logged without propagating to other plugins or crashing the host. The principle is that no single plugin should be able to compromise the system — and the SDK's error boundary design is what makes that guarantee possible.
7.4 Plugin Development Workflow
The SDK itself is only part of what makes a plugin ecosystem productive. The surrounding toolchain — the scaffolding, the local development loop, the testing setup — determines how quickly plugin authors can go from idea to working code.
CLI Tools and Scaffolding
A plugin scaffold generator removes the barrier to entry. A new plugin author should not need to understand the manifest format, the module entry point conventions, or the build configuration before writing their first line of business logic:
plugin-cli create my-pluginA well-designed generator produces a manifest.json with every required field pre-populated, an index.ts that implements the lifecycle interface correctly, and at least one working example — a route registration, a menu item, a widget — so that the author has something running immediately. The example code is what actually teaches the SDK patterns; it is more valuable than documentation in most cases.
Local Development Server
Plugin development without fast feedback is painful. A local development server should load the plugin under development into a sandboxed preview of the host, with hot module replacement so that changes are reflected instantly. The host application used for preview should be a realistic representation — same routes, same services, same theme — so that the plugin author is not surprised when their plugin is deployed to a real installation.
The sandboxing here is important not just for isolation but for confidence: if the plugin can break the preview host, the author discovers that early, not in production.
Testing Framework Integration
The mock host pattern is the most valuable testing tool for plugin authors. A mock host provides the full PluginSDK interface with in-memory implementations of every service, so that the plugin's setup and start functions can be exercised in unit tests without a real browser or a running server:
import { createMockSDK } from '@vendor/plugin-testing';
test('analytics plugin registers its dashboard route', async () => {
const sdk = createMockSDK();
await analyticsPlugin.setup(sdk);
expect(sdk.routes.add).toHaveBeenCalledWith(
expect.objectContaining({ path: '/analytics' })
);
});End-to-end tests with Playwright against a real host instance cover the integration scenarios that unit tests miss, but they are slower and should be reserved for the critical paths. Unit tests with a mock SDK should cover the plugin's registration logic and any business logic that does not depend on the real host environment.
Build and Package Management
An official build configuration for plugins is a meaningful act of curation. If you leave plugin authors to configure Vite, Webpack, or Rollup from scratch, you will get as many configurations as you have plugins, each with slightly different externals handling, slightly different TypeScript settings, and slightly different output formats. Providing a createPluginConfig() function that wraps the build tool of choice and correctly externalises the SDK, handles source maps, and produces the expected output format eliminates an entire category of packaging problems before they appear.
7.5 Advanced SDK Patterns
Beyond the core interface, several patterns from production systems are worth adopting wholesale.
The Factory Pattern for SDK Construction
Rather than exporting the SDK directly, Babel wraps plugin authoring in a factory function that receives an API object:
export function createPlugin(factory: (api: PluginAPI) => PluginObject) {
return factory;
}
// Plugin author uses it:
export default createPlugin((api) => {
// api.cache enables intelligent rebuild optimisation
// api.types provides AST builders without imports
// api.assertVersion ensures compatibility
return {
name: 'my-plugin',
visitor: {
// transformation logic
}
};
});This indirection is subtle but powerful. The factory receives an api object that can polyfill newer features for older host versions, or restrict capabilities based on declared compatibility. New API methods can be added to the api object without touching the plugin's exported factory function. The host controls what the plugin sees, not the other way around.
Service References as First-Class Values
Backstage eliminates string-based service lookups entirely by treating service references as typed values:
export interface ServiceRef<TService> {
id: string;
scope: 'root' | 'plugin';
$$type: '@app/ServiceRef';
}
export const coreServices = {
database: createServiceRef<DatabaseService>({
id: 'core.database',
scope: 'plugin', // One instance per plugin
}),
logger: createServiceRef<LoggerService>({
id: 'core.logger',
scope: 'plugin',
}),
};
// Usage with automatic type inference:
sdk.inject({
deps: {
db: coreServices.database,
log: coreServices.logger,
},
handler: ({ db, log }) => {
// db: DatabaseService (fully typed!)
// log: LoggerService (fully typed!)
}
});When a plugin declares db: coreServices.database, TypeScript infers from the ServiceRef<DatabaseService> type that deps.db is a DatabaseService. There are no string lookups, no casts, no as anywhere. The types flow from the service definition through to the usage site automatically.
Extension Points for Controlled Extensibility
Backstage's extension point pattern solves the problem of plugin-to-plugin extensibility without creating direct dependencies between plugins. A plugin defines an extension point — a typed interface — and other modules can contribute to it without the original plugin knowing anything about them:
// Core plugin defines extension point
export interface SearchExtensionPoint {
addIndexer(indexer: SearchIndexer): void;
addRanker(ranker: SearchRanker): void;
}
export const searchExtensionPoint =
createExtensionPoint<SearchExtensionPoint>({
id: 'search.extension',
});
// Module extends the plugin
export const githubSearchModule = createModule({
pluginId: 'search',
moduleId: 'github',
register(env) {
env.registerInit({
deps: {
search: searchExtensionPoint, // Dependency on extension point!
},
async init({ search }) {
search.addIndexer(new GithubRepoIndexer());
search.addRanker(new GithubStarRanker());
}
});
}
});The search plugin controls what is extensible by defining the SearchExtensionPoint interface. The GitHub module controls how it extends the search plugin by implementing that interface. Neither knows about the other's internals. This is exactly the kind of architecture that makes it possible to install and remove plugins independently without cascading failures.
Strategy Pattern for Business Logic
Vendure handles complex customisation points through strategy interfaces rather than events. The difference matters: an event listener receives a notification and can react to it, but a strategy owns a step in a business process:
// Define strategy interface
export interface PaymentStrategy {
readonly id: string;
init?(injector: Injector): Promise<void>;
createPayment(context: PaymentContext): Promise<Payment>;
capturePayment(paymentId: string): Promise<void>;
refundPayment(paymentId: string, amount: number): Promise<void>;
destroy?(): Promise<void>;
}
// Register via configuration
sdk.config.payment.strategies.push(new StripePaymentStrategy());
// The system calls the appropriate strategy based on context
const payment = await paymentService.create(order);
// This internally resolves and calls the registered strategyThe strategy interface defines a clear contract: these are the methods you must implement, this is when they will be called, this is what they must return. Testing a strategy implementation is straightforward because you can construct it directly and call its methods with mock arguments. Events, by contrast, are fire-and-forget — testing that a listener receives the right events requires more infrastructure and catches fewer integration problems.
Database-Driven Configuration
For applications that require runtime reconfiguration without redeployment, NocoBase persists plugin state in the database rather than in code or configuration files:
// Plugin state in database
interface PluginState {
name: string;
enabled: boolean;
installed: boolean;
version: string;
options: Record<string, unknown>;
}
// Enable/disable plugins at runtime
await pluginManager.enable('analytics', {
trackingId: 'UA-12345',
sampleRate: 0.1
});
// Changes persist and sync across all app instancesThis enables multi-tenant plugin configurations, hot-swapping plugins without a server restart (if the architecture supports it), audit trails for plugin changes, and UI-driven plugin management. The trade-off is complexity: you need a database, a synchronisation mechanism, and careful thought about what happens when the database is unavailable. For low-code platforms and SaaS products where non-technical users need to control plugin behaviour, the trade-off is worthwhile.
7.6 API Versioning and Evolution
Even the most carefully designed SDK will need to change. The question is not whether you will need to add new capabilities or fix design mistakes, but how you manage those changes without breaking the plugins that have already been built.
Semantic Versioning Strategy
The SDK package should follow SemVer strictly: patches for bug fixes, minor versions for backwards-compatible additions, major versions for breaking changes. Breaking changes should be rare, announced months in advance, and accompanied by detailed migration guides. Vendure makes the compatibility requirement explicit in the plugin declaration itself:
@VendurePlugin({
compatibility: '^3.0.0', // Works with any 3.x version
})
export class MyPlugin {}The system validates this at load time and rejects incompatible plugins with a clear error message that tells the plugin author exactly what version constraint was violated. This is far better than discovering incompatibility through a runtime crash.
Deprecation Policies
The lifecycle for retiring a deprecated API has three phases. First, mark the method with @deprecated in TypeScript and redirect to the replacement:
/**
* @deprecated Use `sdk.storage.set()` instead. Will be removed in v4.0.0
*/
sdk.localStorage.save = (key: string, value: unknown) => {
console.warn('sdk.localStorage.save is deprecated. Use sdk.storage.set instead.');
return sdk.storage.set(key, value);
};The @deprecated tag causes IDEs to strike through the method name and display the migration message in tooltips, which means most developers encounter the warning before they even run their code. The console.warn catches anyone who missed the IDE signal. Only in the subsequent major version does the method disappear entirely.
Migration Tools
When a deprecation involves a mechanical change — a renamed method, a reordered parameter, a restructured options object — provide an automated codemod. Babel uses this pattern for its own breaking changes:
npx @vendor/plugin-migrate upgrade --from=3.0 --to=4.0A codemod that handles 80% of the migration automatically transforms a painful upgrade into a routine task. The remaining 20% that requires human judgement should be documented clearly, with before-and-after examples for each case.
API Proposals for Experimental Features
VS Code's proposal system is worth studying closely. Experimental APIs require explicit opt-in in the plugin manifest:
// package.json
{
"enabledApiProposals": [
"fileSearchProvider",
"textSearchProvider"
]
}A plugin cannot access a proposed API without declaring it. This allows the SDK to test new designs with early adopters, gather feedback before the API stabilises, and make breaking changes to proposals without triggering a major version bump. When a proposal is promoted to stable, it graduates from the proposal list and becomes available without declaration. The asymmetry — proposals are opt-in, stable APIs are always available — creates a clean signal about what is safe to rely on.
Breaking Change Management
When a major change is unavoidable, the dual-API pattern buys time for the ecosystem to migrate:
// Support both old and new signatures during transition period
function addRoute(
pathOrOptions: string | RouteOptions,
component?: ComponentType
): void {
if (typeof pathOrOptions === 'string') {
// Old API (deprecated)
console.warn('String path is deprecated. Use RouteOptions object.');
this.registerRoute({ path: pathOrOptions, component: component! });
} else {
// New API
this.registerRoute(pathOrOptions);
}
}Both signatures work. Both produce the same result. The old one logs a warning. Give the ecosystem three to six months with both versions available before removing the deprecated signature in the next major release.
7.7 SDK Design Comparison: Learning from Production Systems
The systems studied each approach SDK design from a different angle, and the differences are instructive:
| Aspect | Babel | VS Code | Backstage | Kibana | Vendure | NocoBase | |--------|-------|---------|-----------|--------|---------|----------| | API Style | Declarative (visitors) | Imperative (event handlers) | Dependency Injection | Dual DI (client/server) | Decorator-based | Application-scoped | | Type Safety | Full TypeScript | Full TypeScript | Full TypeScript | Full TypeScript | Full TypeScript | TypeScript | | Extension Model | Visitor pattern | Contribution points | Extension points | Contracts | Strategies | Resources + Events | | Dependency Resolution | None (single-file) | Explicit dependencies | Service references | Topological sort | NestJS DI | Topological sort | | API Evolution | Factory pattern | Proposal system | Interface extension | Contract versioning | Compatibility ranges | Event-driven | | Error Handling | Fail-fast with suggestions | Multi-level isolation | Graceful degradation | Phase-specific | Trust-based validation | Transactional state | | Configuration | Code-based | Declarative manifest | YAML + TypeScript | YAML schemas | Code (TypeScript) | Database-driven | | Best For | AST transformations | IDE extensions | Developer portals | Data visualisation | E-commerce | Low-code platforms |
The declarative model (Babel's visitor pattern) suits domains where plugin authors need to express what to transform without owning the traversal logic — AST manipulation, schema validation, template processing. The contribution points model (VS Code) suits applications with many discrete extension targets — commands, menus, views, language features — where the extension metadata needs to be machine-readable for things like the extension marketplace. Extension points (Backstage) are the right tool when plugins need to expose stable APIs to other plugins: the extension point interface is the contract, and the system mediates access to it. Strategies (Vendure) work best when the customisation involves owning an entire step in a business workflow, because the interface definition makes the required methods explicit and testing is straightforward. Events (NocoBase) are the simplest model and the right choice for cross-cutting concerns — audit trails, notifications, reactive workflows — where the publisher should not know who is listening.
Most real applications need more than one of these. A platform might use contribution points for UI extensions, strategies for business logic customisation, and events for cross-plugin communication. The question is which model to reach for first when a new extension requirement arrives.
Conclusion
The SDK is where the architecture becomes the experience. Everything discussed in previous chapters — the manifest, the lifecycle contracts, the registry, the loader — was infrastructure. The SDK is what plugin authors actually touch every day, and its design quality determines whether building on the platform feels like a gift or a chore.
The recurring theme across the systems studied is that the best SDKs are opinionated. They do not expose everything; they expose the right things. They use TypeScript not just for type checking but as a documentation medium. They fail loudly in development and gracefully in production. They plan for their own obsolescence by building deprecation and migration into the process from the start.
In the next chapter, we will look at how plugins communicate with each other and with the host through an event-driven architecture — the mechanism that makes it possible for a plugin to react to something that happened in another plugin it has never imported.