Building a Plugin Architecture in JavaScript
Building a Plugin Architecture in JavaScript
How to design extensible systems where features can be added without modifying core — patterns like hooks, middleware chains, and event buses with real Next.js/Node.js examples.
Why Plugin Architecture Matters
At some point, every growing codebase faces the same tension: you need to add features, but modifying core code is risky, expensive, and creates merge conflicts. Plugin architecture solves this by inverting the relationship — instead of core knowing about features, features register themselves with core.
WITHOUT PLUGINS: WITH PLUGINS:
─────────────────────────── ───────────────────────────
┌─────────────────────────┐ ┌─────────────────────────┐
│ CORE │ │ CORE │
│ │ │ │
│ if (featureA) { ... } │ │ for (plugin of plugins)│
│ if (featureB) { ... } │ │ plugin.execute() │
│ if (featureC) { ... } │ │ │
│ // grows forever │ │ // never changes │
└─────────────────────────┘ └─────────────────────────┘
▲ ▲ ▲
│ │ │
┌────────┘ │ └────────┐
│ │ │
┌─────┴───┐ ┌────┴────┐ ┌───┴─────┐
│Plugin A │ │Plugin B │ │Plugin C │
└─────────┘ └─────────┘ └─────────┘
This post covers five plugin patterns, each suited to different scenarios:
- Hook System — Point-in-time callbacks
- Middleware Chain — Sequential transformation pipeline
- Event Bus — Decoupled pub/sub communication
- Slot-based Architecture — UI composition points
- Module Federation — Runtime code loading
Pattern 1: Hook System
Hooks are named extension points where plugins can register callbacks. Core defines when hooks fire; plugins define what happens.
Basic Hook Implementation
// core/hooks.ts
type HookCallback<T = any> = (context: T) => T | void | Promise<T | void>;
class HookSystem {
private hooks = new Map<string, Set<HookCallback>>();
/**
* Register a callback for a hook
*/
register<T>(hookName: string, callback: HookCallback<T>): () => void {
if (!this.hooks.has(hookName)) {
this.hooks.set(hookName, new Set());
}
this.hooks.get(hookName)!.add(callback);
// Return unregister function
return () => {
this.hooks.get(hookName)?.delete(callback);
};
}
/**
* Execute all callbacks for a hook (waterfall - each transforms context)
*/
async call<T>(hookName: string, context: T): Promise<T> {
const callbacks = this.hooks.get(hookName);
if (!callbacks) return context;
let result = context;
for (const callback of callbacks) {
const returned = await callback(result);
if (returned !== undefined) {
result = returned;
}
}
return result;
}
/**
* Execute all callbacks in parallel (no transformation)
*/
async callParallel<T>(hookName: string, context: T): Promise<void> {
const callbacks = this.hooks.get(hookName);
if (!callbacks) return;
await Promise.all(
Array.from(callbacks).map(callback => callback(context))
);
}
}
export const hooks = new HookSystem();
Using Hooks in a Next.js API
// core/api-handler.ts
import { hooks } from './hooks';
import { NextRequest, NextResponse } from 'next/server';
interface RequestContext {
request: NextRequest;
params: Record<string, string>;
user?: User;
response?: any;
error?: Error;
}
export function createApiHandler(
handler: (ctx: RequestContext) => Promise<any>
) {
return async (request: NextRequest, { params }: { params: Record<string, string> }) => {
let context: RequestContext = { request, params };
try {
// Hook: Before request processing
context = await hooks.call('api:beforeRequest', context);
// Hook: Authentication
context = await hooks.call('api:authenticate', context);
// Core handler
context.response = await handler(context);
// Hook: After successful response
context = await hooks.call('api:afterResponse', context);
return NextResponse.json(context.response);
} catch (error) {
context.error = error as Error;
// Hook: Error handling
context = await hooks.call('api:onError', context);
return NextResponse.json(
{ error: context.error.message },
{ status: 500 }
);
} finally {
// Hook: Cleanup (logging, metrics, etc.)
await hooks.callParallel('api:finally', context);
}
};
}
Plugin Examples
// plugins/auth-plugin.ts
import { hooks } from '../core/hooks';
import { verifyToken } from '../lib/jwt';
export function registerAuthPlugin() {
hooks.register('api:authenticate', async (context) => {
const authHeader = context.request.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.slice(7);
try {
context.user = await verifyToken(token);
} catch {
// Invalid token - user remains undefined
}
}
return context;
});
}
// plugins/logging-plugin.ts
import { hooks } from '../core/hooks';
export function registerLoggingPlugin() {
hooks.register('api:beforeRequest', (context) => {
(context as any).startTime = Date.now();
console.log(`[${new Date().toISOString()}] ${context.request.method} ${context.request.url}`);
return context;
});
hooks.register('api:finally', (context) => {
const duration = Date.now() - (context as any).startTime;
const status = context.error ? 'ERROR' : 'OK';
console.log(`[${status}] ${context.request.method} ${context.request.url} - ${duration}ms`);
});
}
// plugins/rate-limit-plugin.ts
import { hooks } from '../core/hooks';
import { RateLimiter } from '../lib/rate-limiter';
const limiter = new RateLimiter({ maxRequests: 100, windowMs: 60000 });
export function registerRateLimitPlugin() {
hooks.register('api:beforeRequest', async (context) => {
const ip = context.request.headers.get('x-forwarded-for') || 'unknown';
const allowed = await limiter.check(ip);
if (!allowed) {
throw new Error('Rate limit exceeded');
}
return context;
});
}
Plugin Registration
// app/api/init.ts
import { registerAuthPlugin } from '../plugins/auth-plugin';
import { registerLoggingPlugin } from '../plugins/logging-plugin';
import { registerRateLimitPlugin } from '../plugins/rate-limit-plugin';
// Register all plugins at app startup
export function initializePlugins() {
registerLoggingPlugin(); // Runs first
registerRateLimitPlugin(); // Runs second
registerAuthPlugin(); // Runs third
console.log('All plugins registered');
}
// Call once in your app entry point
initializePlugins();
Typed Hooks with Full Type Safety
// core/typed-hooks.ts
// Define all hooks and their context types
interface HookDefinitions {
'api:beforeRequest': RequestContext;
'api:authenticate': RequestContext;
'api:afterResponse': RequestContext;
'api:onError': RequestContext;
'api:finally': RequestContext;
'user:created': { user: User };
'user:deleted': { userId: string };
'cache:invalidate': { keys: string[] };
}
class TypedHookSystem {
private hooks = new Map<string, Set<Function>>();
register<K extends keyof HookDefinitions>(
hookName: K,
callback: (context: HookDefinitions[K]) => HookDefinitions[K] | void | Promise<HookDefinitions[K] | void>
): () => void {
if (!this.hooks.has(hookName)) {
this.hooks.set(hookName, new Set());
}
this.hooks.get(hookName)!.add(callback);
return () => this.hooks.get(hookName)?.delete(callback);
}
async call<K extends keyof HookDefinitions>(
hookName: K,
context: HookDefinitions[K]
): Promise<HookDefinitions[K]> {
const callbacks = this.hooks.get(hookName);
if (!callbacks) return context;
let result = context;
for (const callback of callbacks) {
const returned = await callback(result);
if (returned !== undefined) {
result = returned;
}
}
return result;
}
}
export const hooks = new TypedHookSystem();
// Now hooks are fully typed:
hooks.register('user:created', (ctx) => {
console.log(ctx.user.name); // ✓ TypeScript knows ctx.user exists
});
hooks.register('api:beforeRequest', (ctx) => {
console.log(ctx.user.name); // ✗ Error: user might be undefined
});
Pattern 2: Middleware Chain
Middleware is a pipeline where each function can transform input, short-circuit, or pass to the next. Express popularized this pattern; it's perfect for request/response processing.
The Middleware Pattern
Request → [Middleware 1] → [Middleware 2] → [Middleware 3] → Handler
│ │ │
│ │ └── Can transform/pass
│ └── Can transform/short-circuit
└── Can transform/short-circuit
Response ← [Middleware 1] ← [Middleware 2] ← [Middleware 3] ← Handler
│ │ │
└──────────── Each can transform response ──────────┘
Implementing Middleware Chain
// core/middleware.ts
type NextFunction = () => Promise<void>;
type Middleware<TContext> = (
context: TContext,
next: NextFunction
) => Promise<void> | void;
class MiddlewareChain<TContext> {
private middlewares: Middleware<TContext>[] = [];
use(middleware: Middleware<TContext>): this {
this.middlewares.push(middleware);
return this;
}
async execute(context: TContext): Promise<TContext> {
let index = 0;
const next: NextFunction = async () => {
if (index < this.middlewares.length) {
const middleware = this.middlewares[index];
index++;
await middleware(context, next);
}
};
await next();
return context;
}
}
export function createMiddlewareChain<TContext>() {
return new MiddlewareChain<TContext>();
}
Express-style Next.js Middleware
// core/api-middleware.ts
import { NextRequest, NextResponse } from 'next/server';
interface ApiContext {
req: NextRequest;
res: {
status: number;
body: any;
headers: Record<string, string>;
};
user?: User;
locals: Record<string, any>;
}
type ApiMiddleware = (
ctx: ApiContext,
next: () => Promise<void>
) => Promise<void>;
export function createApiRoute(...middlewares: ApiMiddleware[]) {
return async (req: NextRequest) => {
const ctx: ApiContext = {
req,
res: { status: 200, body: null, headers: {} },
locals: {},
};
let index = 0;
const next = async () => {
if (index < middlewares.length) {
const middleware = middlewares[index++];
await middleware(ctx, next);
}
};
try {
await next();
} catch (error) {
ctx.res.status = 500;
ctx.res.body = { error: (error as Error).message };
}
return NextResponse.json(ctx.res.body, {
status: ctx.res.status,
headers: ctx.res.headers,
});
};
}
Building Middleware Stack
// middleware/auth.ts
import { ApiMiddleware } from '../core/api-middleware';
import { verifyToken } from '../lib/jwt';
export const authMiddleware: ApiMiddleware = async (ctx, next) => {
const token = ctx.req.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
ctx.res.status = 401;
ctx.res.body = { error: 'Unauthorized' };
return; // Short-circuit - don't call next()
}
try {
ctx.user = await verifyToken(token);
await next(); // Continue to next middleware
} catch {
ctx.res.status = 401;
ctx.res.body = { error: 'Invalid token' };
}
};
// middleware/validate.ts
import { z } from 'zod';
import { ApiMiddleware } from '../core/api-middleware';
export function validateBody<T extends z.ZodSchema>(schema: T): ApiMiddleware {
return async (ctx, next) => {
try {
const body = await ctx.req.json();
ctx.locals.body = schema.parse(body);
await next();
} catch (error) {
ctx.res.status = 400;
ctx.res.body = { error: 'Validation failed', details: error };
}
};
}
// middleware/cache.ts
import { ApiMiddleware } from '../core/api-middleware';
import { redis } from '../lib/redis';
export function cacheMiddleware(ttlSeconds: number): ApiMiddleware {
return async (ctx, next) => {
const cacheKey = `api:${ctx.req.url}`;
const cached = await redis.get(cacheKey);
if (cached) {
ctx.res.body = JSON.parse(cached);
ctx.res.headers['X-Cache'] = 'HIT';
return; // Short-circuit
}
await next();
// Cache the response after handler completes
if (ctx.res.status === 200) {
await redis.setex(cacheKey, ttlSeconds, JSON.stringify(ctx.res.body));
ctx.res.headers['X-Cache'] = 'MISS';
}
};
}
// middleware/timing.ts
export const timingMiddleware: ApiMiddleware = async (ctx, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
ctx.res.headers['X-Response-Time'] = `${duration}ms`;
};
Composing Route Handlers
// app/api/users/route.ts
import { createApiRoute } from '@/core/api-middleware';
import { authMiddleware } from '@/middleware/auth';
import { validateBody } from '@/middleware/validate';
import { cacheMiddleware } from '@/middleware/cache';
import { timingMiddleware } from '@/middleware/timing';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
// GET /api/users - with caching
export const GET = createApiRoute(
timingMiddleware,
authMiddleware,
cacheMiddleware(60),
async (ctx, next) => {
const users = await db.users.findMany();
ctx.res.body = users;
}
);
// POST /api/users - with validation
export const POST = createApiRoute(
timingMiddleware,
authMiddleware,
validateBody(createUserSchema),
async (ctx, next) => {
const { name, email } = ctx.locals.body;
const user = await db.users.create({ data: { name, email } });
ctx.res.status = 201;
ctx.res.body = user;
}
);
Koa-style Onion Middleware
The "onion" model allows middleware to do work both before AND after the handler:
// core/onion-middleware.ts
type OnionMiddleware<T> = (ctx: T, next: () => Promise<void>) => Promise<void>;
async function compose<T>(
middlewares: OnionMiddleware<T>[],
context: T
): Promise<void> {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) {
throw new Error('next() called multiple times');
}
index = i;
const middleware = middlewares[i];
if (!middleware) return;
await middleware(context, () => dispatch(i + 1));
}
await dispatch(0);
}
// Visualization of onion execution:
//
// middleware1 (before) →
// middleware2 (before) →
// middleware3 (before) →
// handler
// ← middleware3 (after)
// ← middleware2 (after)
// ← middleware1 (after)
// Example: Request timing + error handling
const errorHandler: OnionMiddleware<ApiContext> = async (ctx, next) => {
try {
await next(); // Run everything inside
} catch (error) {
// Catch errors from any downstream middleware/handler
ctx.res.status = 500;
ctx.res.body = { error: (error as Error).message };
}
};
const timing: OnionMiddleware<ApiContext> = async (ctx, next) => {
const start = Date.now();
await next(); // Run handler
// This runs AFTER handler completes
const duration = Date.now() - start;
ctx.res.headers['X-Response-Time'] = `${duration}ms`;
};
// Order matters:
// errorHandler wraps everything → timing wraps handler → handler runs
compose([errorHandler, timing, handler], ctx);
Pattern 3: Event Bus
Event buses enable fully decoupled communication. Publishers don't know about subscribers; subscribers don't know about publishers.
Implementing an Event Bus
// core/event-bus.ts
type EventCallback<T = any> = (payload: T) => void | Promise<void>;
interface EventDefinitions {
// Define your events and their payloads
'user:created': { user: User };
'user:updated': { user: User; changes: Partial<User> };
'user:deleted': { userId: string };
'order:placed': { order: Order };
'order:shipped': { orderId: string; trackingNumber: string };
'cache:invalidate': { patterns: string[] };
'email:send': { to: string; subject: string; body: string };
}
class TypedEventBus {
private listeners = new Map<string, Set<EventCallback>>();
private onceListeners = new Map<string, Set<EventCallback>>();
on<K extends keyof EventDefinitions>(
event: K,
callback: EventCallback<EventDefinitions[K]>
): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
return () => this.off(event, callback);
}
once<K extends keyof EventDefinitions>(
event: K,
callback: EventCallback<EventDefinitions[K]>
): () => void {
if (!this.onceListeners.has(event)) {
this.onceListeners.set(event, new Set());
}
this.onceListeners.get(event)!.add(callback);
return () => this.onceListeners.get(event)?.delete(callback);
}
off<K extends keyof EventDefinitions>(
event: K,
callback: EventCallback<EventDefinitions[K]>
): void {
this.listeners.get(event)?.delete(callback);
this.onceListeners.get(event)?.delete(callback);
}
async emit<K extends keyof EventDefinitions>(
event: K,
payload: EventDefinitions[K]
): Promise<void> {
const callbacks = this.listeners.get(event) || new Set();
const onceCallbacks = this.onceListeners.get(event) || new Set();
// Clear once listeners before executing
this.onceListeners.delete(event);
const allCallbacks = [...callbacks, ...onceCallbacks];
await Promise.all(
allCallbacks.map(callback => callback(payload))
);
}
// Emit without waiting for handlers
emitAsync<K extends keyof EventDefinitions>(
event: K,
payload: EventDefinitions[K]
): void {
this.emit(event, payload).catch(console.error);
}
}
export const eventBus = new TypedEventBus();
Event-Driven Architecture in Node.js
// services/user-service.ts
import { eventBus } from '../core/event-bus';
import { db } from '../lib/database';
export class UserService {
async createUser(data: CreateUserInput): Promise<User> {
const user = await db.users.create({ data });
// Emit event - service doesn't know who's listening
eventBus.emitAsync('user:created', { user });
return user;
}
async updateUser(id: string, changes: Partial<User>): Promise<User> {
const user = await db.users.update({
where: { id },
data: changes,
});
eventBus.emitAsync('user:updated', { user, changes });
return user;
}
async deleteUser(id: string): Promise<void> {
await db.users.delete({ where: { id } });
eventBus.emitAsync('user:deleted', { userId: id });
}
}
Plugin Subscribers
// plugins/email-plugin.ts
import { eventBus } from '../core/event-bus';
import { sendEmail } from '../lib/email';
export function registerEmailPlugin() {
eventBus.on('user:created', async ({ user }) => {
await sendEmail({
to: user.email,
subject: 'Welcome!',
template: 'welcome',
data: { name: user.name },
});
});
eventBus.on('order:shipped', async ({ orderId, trackingNumber }) => {
const order = await db.orders.findUnique({
where: { id: orderId },
include: { user: true },
});
await sendEmail({
to: order.user.email,
subject: 'Your order has shipped!',
template: 'order-shipped',
data: { trackingNumber },
});
});
}
// plugins/analytics-plugin.ts
import { eventBus } from '../core/event-bus';
import { analytics } from '../lib/analytics';
export function registerAnalyticsPlugin() {
eventBus.on('user:created', ({ user }) => {
analytics.track('User Created', {
userId: user.id,
source: user.source,
});
});
eventBus.on('order:placed', ({ order }) => {
analytics.track('Order Placed', {
orderId: order.id,
total: order.total,
items: order.items.length,
});
});
}
// plugins/cache-plugin.ts
import { eventBus } from '../core/event-bus';
import { redis } from '../lib/redis';
export function registerCachePlugin() {
eventBus.on('user:updated', async ({ user }) => {
await redis.del(`user:${user.id}`);
await redis.del('users:list');
});
eventBus.on('user:deleted', async ({ userId }) => {
await redis.del(`user:${userId}`);
await redis.del('users:list');
});
eventBus.on('cache:invalidate', async ({ patterns }) => {
for (const pattern of patterns) {
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
}
});
}
// plugins/audit-log-plugin.ts
import { eventBus } from '../core/event-bus';
import { db } from '../lib/database';
export function registerAuditLogPlugin() {
const auditEvents = ['user:created', 'user:updated', 'user:deleted', 'order:placed'];
for (const event of auditEvents) {
eventBus.on(event as any, async (payload) => {
await db.auditLogs.create({
data: {
event,
payload: JSON.stringify(payload),
timestamp: new Date(),
},
});
});
}
}
Event Bus with Priority and Filtering
// core/advanced-event-bus.ts
interface ListenerOptions {
priority?: number; // Higher runs first
filter?: (payload: any) => boolean; // Only run if filter returns true
}
interface Listener {
callback: EventCallback;
priority: number;
filter?: (payload: any) => boolean;
}
class AdvancedEventBus {
private listeners = new Map<string, Listener[]>();
on<K extends keyof EventDefinitions>(
event: K,
callback: EventCallback<EventDefinitions[K]>,
options: ListenerOptions = {}
): () => void {
const { priority = 0, filter } = options;
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
const listeners = this.listeners.get(event)!;
const listener: Listener = { callback, priority, filter };
listeners.push(listener);
listeners.sort((a, b) => b.priority - a.priority);
return () => {
const index = listeners.indexOf(listener);
if (index > -1) listeners.splice(index, 1);
};
}
async emit<K extends keyof EventDefinitions>(
event: K,
payload: EventDefinitions[K]
): Promise<void> {
const listeners = this.listeners.get(event) || [];
for (const listener of listeners) {
if (listener.filter && !listener.filter(payload)) {
continue;
}
await listener.callback(payload);
}
}
}
// Usage with priority and filtering:
eventBus.on(
'order:placed',
async ({ order }) => {
// Only for high-value orders
await notifyVIPTeam(order);
},
{
priority: 10, // Runs before other handlers
filter: ({ order }) => order.total > 1000,
}
);
Pattern 4: Slot-based Architecture
Slots define UI insertion points where plugins can contribute components. Popular in frameworks like Vue (slots) and WordPress (widget areas).
Implementing UI Slots
// core/slots.tsx
import React, { createContext, useContext, useState, useCallback } from 'react';
type SlotComponent = React.ComponentType<any>;
interface SlotRegistration {
component: SlotComponent;
priority: number;
props?: Record<string, any>;
}
interface SlotContextValue {
register: (slotName: string, registration: SlotRegistration) => () => void;
getComponents: (slotName: string) => SlotRegistration[];
}
const SlotContext = createContext<SlotContextValue | null>(null);
export function SlotProvider({ children }: { children: React.ReactNode }) {
const [slots, setSlots] = useState<Map<string, SlotRegistration[]>>(new Map());
const register = useCallback((slotName: string, registration: SlotRegistration) => {
setSlots(prev => {
const next = new Map(prev);
const existing = next.get(slotName) || [];
const updated = [...existing, registration].sort((a, b) => b.priority - a.priority);
next.set(slotName, updated);
return next;
});
// Return unregister function
return () => {
setSlots(prev => {
const next = new Map(prev);
const existing = next.get(slotName) || [];
next.set(slotName, existing.filter(r => r !== registration));
return next;
});
};
}, []);
const getComponents = useCallback((slotName: string) => {
return slots.get(slotName) || [];
}, [slots]);
return (
<SlotContext.Provider value={{ register, getComponents }}>
{children}
</SlotContext.Provider>
);
}
export function useSlotRegistration() {
const context = useContext(SlotContext);
if (!context) throw new Error('useSlotRegistration must be used within SlotProvider');
return context.register;
}
// The Slot component renders all registered components
export function Slot({
name,
fallback = null,
...slotProps
}: {
name: string;
fallback?: React.ReactNode;
[key: string]: any;
}) {
const context = useContext(SlotContext);
if (!context) throw new Error('Slot must be used within SlotProvider');
const registrations = context.getComponents(name);
if (registrations.length === 0) {
return <>{fallback}</>;
}
return (
<>
{registrations.map((registration, index) => {
const Component = registration.component;
return (
<Component
key={index}
{...slotProps}
{...registration.props}
/>
);
})}
</>
);
}
Defining Slots in Layout
// app/layout.tsx
import { SlotProvider, Slot } from '@/core/slots';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<SlotProvider>
<header>
<nav>
<Slot name="header:logo" />
<Slot name="header:nav" />
<Slot name="header:actions" />
</nav>
</header>
<aside>
<Slot name="sidebar:top" />
<Slot name="sidebar:main" />
<Slot name="sidebar:bottom" />
</aside>
<main>{children}</main>
<footer>
<Slot name="footer:links" />
<Slot name="footer:copyright" />
</footer>
{/* Global overlays */}
<Slot name="global:modals" />
<Slot name="global:toasts" />
</SlotProvider>
</body>
</html>
);
}
Plugin Components
// plugins/notifications-plugin.tsx
'use client';
import { useEffect } from 'react';
import { useSlotRegistration } from '@/core/slots';
import { Bell } from 'lucide-react';
function NotificationBell() {
const [count, setCount] = useState(0);
useEffect(() => {
// Subscribe to notifications
const unsubscribe = notificationService.subscribe(setCount);
return unsubscribe;
}, []);
return (
<button className="relative">
<Bell />
{count > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white rounded-full w-5 h-5 text-xs">
{count}
</span>
)}
</button>
);
}
function NotificationToast() {
const [toasts, setToasts] = useState<Toast[]>([]);
// Toast management logic...
return (
<div className="fixed bottom-4 right-4 space-y-2">
{toasts.map(toast => (
<div key={toast.id} className="bg-white shadow-lg rounded-lg p-4">
{toast.message}
</div>
))}
</div>
);
}
export function NotificationsPlugin() {
const register = useSlotRegistration();
useEffect(() => {
const unregisterBell = register('header:actions', {
component: NotificationBell,
priority: 10,
});
const unregisterToasts = register('global:toasts', {
component: NotificationToast,
priority: 0,
});
return () => {
unregisterBell();
unregisterToasts();
};
}, [register]);
return null; // This component just registers slots
}
// plugins/search-plugin.tsx
function GlobalSearch() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>
<Search className="w-5 h-5" />
<span className="ml-2">Search</span>
<kbd className="ml-4 text-xs bg-gray-100 px-2 py-1 rounded">⌘K</kbd>
</button>
{isOpen && <SearchModal onClose={() => setIsOpen(false)} />}
</>
);
}
export function SearchPlugin() {
const register = useSlotRegistration();
useEffect(() => {
return register('header:actions', {
component: GlobalSearch,
priority: 20, // Higher priority = renders first
});
}, [register]);
return null;
}
Loading Plugins
// app/providers.tsx
'use client';
import { SlotProvider } from '@/core/slots';
import { NotificationsPlugin } from '@/plugins/notifications-plugin';
import { SearchPlugin } from '@/plugins/search-plugin';
import { UserMenuPlugin } from '@/plugins/user-menu-plugin';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SlotProvider>
{/* Load all plugins */}
<NotificationsPlugin />
<SearchPlugin />
<UserMenuPlugin />
{children}
</SlotProvider>
);
}
Pattern 5: Module Federation / Dynamic Plugins
For truly decoupled plugins that can be developed and deployed independently, use dynamic imports or Module Federation.
Dynamic Plugin Loading
// core/plugin-loader.ts
interface PluginManifest {
name: string;
version: string;
entry: string;
slots?: string[];
hooks?: string[];
}
interface LoadedPlugin {
manifest: PluginManifest;
module: any;
unload: () => void;
}
class PluginLoader {
private plugins = new Map<string, LoadedPlugin>();
async loadPlugin(manifestUrl: string): Promise<LoadedPlugin> {
// Fetch plugin manifest
const response = await fetch(manifestUrl);
const manifest: PluginManifest = await response.json();
if (this.plugins.has(manifest.name)) {
throw new Error(`Plugin ${manifest.name} already loaded`);
}
// Dynamically import the plugin module
const module = await import(/* webpackIgnore: true */ manifest.entry);
// Initialize plugin
if (module.initialize) {
await module.initialize();
}
const plugin: LoadedPlugin = {
manifest,
module,
unload: () => {
if (module.cleanup) {
module.cleanup();
}
this.plugins.delete(manifest.name);
},
};
this.plugins.set(manifest.name, plugin);
return plugin;
}
async loadPluginsFromRegistry(registryUrl: string): Promise<void> {
const response = await fetch(registryUrl);
const { plugins }: { plugins: string[] } = await response.json();
await Promise.all(
plugins.map(url => this.loadPlugin(url).catch(console.error))
);
}
getPlugin(name: string): LoadedPlugin | undefined {
return this.plugins.get(name);
}
listPlugins(): PluginManifest[] {
return Array.from(this.plugins.values()).map(p => p.manifest);
}
}
export const pluginLoader = new PluginLoader();
Plugin Structure
// External plugin package: @company/analytics-plugin
// manifest.json
{
"name": "@company/analytics-plugin",
"version": "1.0.0",
"entry": "https://cdn.company.com/plugins/analytics/index.js",
"hooks": ["api:afterResponse", "user:created"],
"slots": ["footer:links"]
}
// index.ts
import { hooks } from '@host/core/hooks';
import { eventBus } from '@host/core/event-bus';
import { registerSlot } from '@host/core/slots';
let cleanupFunctions: Array<() => void> = [];
export function initialize() {
// Register hook handlers
cleanupFunctions.push(
hooks.register('api:afterResponse', (ctx) => {
analytics.trackApiCall(ctx);
return ctx;
})
);
// Register event listeners
cleanupFunctions.push(
eventBus.on('user:created', ({ user }) => {
analytics.identify(user.id, { email: user.email });
})
);
// Register UI components
cleanupFunctions.push(
registerSlot('footer:links', {
component: AnalyticsLink,
priority: 0,
})
);
console.log('Analytics plugin initialized');
}
export function cleanup() {
cleanupFunctions.forEach(fn => fn());
cleanupFunctions = [];
console.log('Analytics plugin cleaned up');
}
function AnalyticsLink() {
return (
<a href="/analytics" className="text-sm text-gray-500">
View Analytics
</a>
);
}
Webpack Module Federation
For micro-frontend style plugins:
// webpack.config.js (Host Application)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
// Plugins loaded at runtime
analyticsPlugin: 'analytics@https://cdn.example.com/analytics/remoteEntry.js',
paymentsPlugin: 'payments@https://cdn.example.com/payments/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
};
// webpack.config.js (Plugin - Analytics)
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'analytics',
filename: 'remoteEntry.js',
exposes: {
'./Plugin': './src/plugin',
'./AnalyticsDashboard': './src/components/Dashboard',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
};
// Host application loading federated plugins
const AnalyticsPlugin = React.lazy(() => import('analyticsPlugin/Plugin'));
const PaymentsPlugin = React.lazy(() => import('paymentsPlugin/Plugin'));
function App() {
return (
<SlotProvider>
<Suspense fallback={<div>Loading plugins...</div>}>
<AnalyticsPlugin />
<PaymentsPlugin />
</Suspense>
<MainApplication />
</SlotProvider>
);
}
Combining Patterns
Real applications often combine multiple patterns:
// A complete plugin system combining hooks, events, and slots
// core/plugin-system.ts
import { hooks, HookSystem } from './hooks';
import { eventBus, EventBus } from './event-bus';
import { slotRegistry, SlotRegistry } from './slots';
interface PluginContext {
hooks: HookSystem;
events: EventBus;
slots: SlotRegistry;
}
interface Plugin {
name: string;
version: string;
dependencies?: string[];
initialize: (ctx: PluginContext) => void | Promise<void>;
cleanup?: () => void | Promise<void>;
}
class PluginSystem {
private plugins = new Map<string, Plugin>();
private initialized = new Set<string>();
private context: PluginContext = {
hooks,
events: eventBus,
slots: slotRegistry,
};
register(plugin: Plugin): void {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin ${plugin.name} already registered`);
}
this.plugins.set(plugin.name, plugin);
}
async initialize(): Promise<void> {
// Sort by dependencies (topological sort)
const sorted = this.topologicalSort();
for (const plugin of sorted) {
if (this.initialized.has(plugin.name)) continue;
console.log(`Initializing plugin: ${plugin.name}`);
await plugin.initialize(this.context);
this.initialized.add(plugin.name);
}
}
async cleanup(): Promise<void> {
// Cleanup in reverse order
const plugins = Array.from(this.initialized)
.map(name => this.plugins.get(name)!)
.reverse();
for (const plugin of plugins) {
if (plugin.cleanup) {
await plugin.cleanup();
}
this.initialized.delete(plugin.name);
}
}
private topologicalSort(): Plugin[] {
// Implementation of topological sort based on dependencies
const result: Plugin[] = [];
const visited = new Set<string>();
const visit = (name: string) => {
if (visited.has(name)) return;
visited.add(name);
const plugin = this.plugins.get(name);
if (!plugin) return;
for (const dep of plugin.dependencies || []) {
visit(dep);
}
result.push(plugin);
};
for (const [name] of this.plugins) {
visit(name);
}
return result;
}
}
export const pluginSystem = new PluginSystem();
// Example plugin using all patterns
const analyticsPlugin: Plugin = {
name: 'analytics',
version: '1.0.0',
dependencies: ['core'],
initialize: ({ hooks, events, slots }) => {
// Use hooks for request instrumentation
hooks.register('api:afterResponse', (ctx) => {
trackApiCall(ctx.request.url, ctx.res.status);
return ctx;
});
// Use events for business analytics
events.on('order:placed', ({ order }) => {
trackPurchase(order);
});
// Use slots for UI
slots.register('sidebar:bottom', {
component: AnalyticsWidget,
priority: 0,
});
},
cleanup: () => {
// Cleanup logic
},
};
pluginSystem.register(analyticsPlugin);
Design Principles
1. Core Should Be Plugin-Agnostic
// ❌ BAD: Core knows about specific plugins
class UserService {
async createUser(data: CreateUserInput) {
const user = await db.users.create({ data });
// Core shouldn't know about these
analyticsPlugin.track('user:created', user);
emailPlugin.sendWelcome(user);
cachePlugin.invalidate('users');
return user;
}
}
// ✅ GOOD: Core just emits, plugins decide what to do
class UserService {
async createUser(data: CreateUserInput) {
const user = await db.users.create({ data });
// Generic emission - plugins subscribe as needed
eventBus.emit('user:created', { user });
return user;
}
}
2. Plugins Should Be Independently Testable
// Each plugin should be testable in isolation
// analytics-plugin.test.ts
import { createMockEventBus } from '@/test-utils';
import { initializeAnalyticsPlugin } from './analytics-plugin';
describe('Analytics Plugin', () => {
it('tracks user creation', async () => {
const mockEventBus = createMockEventBus();
const trackSpy = jest.fn();
initializeAnalyticsPlugin({
events: mockEventBus,
tracker: { track: trackSpy },
});
await mockEventBus.emit('user:created', { user: { id: '1' } });
expect(trackSpy).toHaveBeenCalledWith('User Created', { userId: '1' });
});
});
3. Graceful Degradation
// Plugins should fail gracefully without breaking core
hooks.register('api:beforeRequest', async (ctx) => {
try {
await riskyPluginOperation(ctx);
} catch (error) {
// Log but don't throw - don't break the request
console.error('Plugin error:', error);
// Optionally emit error event for monitoring
eventBus.emit('plugin:error', { plugin: 'risky', error });
}
return ctx;
});
4. Clear Extension Points
// Document available hooks/events/slots
/**
* Available Hooks:
*
* api:beforeRequest - Runs before request processing
* api:authenticate - Runs for authentication
* api:afterResponse - Runs after successful response
* api:onError - Runs on error
* api:finally - Always runs (cleanup)
*
* Available Events:
*
* user:created - After user creation
* user:updated - After user update
* user:deleted - After user deletion
*
* Available Slots:
*
* header:actions - Header action buttons
* sidebar:top - Top of sidebar
* sidebar:bottom - Bottom of sidebar
*/
Quick Reference
When to Use Each Pattern
HOOKS (Synchronous Extension Points)
────────────────────────────────────
Use when: Core needs to allow modification of its behavior
Examples: Request/response transformation, validation, authentication
Trade-off: Tighter coupling, but more control
MIDDLEWARE (Pipeline Processing)
────────────────────────────────────
Use when: Processing should flow through a chain
Examples: HTTP request handling, data transformation pipelines
Trade-off: Order-dependent, can be complex to debug
EVENT BUS (Decoupled Communication)
────────────────────────────────────
Use when: Components shouldn't know about each other
Examples: Cross-module notifications, audit logging, analytics
Trade-off: Can be hard to trace event flow
SLOTS (UI Composition)
────────────────────────────────────
Use when: UI needs extension points
Examples: Plugin widgets, customizable layouts, dashboard panels
Trade-off: Runtime composition overhead
MODULE FEDERATION (Independent Deployment)
────────────────────────────────────
Use when: Plugins need independent development/deployment
Examples: Micro-frontends, third-party integrations
Trade-off: Complexity, version management
Architecture Decision Checklist
## Before Building Plugin Architecture
- [ ] Is extensibility a real requirement, or speculative?
- [ ] How many plugins do we realistically expect?
- [ ] Will plugins be internal or third-party?
- [ ] What's the security model for plugins?
- [ ] How will plugins be discovered and loaded?
- [ ] What's the plugin lifecycle (install, enable, disable, uninstall)?
- [ ] How will plugin errors be handled?
- [ ] What's the versioning strategy for plugin APIs?
Closing Thoughts
Plugin architecture is a commitment. It adds complexity upfront in exchange for flexibility later. Before reaching for these patterns, ask: do I actually need this extensibility?
Signs you might:
- Multiple teams need to add features independently
- Third parties will extend your system
- Features are truly optional and user-configurable
- You're building a platform, not an application
Signs you might not:
- You're the only team working on this
- "Plugins" are really just features that belong in core
- You're optimizing for hypothetical future requirements
When you do need plugin architecture, start simple. A basic hook system or event bus covers 90% of real-world cases. Module Federation and dynamic loading are powerful but complex — save them for when you genuinely need independent deployment.
The best plugin systems disappear. Developers don't think about "using hooks" — they just add features. The architecture enables extension without demanding attention. That's the goal.
What did you think?