Description
NovuModule.registerAsync() from @novu/framework/nest accepts a controllerDecorators option in its TypeScript types, but the runtime implementation drops it. Only the synchronous NovuModule.register() actually applies the decorators to the internal NovuController class. The async variant ignores the option silently — no warning, no error, no type mismatch.
This breaks any integration that needs to attach class decorators to the bridge controller while also needing async DI (e.g. resolving config from the Nest container instead of process.env).
Environment
@novu/framework: 2.10.0
@nestjs/common: 11.1.19
- Node: 22
Steps to reproduce
Register the Novu bridge with registerAsync and pass controllerDecorators:
import { Client, NovuModule } from '@novu/framework/nest';
@Module({
imports: [
NovuModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
apiPath: '/novu/bridge',
workflows: [/* ... */],
client: new Client({
secretKey: config.get('NOVU_SECRET_KEY'),
}),
controllerDecorators: [SomeDecorator()], // ← silently ignored
}),
}),
],
})
export class AppModule {}
Expected
The decorators in controllerDecorators are applied to the bridge controller, matching the behavior of the synchronous register().
Actual
The decorators are never applied.
Root cause
packages/framework/src/servers/nest/nest.module.ts:
export class NovuModule extends NovuBaseModule {
static register(options: typeof OPTIONS_TYPE, customProviders?: Provider[]) {
const superModule = NovuBaseModule.register(options);
superModule.controllers = [applyDecorators(NovuController, options.controllerDecorators || [])]; // ✅ applies them
superModule.providers?.push(registerApiPath, NovuClient, NovuHandler, ...(customProviders || []));
superModule.exports = [NovuClient, NovuHandler];
return superModule;
}
static registerAsync(options: typeof ASYNC_OPTIONS_TYPE, customProviders?: Provider[]) {
const superModule = NovuBaseModule.registerAsync(options);
superModule.controllers = [NovuController]; // ❌ option dropped
superModule.providers?.push(registerApiPath, NovuClient, NovuHandler, ...(customProviders || []));
superModule.exports = [NovuClient, NovuHandler];
return superModule;
}
}
register runs applyDecorators(NovuController, options.controllerDecorators || []), while registerAsync skips it and assigns NovuController directly.
Workaround
Apply the decorator to the exported NovuController class at module load time:
import { NovuController } from '@novu/framework/nest';
SomeDecorator()(NovuController);
This works because registerAsync reuses the same shared NovuController class, so the metadata persists through module resolution.
Type-level concern
The NovuModuleOptions type (servers/nest.ts) declares:
type NovuModuleOptions = ServeHandlerOptions & {
apiPath: string;
controllerDecorators?: ClassDecorator[];
};
…and registerAsync accepts ASYNC_OPTIONS_TYPE which extends this. So users passing controllerDecorators to registerAsync get full TS acceptance with no runtime effect — the most surprising kind of failure mode.
Description
NovuModule.registerAsync()from@novu/framework/nestaccepts acontrollerDecoratorsoption in its TypeScript types, but the runtime implementation drops it. Only the synchronousNovuModule.register()actually applies the decorators to the internalNovuControllerclass. The async variant ignores the option silently — no warning, no error, no type mismatch.This breaks any integration that needs to attach class decorators to the bridge controller while also needing async DI (e.g. resolving config from the Nest container instead of
process.env).Environment
@novu/framework: 2.10.0@nestjs/common: 11.1.19Steps to reproduce
Register the Novu bridge with
registerAsyncand passcontrollerDecorators:Expected
The decorators in
controllerDecoratorsare applied to the bridge controller, matching the behavior of the synchronousregister().Actual
The decorators are never applied.
Root cause
packages/framework/src/servers/nest/nest.module.ts:registerrunsapplyDecorators(NovuController, options.controllerDecorators || []), whileregisterAsyncskips it and assignsNovuControllerdirectly.Workaround
Apply the decorator to the exported
NovuControllerclass at module load time:This works because
registerAsyncreuses the same sharedNovuControllerclass, so the metadata persists through module resolution.Type-level concern
The
NovuModuleOptionstype (servers/nest.ts) declares:…and
registerAsyncacceptsASYNC_OPTIONS_TYPEwhich extends this. So users passingcontrollerDecoratorstoregisterAsyncget full TS acceptance with no runtime effect — the most surprising kind of failure mode.