Snippets.

April 20, 2025

Architecture11 min read

Plugin Architecture in NestJS with Dynamic Modules

Plugin architecture in NestJS with dynamic modules

If you've been building NestJS applications for a while, you've probably hit a point where you need your system to be extensible. Maybe you need to support multiple payment providers, different notification channels, or feature toggles that swap implementations at runtime. Hardcoding these into your services leads to a mess of if/else chains and tight coupling.

What you actually want is a plugin architecture, where new capabilities can be added without modifying existing code. The good news is that NestJS gives us powerful primitives to build exactly this: Dynamic Modules, Custom Decorators, and Discovery patterns.

In this article, we'll build a real notification system where each channel (Email, SMS, Slack) is a self-contained plugin. The system discovers all registered plugins automatically and routes notifications to the right one. No switch statements, no hardcoded lists.

Plugin Contracts With Interfaces

Before writing any infrastructure code, we need to define what a plugin looks like. This is the most important step because it establishes the contract that every plugin must follow. If you've read my article on Understanding Clean Architecture, you'll recognize this as a "port", the interface that the core defines and the infrastructure implements.

interface NotificationRecipient {
  readonly identifier: string;
  readonly displayName: string;
}

interface NotificationPayload {
  readonly subject: string;
  readonly body: string;
  readonly recipient: NotificationRecipient;
  readonly metadata?: Record<string, unknown>;
}

interface NotificationResult {
  readonly success: boolean;
  readonly channelName: string;
  readonly timestamp: Date;
  readonly errorMessage?: string;
}

interface NotificationChannel {
  readonly channelName: string;
  initialize(): Promise<void>;
  send(notificationPayload: NotificationPayload): Promise<NotificationResult>;
  supports(notificationPayload: NotificationPayload): boolean;
}

Notice that every plugin must declare its channelName, have an initialize method for setup logic, a send method for actual delivery, and a supports method so the system can figure out which channel handles which notification. This contract is clean and minimal, plugins only need to care about their own delivery logic.

ConfigurableModuleBuilder Deep Dive

NestJS provides ConfigurableModuleBuilder which removes the boilerplate of creating modules with register and registerAsync static methods. This is perfect for plugins that need configuration, like API keys or endpoint URLs.

interface NotificationModuleOptions {
  readonly defaultChannel: string;
  readonly retryAttempts: number;
  readonly retryDelayMilliseconds: number;
  readonly enabledChannels: string[];
}

const {
  ConfigurableModuleClass,
  MODULE_OPTIONS_TOKEN,
  OPTIONS_TYPE,
  ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<NotificationModuleOptions>()
  .setClassMethodName('forRoot')
  .setFactoryMethodName('createNotificationOptions')
  .build();

Now ConfigurableModuleClass gives us a base class with forRoot and forRootAsync methods automatically generated. The MODULE_OPTIONS_TOKEN is the injection token we use to access the options anywhere in the module.

Here's how we use this in our module definition:

@Module({})
class NotificationModule extends ConfigurableModuleClass {
  public static forRoot(
    notificationOptions: typeof OPTIONS_TYPE,
  ): DynamicModule {
    const baseModule = super.forRoot(notificationOptions);

    return {
      ...baseModule,
      global: true,
    };
  }

  public static forRootAsync(
    asyncOptions: typeof ASYNC_OPTIONS_TYPE,
  ): DynamicModule {
    const baseModule = super.forRootAsync(asyncOptions);

    return {
      ...baseModule,
      global: true,
    };
  }
}

The consumer can register the module synchronously or asynchronously. The async variant is useful when your config comes from a database or external service:

// Synchronous registration
@Module({
  imports: [
    NotificationModule.forRoot({
      defaultChannel: 'email',
      retryAttempts: 3,
      retryDelayMilliseconds: 1000,
      enabledChannels: ['email', 'sms', 'slack'],
    }),
  ],
})
class AppModule {}

// Async registration with useFactory
@Module({
  imports: [
    NotificationModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService): NotificationModuleOptions => ({
        defaultChannel: configService.get<string>('NOTIFICATION_DEFAULT_CHANNEL'),
        retryAttempts: configService.get<number>('NOTIFICATION_RETRY_ATTEMPTS'),
        retryDelayMilliseconds: configService.get<number>('NOTIFICATION_RETRY_DELAY_MS'),
        enabledChannels: configService.get<string>('NOTIFICATION_CHANNELS').split(','),
      }),
    }),
  ],
})
class AppModule {}

Creating Custom Decorators For Plugin Registration

Now comes the fun part. We want plugin authors to simply decorate their class and have it automatically registered. We'll create a @NotificationChannel() decorator that tags classes with metadata so we can discover them later.

const NOTIFICATION_CHANNEL_METADATA_KEY = 'NOTIFICATION_CHANNEL';

interface NotificationChannelMetadata {
  readonly channelName: string;
  readonly priority: number;
  readonly description: string;
}

function NotificationChannel(
  pluginMetadata: NotificationChannelMetadata,
): ClassDecorator {
  return (target: Function): void => {
    SetMetadata(NOTIFICATION_CHANNEL_METADATA_KEY, pluginMetadata)(target);
    Injectable()(target);
  };
}

This decorator does two things: it attaches our custom metadata to the class, and it also applies @Injectable() so the class can participate in NestJS dependency injection. Plugin authors don't need to remember both decorators, just @NotificationChannel() handles everything.

The Discovery Pattern

With our plugins decorated, we need a way to find them all at runtime. NestJS provides DiscoveryService from the @nestjs/core package which lets us scan for providers with specific metadata.

@Injectable()
class PluginDiscoveryService implements OnModuleInit {
  private readonly discoveredChannels: Map<string, NotificationChannel> = new Map();

  public constructor(
    private readonly discoveryService: DiscoveryService,
    private readonly reflector: Reflector,
  ) {}

  public async onModuleInit(): Promise<void> {
    const wrappers = this.discoveryService.getProviders();

    for (const wrapper of wrappers) {
      if (!wrapper.instance || wrapper.isAlias) {
        continue;
      }

      const pluginMetadata = this.reflector.get<NotificationChannelMetadata>(
        NOTIFICATION_CHANNEL_METADATA_KEY,
        wrapper.metatype,
      );

      if (!pluginMetadata) {
        continue;
      }

      const channelInstance = wrapper.instance as NotificationChannel;
      this.discoveredChannels.set(pluginMetadata.channelName, channelInstance);
    }
  }

  public getChannel(channelName: string): NotificationChannel | undefined {
    return this.discoveredChannels.get(channelName);
  }

  public getAllChannels(): NotificationChannel[] {
    return Array.from(this.discoveredChannels.values());
  }

  public getChannelNames(): string[] {
    return Array.from(this.discoveredChannels.keys());
  }
}

The onModuleInit lifecycle hook fires after all modules are loaded, so at that point every plugin provider is available. We iterate through all providers, check for our metadata, and store references to the plugin instances.

Building A Notification System

Now let's build the actual plugins. Each one implements the NotificationChannel interface and is tagged with our decorator. Starting with the Email channel:

@NotificationChannel({
  channelName: 'email',
  priority: 1,
  description: 'Send notifications via email using SMTP',
})
class EmailNotificationChannel implements NotificationChannel {
  public readonly channelName: string = 'email';

  public constructor(
    private readonly emailTransportService: EmailTransportService,
    @Inject(MODULE_OPTIONS_TOKEN)
    private readonly notificationOptions: NotificationModuleOptions,
  ) {}

  public async initialize(): Promise<void> {
    await this.emailTransportService.verifyConnection();
  }

  public supports(notificationPayload: NotificationPayload): boolean {
    return notificationPayload.recipient.identifier.includes('@');
  }

  public async send(
    notificationPayload: NotificationPayload,
  ): Promise<NotificationResult> {
    try {
      await this.emailTransportService.sendMail({
        to: notificationPayload.recipient.identifier,
        subject: notificationPayload.subject,
        body: notificationPayload.body,
      });

      return {
        success: true,
        channelName: this.channelName,
        timestamp: new Date(),
      };
    } catch (error) {
      return {
        success: false,
        channelName: this.channelName,
        timestamp: new Date(),
        errorMessage: error instanceof Error ? error.message : 'Unknown email delivery error',
      };
    }
  }
}

The SMS and Slack plugins follow the same pattern:

@NotificationChannel({
  channelName: 'sms',
  priority: 2,
  description: 'Send notifications via SMS using Twilio',
})
class SmsNotificationChannel implements NotificationChannel {
  public readonly channelName: string = 'sms';

  public constructor(
    private readonly smsGatewayService: SmsGatewayService,
  ) {}

  public async initialize(): Promise<void> {
    await this.smsGatewayService.validateCredentials();
  }

  public supports(notificationPayload: NotificationPayload): boolean {
    return /^\+\d{10,15}$/.test(notificationPayload.recipient.identifier);
  }

  public async send(
    notificationPayload: NotificationPayload,
  ): Promise<NotificationResult> {
    try {
      await this.smsGatewayService.sendMessage({
        phoneNumber: notificationPayload.recipient.identifier,
        messageBody: `${notificationPayload.subject}: ${notificationPayload.body}`,
      });

      return {
        success: true,
        channelName: this.channelName,
        timestamp: new Date(),
      };
    } catch (error) {
      return {
        success: false,
        channelName: this.channelName,
        timestamp: new Date(),
        errorMessage: error instanceof Error ? error.message : 'Unknown SMS delivery error',
      };
    }
  }
}

@NotificationChannel({
  channelName: 'slack',
  priority: 3,
  description: 'Send notifications to Slack channels via webhooks',
})
class SlackNotificationChannel implements NotificationChannel {
  public readonly channelName: string = 'slack';

  public constructor(
    private readonly slackWebhookService: SlackWebhookService,
  ) {}

  public async initialize(): Promise<void> {
    await this.slackWebhookService.testWebhookConnection();
  }

  public supports(notificationPayload: NotificationPayload): boolean {
    return notificationPayload.recipient.identifier.startsWith('#');
  }

  public async send(
    notificationPayload: NotificationPayload,
  ): Promise<NotificationResult> {
    try {
      await this.slackWebhookService.postMessage({
        slackChannel: notificationPayload.recipient.identifier,
        text: `*${notificationPayload.subject}*\n${notificationPayload.body}`,
      });

      return {
        success: true,
        channelName: this.channelName,
        timestamp: new Date(),
      };
    } catch (error) {
      return {
        success: false,
        channelName: this.channelName,
        timestamp: new Date(),
        errorMessage: error instanceof Error ? error.message : 'Unknown Slack delivery error',
      };
    }
  }
}

Each plugin is self-contained. It handles its own transport logic, knows what kind of recipient it supports, and returns a consistent result. Adding a new channel, say push notifications, means creating one new file with the decorator and it's automatically discovered.

The Plugin Registry

The registry ties everything together. It uses the PluginDiscoveryService to find all channels, initializes them, and provides a clean API for sending notifications.

@Injectable()
class NotificationPluginRegistry implements OnModuleInit {
  private readonly logger: Logger = new Logger(NotificationPluginRegistry.name);
  private readonly initializedChannels: Map<string, NotificationChannel> = new Map();

  public constructor(
    private readonly pluginDiscoveryService: PluginDiscoveryService,
    @Inject(MODULE_OPTIONS_TOKEN)
    private readonly notificationOptions: NotificationModuleOptions,
  ) {}

  public async onModuleInit(): Promise<void> {
    const allChannels = this.pluginDiscoveryService.getAllChannels();

    for (const notificationChannel of allChannels) {
      if (!this.notificationOptions.enabledChannels.includes(notificationChannel.channelName)) {
        this.logger.log(`Skipping disabled channel: ${notificationChannel.channelName}`);
        continue;
      }

      try {
        await notificationChannel.initialize();
        this.initializedChannels.set(notificationChannel.channelName, notificationChannel);
        this.logger.log(`Initialized channel: ${notificationChannel.channelName}`);
      } catch (error) {
        this.logger.error(
          `Failed to initialize channel: ${notificationChannel.channelName}`,
          error instanceof Error ? error.stack : undefined,
        );
      }
    }
  }

  public async sendNotification(
    notificationPayload: NotificationPayload,
    preferredChannel?: string,
  ): Promise<NotificationResult> {
    const targetChannel = this.resolveChannel(notificationPayload, preferredChannel);

    if (!targetChannel) {
      return {
        success: false,
        channelName: 'none',
        timestamp: new Date(),
        errorMessage: 'No suitable notification channel found for this payload',
      };
    }

    return targetChannel.send(notificationPayload);
  }

  private resolveChannel(
    notificationPayload: NotificationPayload,
    preferredChannel?: string,
  ): NotificationChannel | undefined {
    if (preferredChannel) {
      const requestedChannel = this.initializedChannels.get(preferredChannel);

      if (requestedChannel?.supports(notificationPayload)) {
        return requestedChannel;
      }
    }

    for (const [, notificationChannel] of this.initializedChannels) {
      if (notificationChannel.supports(notificationPayload)) {
        return notificationChannel;
      }
    }

    const defaultChannel = this.initializedChannels.get(
      this.notificationOptions.defaultChannel,
    );

    return defaultChannel;
  }
}

The resolveChannel method implements a clear priority: preferred channel first, then auto-detection based on the supports method, and finally the default channel as a fallback. No switch statements, no hardcoded channel names in the routing logic.

Plugin Lifecycle And Initialization

You might have noticed the initialize method in our contract and the onModuleInit hook in the registry. This is intentional. Plugins often need to perform async setup, like verifying SMTP connections, validating API credentials, or loading configuration from external sources.

By separating initialization from construction, we get several benefits. The constructor handles dependency injection only. The initialize method handles async setup that might fail. The registry decides when and in what order to initialize plugins. Failed initialization doesn't crash the entire app, just that one channel.

This is the same principle as the lifecycle hooks in NestJS itself. Construction and initialization are separate concerns.

Testing Plugins In Isolation

This architecture makes testing straightforward. Since every plugin depends on an interface, you can mock any channel without touching the rest:

describe('NotificationPluginRegistry', () => {
  let notificationPluginRegistry: NotificationPluginRegistry;
  let mockEmailChannel: NotificationChannel;
  let mockSmsChannel: NotificationChannel;

  beforeEach(async () => {
    mockEmailChannel = {
      channelName: 'email',
      initialize: jest.fn().mockResolvedValue(undefined),
      send: jest.fn().mockResolvedValue({
        success: true,
        channelName: 'email',
        timestamp: new Date(),
      }),
      supports: jest.fn().mockImplementation(
        (notificationPayload: NotificationPayload): boolean =>
          notificationPayload.recipient.identifier.includes('@'),
      ),
    };

    mockSmsChannel = {
      channelName: 'sms',
      initialize: jest.fn().mockResolvedValue(undefined),
      send: jest.fn().mockResolvedValue({
        success: true,
        channelName: 'sms',
        timestamp: new Date(),
      }),
      supports: jest.fn().mockImplementation(
        (notificationPayload: NotificationPayload): boolean =>
          /^\+\d{10,15}$/.test(notificationPayload.recipient.identifier),
      ),
    };

    const mockDiscoveryService: Partial<PluginDiscoveryService> = {
      getAllChannels: jest.fn().mockReturnValue([mockEmailChannel, mockSmsChannel]),
    };

    const testingModule = await Test.createTestingModule({
      providers: [
        NotificationPluginRegistry,
        { provide: PluginDiscoveryService, useValue: mockDiscoveryService },
        {
          provide: MODULE_OPTIONS_TOKEN,
          useValue: {
            defaultChannel: 'email',
            retryAttempts: 3,
            retryDelayMilliseconds: 1000,
            enabledChannels: ['email', 'sms'],
          } satisfies NotificationModuleOptions,
        },
      ],
    }).compile();

    notificationPluginRegistry = testingModule.get(NotificationPluginRegistry);
    await notificationPluginRegistry.onModuleInit();
  });

  it('should route email notifications to the email channel', async () => {
    const emailPayload: NotificationPayload = {
      subject: 'Test Subject',
      body: 'Test Body',
      recipient: { identifier: 'user@example.com', displayName: 'Test User' },
    };

    const notificationResult = await notificationPluginRegistry.sendNotification(emailPayload);

    expect(notificationResult.success).toBe(true);
    expect(notificationResult.channelName).toBe('email');
    expect(mockEmailChannel.send).toHaveBeenCalledWith(emailPayload);
  });

  it('should route SMS notifications to the SMS channel', async () => {
    const smsPayload: NotificationPayload = {
      subject: 'Alert',
      body: 'Your code has been verified',
      recipient: { identifier: '+1234567890', displayName: 'Test User' },
    };

    const notificationResult = await notificationPluginRegistry.sendNotification(smsPayload);

    expect(notificationResult.success).toBe(true);
    expect(notificationResult.channelName).toBe('sms');
    expect(mockSmsChannel.send).toHaveBeenCalledWith(smsPayload);
  });
});

Each plugin can also be tested in complete isolation. Just instantiate it with a mocked transport service and verify its send and supports methods. No need to spin up the entire NestJS application.

Clean Architecture Alignment

If you've read my article on Clean Architecture Application Structure In NestJS Framework, you know that the core principle is the dependency rule: inner layers define interfaces, outer layers implement them.

Our plugin architecture follows this exactly. The NotificationChannel interface lives in the domain or application layer. It defines what the core needs without knowing anything about SMTP, Twilio, or Slack webhooks. Each plugin (Email, SMS, Slack) is an infrastructure adapter that implements the interface. The NotificationPluginRegistry is an application service that orchestrates the plugins through the interface.

This means the core business logic never changes when you add, remove, or swap plugins. Want to replace Twilio with a different SMS provider? Create a new plugin, remove the old one. The registry and the rest of your application don't need a single line changed.

The decorator-based discovery pattern adds another benefit: plugins are self-registering. You don't maintain a central list of "all available channels" that someone forgets to update. Drop a decorated class into a module and it's picked up automatically.

Conclusion

A plugin architecture doesn't have to be complex. NestJS gives you everything you need out of the box: ConfigurableModuleBuilder for flexible module configuration, SetMetadata and Reflector for tagging and discovering plugins, and lifecycle hooks for orderly initialization. The key decisions are defining a solid plugin interface, keeping plugins self-contained, and using discovery instead of explicit registration. This keeps your system open for extension without modifying existing code, which is exactly what the Open/Closed Principle asks for.