Cómo construir una arquitectura de plugins en TypeScript
Cómo diseñar un sistema de plugins que extienda tu aplicación sin tocar su núcleo: interfaces, hooks de ciclo de vida, resolución de dependencias y sandboxing.

Una arquitectura de plugins permite a los usuarios extender el comportamiento de tu aplicación sin modificar su código fuente. Piensa en las extensiones de VS Code, los plugins de Webpack, las transformaciones de Babel o las reglas de ESLint. La aplicación central define puntos de extensión — interfaces bien definidas donde los plugins pueden conectarse — y los plugins implementan esas interfaces para añadir funcionalidad.
El reto de diseño es encontrar el equilibrio adecuado: lo suficientemente flexible para soportar casos de uso diversos, y lo suficientemente restringido para evitar que los plugins rompan la aplicación anfitriona.
Definición de la interfaz del plugin
La interfaz del plugin es un contrato entre la aplicación central y sus plugins. Especifica qué pueden hacer los plugins, qué datos reciben y qué métodos de ciclo de vida pueden implementar.
// 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.El gestor de plugins
El gestor de plugins se encarga del registro, la gestión del ciclo de vida, la resolución de dependencias y la ejecución de hooks. Es el orquestador entre la aplicación central y sus 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;
}
}Pipeline de ejecución de hooks
Los plugins extienden el comportamiento mediante hooks — funciones llamadas en puntos específicos del pipeline de procesamiento de la aplicación. Los hooks pueden ser síncronos o asíncronos, y pueden transformar los datos a medida que pasan por ellos.
// 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;
}Plugins de ejemplo
Aquí hay implementaciones concretas de plugins que muestran el patrón en la práctica.
// 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');
});
},
};Conclusiones clave
- Define una interfaz de plugin clara — el contrato entre el núcleo y los plugins especifica los hooks de ciclo de vida, las APIs disponibles y las formas de los datos
- Aísla el acceso de los plugins en un sandbox — proporciona loggers con ámbito, almacenamiento aislado y eventos filtrados en lugar de acceso directo a los internos de la aplicación
- Resuelve las dependencias con ordenación topológica — detecta las dependencias circulares en el momento del registro, no en tiempo de ejecución
- Usa hooks en cascada para la transformación de datos — cada plugin recibe la salida del anterior, construyendo un pipeline componible
- Maneja los errores de los plugins con elegancia — un plugin que falla no debería tumbar la aplicación anfitriona; registra el error, omítelo y continúa
- Congela la configuración y delimita el almacenamiento — evita que los plugins modifiquen el estado de otros plugins o la configuración global


