Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 053 / 100

Plugin architecture: a practical Design Patterns guide

Extend an app without changing its core.

Expona Admin9 min read

Plugin architecture: a practical Design Patterns guide

Most applications start monolithic and eventually need to extend their capabilities without destabilizing the core system. A well-designed plugin architecture lets teams add features, integrate third-party tools, and enable customer customization through well-defined extension points rather than code changes to the main codebase. This pattern has proven itself across payment processors, analytics platforms, e-commerce backends, and internal tools we've shipped in production.

Why plugin architecture matters

As systems grow, the cost of adding features directly to the core increases. Each change risks breaking existing functionality, requires careful coordination across teams, and slows down deployment cycles. A plugin architecture inverts this problem: you define contracts, plugins implement them, and the core system remains stable. This separation of concerns also enables parallel development—teams can build and test plugins independently, and users can selectively enable only the functionality they need.

Define extension points as interfaces

The foundation of any plugin architecture is a clear contract. Extension points are interfaces that plugins must implement. These interfaces should be minimal, versioned, and focused on a single responsibility.

Consider a payment processing system. Instead of hardcoding Stripe, PayPal, and Square integrations into the core, you define an extension point:

typescript
export interface PaymentProvider {
  readonly name: string;
  readonly version: string;
  
  processPayment(request: PaymentRequest): Promise<PaymentResult>;
  refund(transactionId: string, amount: number): Promise<RefundResult>;
  getStatus(transactionId: string): Promise<TransactionStatus>;
}

export interface PaymentRequest {
  amount: number;
  currency: string;
  customerId: string;
  metadata: Record<string, string>;
}

export interface PaymentResult {
  transactionId: string;
  status: 'success' | 'failed' | 'pending';
  errorMessage?: string;
}

Now any payment provider can implement this interface. A Stripe plugin looks like:

typescript
export class StripePaymentPlugin implements PaymentProvider {
  name = 'stripe-payment';
  version = '1.0.0';
  
  private client: Stripe;
  
  constructor(apiKey: string) {
    this.client = new Stripe(apiKey);
  }
  
  async processPayment(request: PaymentRequest): Promise<PaymentResult> {
    try {
      const charge = await this.client.charges.create({
        amount: Math.round(request.amount * 100),
        currency: request.currency,
        customer: request.customerId,
        metadata: request.metadata,
      });
      
      return {
        transactionId: charge.id,
        status: charge.status === 'succeeded' ? 'success' : 'failed',
      };
    } catch (error) {
      return {
        transactionId: '',
        status: 'failed',
        errorMessage: error.message,
      };
    }
  }
  
  async refund(transactionId: string, amount: number): Promise<RefundResult> {
    const refund = await this.client.refunds.create({
      charge: transactionId,
      amount: Math.round(amount * 100),
    });
    
    return {
      refundId: refund.id,
      status: refund.status === 'succeeded' ? 'success' : 'failed',
    };
  }
  
  async getStatus(transactionId: string): Promise<TransactionStatus> {
    const charge = await this.client.charges.retrieve(transactionId);
    return {
      transactionId: charge.id,
      status: charge.status,
      amount: charge.amount / 100,
    };
  }
}

The key principle: define interfaces that are stable and testable. Make them small enough to implement easily, but complete enough to be useful. Avoid leaking implementation details into the interface.

Discover plugins by config or convention

Once you have interfaces, you need a way to load plugins at runtime. Two main approaches work well in practice: configuration-driven discovery and convention-based discovery.

Configuration-driven discovery is explicit and safe. You list plugins in a config file, and the core system loads them:

typescript
// config.json
{
  "plugins": [
    {
      "name": "stripe-payment",
      "path": "./plugins/stripe-payment.js",
      "enabled": true,
      "config": {
        "apiKey": "${STRIPE_API_KEY}"
      }
    },
    {
      "name": "paypal-payment",
      "path": "./plugins/paypal-payment.js",
      "enabled": false,
      "config": {
        "clientId": "${PAYPAL_CLIENT_ID}"
      }
    }
  ]
}

The plugin loader then instantiates each enabled plugin:

typescript
export class PluginRegistry {
  private plugins: Map<string, PaymentProvider> = new Map();
  
  async loadPlugins(config: PluginConfig[]): Promise<void> {
    for (const pluginConfig of config) {
      if (!pluginConfig.enabled) continue;
      
      try {
        const module = await import(pluginConfig.path);
        const PluginClass = module.default || module[pluginConfig.name];
        
        const instance = new PluginClass(pluginConfig.config);
        this.plugins.set(pluginConfig.name, instance);
        
        console.log(`Loaded plugin: ${pluginConfig.name} v${instance.version}`);
      } catch (error) {
        console.error(`Failed to load plugin ${pluginConfig.name}:`, error);
        // Decide: fail fast or continue?
      }
    }
  }
  
  getPlugin(name: string): PaymentProvider | undefined {
    return this.plugins.get(name);
  }
  
  listPlugins(): PaymentProvider[] {
    return Array.from(this.plugins.values());
  }
}

Convention-based discovery is more implicit. You scan a directory, require all files matching a pattern, and assume they export a plugin class. This works well in smaller systems but can become fragile:

typescript
export async function discoverPluginsByConvention(
  pluginDir: string
): Promise<PaymentProvider[]> {
  const files = await fs.readdir(pluginDir);
  const plugins: PaymentProvider[] = [];
  
  for (const file of files) {
    if (!file.endsWith('.plugin.js')) continue;
    
    const modulePath = path.join(pluginDir, file);
    const module = await import(modulePath);
    const PluginClass = module.default;
    
    if (PluginClass && typeof PluginClass === 'function') {
      plugins.push(new PluginClass());
    }
  }
  
  return plugins;
}

In production, we prefer configuration-driven discovery because it's explicit, auditable, and allows you to toggle plugins without file system changes. Environment variable substitution (like ${STRIPE_API_KEY}) keeps secrets out of version control.

Isolate plugin failures

A crashed or slow plugin must never take down the core system. Isolation is critical. The most practical approach is wrapping each plugin call in error handling and timeouts:

typescript
export class SafePaymentProvider implements PaymentProvider {
  name: string;
  version: string;
  
  private timeoutMs = 5000;
  private maxRetries = 2;
  private fallbackResult: PaymentResult = {
    transactionId: '',
    status: 'failed',
    errorMessage: 'Plugin timeout or error',
  };
  
  constructor(
    private plugin: PaymentProvider,
    private logger: Logger
  ) {
    this.name = plugin.name;
    this.version = plugin.version;
  }
  
  async processPayment(request: PaymentRequest): Promise<PaymentResult> {
    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      try {
        const result = await Promise.race([
          this.plugin.processPayment(request),
          this.timeout(this.timeoutMs),
        ]);
        
        return result;
      } catch (error) {
        this.logger.warn(
          `Plugin ${this.plugin.name} attempt ${attempt} failed:`,
          error
        );
        
        if (attempt === this.maxRetries) {
          return this.fallbackResult;
        }
        
        await this.delay(Math.pow(2, attempt) * 100); // Exponential backoff
      }
    }
    
    return this.fallbackResult;
  }
  
  private timeout(ms: number): Promise<never> {
    return new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Plugin timeout')), ms)
    );
  }
  
  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  // Wrap refund and getStatus similarly...
  async refund(transactionId: string, amount: number): Promise<RefundResult> {
    try {
      return await Promise.race([
        this.plugin.refund(transactionId, amount),
        this.timeout(this.timeoutMs),
      ]);
    } catch (error) {
      this.logger.error(`Refund failed on plugin ${this.plugin.name}:`, error);
      return {
        refundId: '',
        status: 'failed',
        errorMessage: error.message,
      };
    }
  }
  
  async getStatus(transactionId: string): Promise<TransactionStatus> {
    try {
      return await Promise.race([
        this.plugin.getStatus(transactionId),
        this.timeout(this.timeoutMs),
      ]);
    } catch (error) {
      this.logger.error(`Status check failed on plugin ${this.plugin.name}:`, error);
      throw error; // Re-throw for caller to handle
    }
  }
}

This wrapper ensures that if a plugin hangs or throws, the system responds within a known time window. For critical paths like payment processing, you might also implement circuit breakers: if a plugin fails repeatedly, stop calling it temporarily and use a fallback.

Version the plugin contract

Interfaces evolve. Payment processors add new fields, require new methods, change behavior. Without versioning, you'll break existing plugins. Use semantic versioning and explicit version checks:

typescript
export interface PaymentProviderV1 {
  readonly name: string;
  readonly version: string;
  
  processPayment(request: PaymentRequestV1): Promise<PaymentResultV1>;
  refund(transactionId: string): Promise<RefundResultV1>;
}

export interface PaymentProviderV2 extends PaymentProviderV1 {
  refund(
    transactionId: string,
    amount?: number
  ): Promise<RefundResultV2>; // amount is now optional
  getStatus(transactionId: string): Promise<TransactionStatus>; // New method
}

export class PluginAdapter {
  static adaptV1ToV2(plugin: PaymentProviderV1): PaymentProviderV2 {
    return {
      ...plugin,
      async refund(transactionId: string, amount?: number) {
        // V1 only supported full refunds
        return plugin.refund(transactionId);
      },
      async getStatus(_transactionId: string) {
        throw new Error('Plugin does not support getStatus');
      },
    };
  }
}

In the plugin registry, validate versions on load and either adapt or reject incompatible plugins:

typescript
async loadPlugins(config: PluginConfig[]): Promise<void> {
  const supportedVersions = ['1.0.0', '1.1.0', '2.0.0'];
  
  for (const pluginConfig of config) {
    if (!pluginConfig.enabled) continue;
    
    try {
      const module = await import(pluginConfig.path);
      const PluginClass = module.default;
      const instance = new PluginClass(pluginConfig.config);
      
      if (!supportedVersions.includes(instance.version)) {
        throw new Error(
          `Plugin version ${instance.version} not supported. Supported: ${supportedVersions.join(', ')}`
        );
      }
      
      this.plugins.set(pluginConfig.name, instance);
    } catch (error) {
      console.error(`Failed to load plugin ${pluginConfig.name}:`, error);
      // Handle gracefully
    }
  }
}

Common mistakes

  • Leaking implementation details into the interface. If your payment provider interface exposes internal Stripe objects, plugins become tightly coupled to Stripe. Keep interfaces clean and minimal.
  • No timeout or isolation. A slow plugin becomes a slow system. Always wrap plugin calls with timeouts and error handlers.
  • Breaking plugins silently. If a plugin fails to load, the system continues but the feature is unavailable—users don't know why. Log clearly, include version numbers, and consider failing fast during development.
  • Versioning too late. Once plugins depend on your interface, breaking changes are expensive. Version from the start, even if you think it won't change.
  • No observability. Plugin behavior is hidden from logs and metrics. Instrument every plugin call: track latency, errors, and which plugin processed each request.

Key takeaways

  • Start with clear, minimal interfaces as extension points. Every interface should specify a single responsibility and include version information.
  • Load plugins through explicit configuration, not magic discovery. Configuration is auditable and works well with secrets management.
  • Isolate plugin failures with timeouts, retries, and error boundaries. A plugin should never crash the core system.
  • Version your plugin contract from day one. Use semantic versioning and adapt old plugins to new interfaces when possible.
  • Observe plugin behavior. Log plugin loads, calls, latencies, and failures so you can debug issues in production.
  • Keep the core lean and the contracts stable. The goal is to ship features in plugins without touching the main codebase.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles