Skip to content

🐛 Bug Report: NovuModule.registerAsync silently ignores controllerDecorators option (@novu/framework v2.10.0) #11009

@nikrabaev

Description

@nikrabaev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions