Plugin Architecture
Plugin Architecture (or Microkernel Architecture) allows an application to be extended dynamically without modifying the core system. It is heavily used in CMS platforms, IDEs, and extensible SaaS products.
Overview
Imagine you are building a payment processing engine. Your core engine knows how to validate carts and calculate taxes. However, you want to support Stripe, PayPal, and Square. Instead of hardcoding all three into your core engine, you define a Plugin interface.
The core system (Microkernel) loads available plugins at startup and delegates the actual payment processing to whichever plugin is currently active or requested.
NestJS’s Dependency Injection system makes Plugin Architecture trivial. By utilizing Custom Providers (specifically using Injection Tokens and useClass or useFactory), you can register multiple implementations of the same interface.
Key Concepts
- Core System (Microkernel): The main application that contains the foundational logic. It defines the extension points (Interfaces).
- Plugins: Independent modules that implement the extension points. They can be built-in, or even loaded dynamically at runtime from external npm packages.
- Extension Points: The defined Interfaces or Abstract Classes that plugins must adhere to.
- Registry / Discovery: The mechanism by which the core system finds and registers the available plugins.
Code Examples
1. Defining the Extension Point
Define the interface that all plugins must implement.
// plugin.interface.ts
export const PAYMENT_PLUGIN = Symbol('PAYMENT_PLUGIN');
export interface PaymentPlugin {
getProviderName(): string;
processPayment(amount: number): Promise<boolean>;
}
2. Creating the Plugins
Create independent services that implement the interface.
// stripe.plugin.ts
import { Injectable } from '@nestjs/common';
import { PaymentPlugin } from './plugin.interface';
@Injectable()
export class StripePlugin implements PaymentPlugin {
getProviderName() { return 'stripe'; }
async processPayment(amount: number) {
console.log(`Processing $${amount} via Stripe`);
return true;
}
}
// paypal.plugin.ts
import { Injectable } from '@nestjs/common';
import { PaymentPlugin } from './plugin.interface';
@Injectable()
export class PaypalPlugin implements PaymentPlugin {
getProviderName() { return 'paypal'; }
async processPayment(amount: number) {
console.log(`Processing $${amount} via PayPal`);
return true;
}
}
3. Registering the Plugins (The Registry)
In your module, you register all available plugins under the same injection token.
// payment.module.ts
import { Module } from '@nestjs/common';
import { PaymentService } from './payment.service';
import { StripePlugin } from './stripe.plugin';
import { PaypalPlugin } from './paypal.plugin';
import { PAYMENT_PLUGIN } from './plugin.interface';
@Module({
providers: [
PaymentService,
// Note: We don't use 'useClass', we just register the classes normally
StripePlugin,
PaypalPlugin,
// Then we create a factory that returns an ARRAY of the plugins!
{
provide: PAYMENT_PLUGIN,
useFactory: (stripe: StripePlugin, paypal: PaypalPlugin) => {
return [stripe, paypal]; // Return array of plugins
},
inject: [StripePlugin, PaypalPlugin],
}
],
})
export class PaymentModule {}
4. The Core Engine Consuming the Plugins
The core engine injects the array of plugins. When a request comes in, it dynamically selects the correct plugin.
// payment.service.ts
import { Injectable, Inject, BadRequestException } from '@nestjs/common';
import { PaymentPlugin, PAYMENT_PLUGIN } from './plugin.interface';
@Injectable()
export class PaymentService {
private pluginRegistry = new Map<string, PaymentPlugin>();
constructor(
// We inject the array of plugins!
@Inject(PAYMENT_PLUGIN) plugins: PaymentPlugin[]
) {
// Build a dictionary for fast O(1) lookup
for (const plugin of plugins) {
this.pluginRegistry.set(plugin.getProviderName(), plugin);
}
}
async checkout(providerName: string, amount: number) {
const plugin = this.pluginRegistry.get(providerName);
if (!plugin) {
throw new BadRequestException(`Payment provider ${providerName} not found`);
}
return plugin.processPayment(amount);
}
}
Best Practices
- Dynamic Module Loading: For true plugin architectures, you might not want to hardcode the
inject: [StripePlugin, PaypalPlugin]array. You can use Node.js tools likefs.readdirSyncin yourAppModuleto read a/pluginsfolder, dynamicallyimport()the files, and register them into the NestJS DI container before the app bootstraps. - Isolate Plugins: If you are allowing third parties to write plugins for your system, ensure the plugins run in a secure, isolated context (or as separate microservices) so a poorly written plugin doesn’t crash the core Microkernel.