Eine Plugin-Architektur in TypeScript aufbauen
So entwirfst du ein Plugin-System, das deine Anwendung ohne Kernänderungen erweitert: Schnittstellen, Lifecycle-Hooks, Abhängigkeiten und Sandboxing.

Eine Plugin-Architektur ermöglicht es Nutzern, das Verhalten deiner Anwendung zu erweitern, ohne ihren Quellcode zu verändern. Denk an VS-Code-Erweiterungen, Webpack-Plugins, Babel-Transforms oder ESLint-Regeln. Die Kernanwendung definiert Erweiterungspunkte — klar definierte Schnittstellen, an denen sich Plugins einklinken können — und Plugins implementieren diese Schnittstellen, um Funktionalität hinzuzufügen.
Die Designherausforderung besteht darin, die richtige Balance zu finden: flexibel genug für vielfältige Anwendungsfälle, aber eingeschränkt genug, um zu verhindern, dass Plugins die Host-Anwendung beschädigen.
Die Plugin-Schnittstelle definieren
Die Plugin-Schnittstelle ist ein Vertrag zwischen der Kernanwendung und ihren Plugins. Sie legt fest, was Plugins tun können, welche Daten sie erhalten und welche Lifecycle-Methoden sie implementieren können.
// 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.Der Plugin-Manager
Der Plugin-Manager übernimmt Registrierung, Lifecycle-Management, Abhängigkeitsauflösung und Hook-Ausführung. Er ist der Orchestrator zwischen der Kernanwendung und ihren 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-Ausführungspipeline
Plugins erweitern das Verhalten über Hooks — Funktionen, die an bestimmten Stellen der Verarbeitungspipeline der Anwendung aufgerufen werden. Hooks können synchron oder asynchron sein und die Daten beim Durchlauf transformieren.
// 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;
}Beispiel-Plugins
Hier sind konkrete Plugin-Implementierungen, die das Muster in der Praxis zeigen.
// 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');
});
},
};Die wichtigsten Erkenntnisse
- Definiere eine klare Plugin-Schnittstelle — der Vertrag zwischen Kern und Plugins legt Lifecycle-Hooks, verfügbare APIs und Datenstrukturen fest
- Schotte den Plugin-Zugriff in einer Sandbox ab — stelle Logger mit eigenem Namespace, isolierten Speicher und gefilterte Events bereit, statt direkten Zugriff auf Anwendungsinterna zu gewähren
- Löse Abhängigkeiten per topologischer Sortierung auf — erkenne zirkuläre Abhängigkeiten bereits bei der Registrierung, nicht zur Laufzeit
- Nutze Waterfall-Hooks für Datentransformation — jedes Plugin erhält die Ausgabe des vorherigen und baut so eine komponierbare Pipeline
- Behandle Plugin-Fehler graceful — ein abstürzendes Plugin sollte die Host-Anwendung nicht mitreißen; loggen, überspringen, weiterlaufen
- Friere die Konfiguration ein und grenze den Speicher ab — verhindere, dass Plugins den Zustand anderer Plugins oder die globale Konfiguration verändern


