How I built a pluggable notification engine in NestJS using dynamic modules, discovery, and lifecycle hooks, and the failure modes that taught me to treat the registry as a public contract.
A Thursday afternoon at Smarthronline. The product lead pinged me asking for a Discord channel on top of the email and Slack notifications we already had. We were two days from a hiring-event launch. The notification service had a switch statement that knew about every channel. Adding Discord meant a code change, a code review, a deploy, and a smoke test against a live tenant.
I didn’t want to do any of that.
That conversation is the reason the next sprint at Smarthronline went into a proper plugin system. New channels became modules. New modules became one PR, one merge, one boot. The switch statement died in a fire. This is how I’d build the same thing today in NestJS, and the failure modes I’ve learned to design around first.
The honest answer is product velocity. The dishonest answer is “extensibility.” Extensibility on its own is a worse goal than people think. Plugin systems make sense when two things are true at the same time: the surface (notification channel, payment provider, importer) is genuinely open-ended, and you want the registration boundary to be a module, not a feature flag.
If you only ever have three channels, a strategy map keyed on an enum is fine. Don’t reach for plugins to feel clever.
When the surface really does keep growing, NestJS gives you everything you need. Dynamic modules for registration. The DI container for instantiation. DiscoveryService from @nestjs/core for finding things at boot. OnModuleInit and OnApplicationBootstrap for lifecycle. Used together they replace a lot of homegrown wiring.
Start with the interface. The interface is the only thing your core service is allowed to know about.
import { ModuleMetadata, Type } from '@nestjs/common';
export interface NotificationPayload {
tenantId: string;
userId: string;
template: string;
vars: Record<string, unknown>;
}
export interface NotificationPlugin {
readonly channel: string;
send(payload: NotificationPayload): Promise<void>;
healthcheck?(): Promise<{ ok: boolean; latencyMs?: number }>;
}
export const NOTIFICATION_PLUGIN = Symbol('NOTIFICATION_PLUGIN');
export interface NotificationPluginModuleOptions {
imports?: ModuleMetadata['imports'];
provider: Type<NotificationPlugin>;
}
Two things matter here. The channel property is the registry key, so it has to be unique and stable. And the optional healthcheck is non-negotiable in production, even though I marked it optional in the type. Every plugin we shipped at Smarthronline had one. The reason is the war story below.
Each channel ships as its own NestJS module that calls NotificationPluginModule.forFeature and hands back a provider tagged with the NOTIFICATION_PLUGIN symbol and a multi-injection token.
import { DynamicModule, Module, Provider } from '@nestjs/common';
import {
NotificationPlugin,
NotificationPluginModuleOptions,
NOTIFICATION_PLUGIN,
} from './notification-plugin.types';
@Module({})
export class NotificationPluginModule {
static forFeature(options: NotificationPluginModuleOptions): DynamicModule {
const pluginProvider: Provider = {
provide: NOTIFICATION_PLUGIN,
useExisting: options.provider,
};
return {
module: NotificationPluginModule,
imports: options.imports ?? [],
providers: [options.provider, pluginProvider],
exports: [options.provider, NOTIFICATION_PLUGIN],
};
}
}
The useExisting trick is what makes this work. The plugin class is bound as itself, but it’s also bound to the shared NOTIFICATION_PLUGIN symbol. That symbol is what the discovery layer uses to find every plugin in the container, regardless of which module declared it. No central registry array. No “register your plugin here” comment that someone forgets.
A concrete channel module looks like this:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NotificationPluginModule } from '../notification-plugin.module';
import { SlackNotificationPlugin } from './slack.plugin';
@Module({
imports: [
NotificationPluginModule.forFeature({
imports: [ConfigModule],
provider: SlackNotificationPlugin,
}),
],
exports: [NotificationPluginModule],
})
export class SlackChannelModule {}
Adding Discord later is a new folder, a new module, and one line in AppModule.imports. That’s the whole point.
DiscoveryService walks the DI container and gives you providers by metadata. We use it to collect every NOTIFICATION_PLUGIN-tagged binding the moment the app finishes booting.
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DiscoveryService } from '@nestjs/core';
import {
NotificationPlugin,
NOTIFICATION_PLUGIN,
} from './notification-plugin.types';
@Injectable()
export class NotificationRegistry implements OnApplicationBootstrap {
private readonly logger = new Logger(NotificationRegistry.name);
private readonly plugins = new Map<string, NotificationPlugin>();
constructor(private readonly discovery: DiscoveryService) {}
async onApplicationBootstrap(): Promise<void> {
const providers = this.discovery.getProviders();
for (const wrapper of providers) {
if (wrapper.token !== NOTIFICATION_PLUGIN || !wrapper.instance) continue;
const plugin = wrapper.instance as NotificationPlugin;
if (this.plugins.has(plugin.channel)) {
throw new Error(
`Duplicate notification channel '${plugin.channel}'. ` +
`Two plugins cannot register the same channel key.`,
);
}
if (plugin.healthcheck) {
const result = await plugin.healthcheck();
if (!result.ok) {
this.logger.warn(
`Plugin '${plugin.channel}' booted but healthcheck failed`,
);
}
}
this.plugins.set(plugin.channel, plugin);
this.logger.log(`Registered notification channel: ${plugin.channel}`);
}
}
resolve(channel: string): NotificationPlugin {
const plugin = this.plugins.get(channel);
if (!plugin) {
throw new Error(`Notification channel '${channel}' not registered`);
}
return plugin;
}
}
The duplicate-channel check is the kind of thing you don’t think you need until you do. Two engineers shipped two webhook plugins on different branches at Smarthronline once. Merged in the same week. Boot didn’t complain. Half the tenants got the wrong webhook payload shape for about an hour. Now every registry I write fails closed when it sees the same key twice.
A real notification system has plugins that depend on each other. The audit plugin reads every send. The Slack plugin uses a shared HTTP client provided by another module. The clean answer is to keep the inter-plugin contract as a regular NestJS provider, not a sibling plugin.
@Injectable()
export class SlackNotificationPlugin implements NotificationPlugin {
readonly channel = 'slack';
constructor(
private readonly http: HttpClient,
private readonly audit: AuditTrail,
) {}
async send(payload: NotificationPayload): Promise<void> {
const started = Date.now();
try {
await this.http.post('/slack/webhooks/send', payload);
await this.audit.record({ ...payload, channel: this.channel, ok: true });
} catch (err) {
await this.audit.record({ ...payload, channel: this.channel, ok: false });
throw err;
}
}
async healthcheck() {
return { ok: await this.http.ping('/slack/webhooks/health') };
}
}
Plugins consume infrastructure through DI like any other service. They don’t reach across into each other directly. If two plugins truly need to share state, that’s a sign the shared thing should be its own provider in a separate module they both import.
useExisting. That’s the whole registry.DiscoveryService and OnApplicationBootstrap is the boring, correct path. Skip homegrown registries.Thanks for reading. If you’ve got thoughts, send them my way.