nest icon indicating copy to clipboard operation
nest copied to clipboard

Ability To Cherry Pick/Exclude Controllers in Module Imports

Open ogheneovo12 opened this issue 5 months ago • 0 comments
trafficstars

Is there an existing issue that is already proposing this?

  • [x] I have searched the existing issues

Is your feature request related to a problem? Please describe it

I needed to use the same codebase to handle Core App and Admin App, The Codebase started with the core app, and i wasn't ready to refactor things to meet the recommend mono repo structure.

instead i created two application entry file, with respective root modules /apps/core and /apps/admin, configured the nestjs cli to point to them

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "projects": {
    "core-app": {
      "type": "application",
      "entryFile": "./apps/core/main",
      "compilerOptions": {
        "tsConfigPath": "src/apps/core/tsconfig.app.json"
      }
    },
    "admin-app": {
      "type": "application",
      "entryFile": "./apps/admin/main",
      "compilerOptions": {
        "tsConfigPath": "src/apps/admin/tsconfig.app.json"
      }
    }
  },
  "compilerOptions": {
    "deleteOutDir": true,
    "plugins": ["@nestjs/swagger"],
    "assets": ["mail/templates/**/*", "app-utils/data/**/*"],
    "watchAssets": true
  }
}

The first problem i encountered was importing modules used in Core App in the Admin App, will make the controllers spill over, this controllers would appear in the admin swagger documentation, i had a lot of issue trying to hide them from swagger.

Then i came up with a helper method to clone modules (with subclasses), so as not to override the original module class, while excluding the controllers in those modules.

import {
  DynamicModule,
  Type,
  ForwardReference,
  forwardRef,
} from '@nestjs/common';

const excludedModuleCache = new WeakMap<Type<any>, Type<any>>();

function createExcludedModule(module: Type<any>): Type<any> {
  if (excludedModuleCache.has(module)) {
    return excludedModuleCache.get(module)!;
  }

  // Create a subclass to avoid modifying the original module
  const ExcludedModule = class extends module {};

  // Copy existing metadata from the original module
  const metadataKeys = Reflect.getMetadataKeys(module);
  metadataKeys.forEach((key) => {
    const value = Reflect.getMetadata(key, module);
    Reflect.defineMetadata(key, value, ExcludedModule);
  });

  // Override controllers to be empty
  Reflect.defineMetadata('controllers', [], ExcludedModule);

  // Process imports recursively to replace with excluded modules
  const originalImports = Reflect.getMetadata('imports', ExcludedModule) || [];
  const processedImports = originalImports.map((importItem) => {
    if (typeof importItem === 'function') {
      return createExcludedModule(importItem);
    } else if ((importItem as ForwardReference).forwardRef) {
      const originalRef = (importItem as ForwardReference).forwardRef();
      const excludedRef = createExcludedModule(originalRef);
      return forwardRef(() => excludedRef);
    } else if ((importItem as DynamicModule).module) {
      const dynamicModule = importItem as DynamicModule;
      return {
        ...dynamicModule,
        module: createExcludedModule(dynamicModule.module),
      };
    }
    return importItem;
  });

  Reflect.defineMetadata('imports', processedImports, ExcludedModule);

  excludedModuleCache.set(module, ExcludedModule);
  return ExcludedModule;
}

export function excludeControllers(modules: Type<any>[]): DynamicModule[] {
  return modules.map((module) => {
    const ExcludedModule = createExcludedModule(module);
    return {
      module: ExcludedModule,
      imports: Reflect.getMetadata('imports', ExcludedModule) || [],
      providers: Reflect.getMetadata('providers', ExcludedModule) || [],
      exports: Reflect.getMetadata('exports', ExcludedModule) || [],
      controllers: [], // Explicit exclusion
    };
  });
}

//USAGE

@Module({
  imports: [
    ...excludeControllers([
      WorkspaceModule,
      WorkspaceMemberModule,
      MediaModule,
      SubscriptionPlanModule,
    ]),
    OfflinePaymentOptionsModule,
],
 providers: [AdminManagementService],
  exports: [],
})

This was working great until i had a circular dependency issue in the core app and i decided to use forwardRef, which broke the utility function on the admin side of things, refactored to use shared module, and i got the below error which i am still trying to resolve.

ERROR [ExceptionHandler] UnknownExportException [Error]: Nest cannot export a provider/module that is not a part of the currently processed module (ExcludedModule). Please verify whether the exported MongooseModule is available in this particular context.

Possible Solutions:

  • Is MongooseModule part of the relevant providers/imports within ExcludedModule?

Shared Module

@Module({
  imports: [
    MongooseModule.forFeature([
      {
        name: CohortParticipant.name,
        schema: CohortParticipantSchema,
      },
    ]),
  ],
  exports: [MongooseModule],
})
export class SharedSchemaModule {}

Describe the solution you'd like

It would have been great if such a method exists internally in the nestjs core modules

Teachability, documentation, adoption, migration strategy

//USAGE

@Module({
  imports: [
    ...excludeControllers([
      WorkspaceModule,
      WorkspaceMemberModule,
      MediaModule,
      SubscriptionPlanModule,
    ]),
    OfflinePaymentOptionsModule,
],
 providers: [AdminManagementService],
  exports: [],
})

What is the motivation / use case for changing the behavior?

Dynamically Exclude Controllers when importing modules into other modules

ogheneovo12 avatar Jun 14 '25 20:06 ogheneovo12