Building a Plugin Architecture in TypeScript
How to design a plugin system that lets users extend your application without touching its core: plugin interfaces, lifecycle hooks, resolution, sandboxing.

A plugin architecture lets users extend your application's behavior without modifying its source code. Think VS Code extensions, Webpack plugins, Babel transforms, or ESLint rules. The core application defines extension points — well-defined interfaces where plugins can hook in — and plugins implement those interfaces to add functionality.
The design challenge is finding the right balance: flexible enough to support diverse use cases, constrained enough to prevent plugins from breaking the host application.
Defining the Plugin Interface
The plugin interface is a contract between the core application and its plugins. It specifies what plugins can do, what data they receive, and what lifecycle methods they can implement.
// The core plugin interface — every plugin must implement this
interface Plugin {
name: string;
version: string;
dependencies?: string[]; // Other plugins this one requires
// Lifecycle hooks
onInit?(context: PluginContext): Promise<void> | void;
onBeforeProcess?(data: ProcessInput): Promise<ProcessInput> | ProcessInput;
onAfterProcess?(data: ProcessOutput): Promise<ProcessOutput> | ProcessOutput;
onDestroy?(): Promise<void> | void;
}
// Context provided to plugins — their window into the host application
interface PluginContext {
config: Readonly<Record<string, unknown>>;
logger: PluginLogger;
storage: PluginStorage;
events: PluginEventEmitter;
}
interface PluginLogger {
info(message: string, data?: Record<string, unknown>): void;
warn(message: string, data?: Record<string, unknown>): void;
error(message: string, data?: Record<string, unknown>): void;
}
interface PluginStorage {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
}
interface PluginEventEmitter {
on(event: string, handler: (...args: unknown[]) => void): void;
emit(event: string, ...args: unknown[]): void;
}// ❌ Giving plugins direct access to application internals
interface BadPluginContext {
database: DatabaseConnection; // Can drop tables
httpServer: HttpServer; // Can add uncontrolled routes
processEnv: NodeJS.ProcessEnv; // Can read secrets
}
// ✅ Providing a scoped, sandboxed API surface
// Plugins get a logger (namespaced), a key-value store (isolated per plugin),
// and an event bus (filtered to allowed events). Nothing else.The Plugin Manager
The plugin manager handles registration, lifecycle management, dependency resolution, and hook execution. It is the orchestrator between the core app and its plugins.
class PluginManager {
private plugins: Map<string, Plugin> = new Map();
private initialized: Set<string> = new Set();
private contexts: Map<string, PluginContext> = new Map();
register(plugin: Plugin): void {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin "${plugin.name}" is already registered`);
}
this.plugins.set(plugin.name, plugin);
}
async initializeAll(): Promise<void> {
// Resolve initialization order based on dependencies
const order = this.resolveDependencyOrder();
for (const pluginName of order) {
await this.initializePlugin(pluginName);
}
}
private async initializePlugin(name: string): Promise<void> {
if (this.initialized.has(name)) return;
const plugin = this.plugins.get(name);
if (!plugin) throw new Error(`Plugin "${name}" not found`);
// Verify dependencies are initialized
for (const dep of plugin.dependencies ?? []) {
if (!this.initialized.has(dep)) {
throw new Error(
`Plugin "${name}" depends on "${dep}" which is not initialized`
);
}
}
// Create sandboxed context for this plugin
const context = this.createContext(name);
this.contexts.set(name, context);
if (plugin.onInit) {
await plugin.onInit(context);
}
this.initialized.add(name);
console.log(`Plugin "${name}" v${plugin.version} initialized`);
}
private createContext(pluginName: string): PluginContext {
return {
config: Object.freeze(this.getPluginConfig(pluginName)),
logger: this.createScopedLogger(pluginName),
storage: this.createScopedStorage(pluginName),
events: this.createScopedEventEmitter(pluginName),
};
}
private getPluginConfig(name: string): Record<string, unknown> {
return {}; // Load from configuration source
}
private createScopedLogger(name: string): PluginLogger {
return {
info: (msg, data) => console.log(`[${name}] INFO: ${msg}`, data ?? ''),
warn: (msg, data) => console.warn(`[${name}] WARN: ${msg}`, data ?? ''),
error: (msg, data) => console.error(`[${name}] ERROR: ${msg}`, data ?? ''),
};
}
private createScopedStorage(name: string): PluginStorage {
const store = new Map<string, unknown>();
return {
get: async <T>(key: string) => store.get(`${name}:${key}`) as T | undefined,
set: async <T>(key: string, value: T) => { store.set(`${name}:${key}`, value); },
delete: async (key: string) => { store.delete(`${name}:${key}`); },
};
}
private createScopedEventEmitter(name: string): PluginEventEmitter {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
on: (event, handler) => {
if (!handlers.has(event)) handlers.set(event, []);
handlers.get(event)!.push(handler);
},
emit: (event, ...args) => {
for (const handler of handlers.get(event) ?? []) {
handler(...args);
}
},
};
}
// Topological sort for dependency resolution
private resolveDependencyOrder(): string[] {
const visited = new Set<string>();
const order: string[] = [];
const visit = (name: string, stack: Set<string>) => {
if (stack.has(name)) {
throw new Error(`Circular dependency detected involving "${name}"`);
}
if (visited.has(name)) return;
stack.add(name);
const plugin = this.plugins.get(name);
for (const dep of plugin?.dependencies ?? []) {
visit(dep, stack);
}
stack.delete(name);
visited.add(name);
order.push(name);
};
for (const name of this.plugins.keys()) {
visit(name, new Set());
}
return order;
}
}Hook Execution Pipeline
Plugins extend behavior through hooks — functions called at specific points in the application's processing pipeline. Hooks can be synchronous or async, and they can transform data as it passes through.
// Waterfall hooks: each plugin transforms the data, passing it to the next
async function executeWaterfallHook<T>(
plugins: Plugin[],
hookName: 'onBeforeProcess' | 'onAfterProcess',
initialData: T
): Promise<T> {
let data = initialData;
for (const plugin of plugins) {
const hook = plugin[hookName] as
| ((data: T) => Promise<T> | T)
| undefined;
if (hook) {
try {
data = await hook(data);
} catch (error) {
console.error(
`Plugin "${plugin.name}" threw in ${hookName}:`,
error
);
// Decision: skip this plugin and continue, or abort?
// Typically: log and continue for non-critical hooks
}
}
}
return data;
}
// Usage in the core application
async function processData(input: ProcessInput): Promise<ProcessOutput> {
// Pre-processing hooks — plugins can modify the input
const processed = await executeWaterfallHook(
activePlugins,
'onBeforeProcess',
input
);
// Core processing logic
const result = await coreProcess(processed);
// Post-processing hooks — plugins can modify the output
const finalResult = await executeWaterfallHook(
activePlugins,
'onAfterProcess',
result
);
return finalResult;
}Example Plugins
Here are concrete plugin implementations showing the pattern in practice.
// Plugin 1: Adds timing metrics to every processed item
const timingPlugin: Plugin = {
name: 'timing-metrics',
version: '1.0.0',
onBeforeProcess(data: ProcessInput): ProcessInput {
return { ...data, _startTime: Date.now() };
},
onAfterProcess(data: ProcessOutput): ProcessOutput {
const startTime = (data as any)._startTime;
const duration = Date.now() - startTime;
return { ...data, processingTimeMs: duration };
},
};
// Plugin 2: Validates input data before processing
const validationPlugin: Plugin = {
name: 'input-validation',
version: '1.0.0',
dependencies: [], // No dependencies
onBeforeProcess(data: ProcessInput): ProcessInput {
if (!data.id) {
throw new Error('Validation failed: missing required field "id"');
}
if (typeof data.payload !== 'object') {
throw new Error('Validation failed: payload must be an object');
}
return data;
},
};
// Plugin 3: Caches results to avoid reprocessing
const cachingPlugin: Plugin = {
name: 'result-cache',
version: '1.0.0',
async onInit(context: PluginContext): Promise<void> {
context.logger.info('Cache plugin initialized');
},
async onBeforeProcess(data: ProcessInput): Promise<ProcessInput> {
// Check if result is already cached
return data; // Pass through — cache check happens via context.storage
},
async onAfterProcess(data: ProcessOutput): Promise<ProcessOutput> {
// Store result in cache for future lookups
return data;
},
};
// Register and initialize
const manager = new PluginManager();
manager.register(validationPlugin);
manager.register(timingPlugin);
manager.register(cachingPlugin);
await manager.initializeAll();// ❌ Plugin that modifies global state
const dangerousPlugin: Plugin = {
name: 'dangerous',
version: '1.0.0',
onInit() {
// Modifying prototypes, globals, or shared state
(Array.prototype as any).myMethod = () => {}; // NO!
process.env.SECRET = 'exposed'; // NO!
},
};
// ✅ Plugin that operates only through its provided context
const safePlugin: Plugin = {
name: 'safe',
version: '1.0.0',
async onInit(context: PluginContext) {
// Uses only the APIs provided through context
context.logger.info('Initializing');
await context.storage.set('lastInit', new Date().toISOString());
context.events.on('data.processed', () => {
context.logger.info('Processing completed');
});
},
};Key Takeaways
- Define a clear plugin interface — the contract between core and plugins specifies lifecycle hooks, available APIs, and data shapes
- Sandbox plugin access — provide scoped loggers, isolated storage, and filtered events instead of direct access to application internals
- Resolve dependencies with topological sort — detect circular dependencies at registration time, not at runtime
- Use waterfall hooks for data transformation — each plugin receives the output of the previous one, building a composable pipeline
- Handle plugin errors gracefully — a crashing plugin should not take down the host application; log, skip, and continue
- Freeze configuration and scope storage — prevent plugins from modifying each other's state or the global configuration


