Module graph building

Source: src/engine/graph/module-graph.ts

Builds a directed dependency graph of NestJS @Module() classes and their relationships. Project-scoped rules read it to answer questions no single file can: circular dependencies, unused exports, orphan modules, and cross-module boundaries.

Input is the parsed project and the file list:

project: Project     // ts-morph AST project
files: string[]      // file paths to analyze

The result is the graph plus a reverse index from provider to owning module:

interface ModuleGraph {
  modules: Map<string, ModuleNode>          // module name → node
  edges: Map<string, Set<string>>           // module → set of imported modules
  providerToModule: Map<string, ModuleNode> // provider name → owning module
}
 
interface ModuleNode {
  name: string                    // class name
  filePath: string
  filePaths?: string[]            // every declaration file when same-name variants were unioned
  line?: number                   // line of the class declaration
  classDeclaration: ClassDeclaration
  imports: string[]               // from @Module({ imports: [...] })
  exports: string[]               // from @Module({ exports: [...] })
  providers: string[]             // from @Module({ providers: [...] })
  controllers: string[]           // from @Module({ controllers: [...] })
  isGlobal: boolean               // class carries @Global()
  packageImports?: Record<string, string>  // import name → bare package specifier
}

How it works

Two-pass algorithm

Pass 1, module collection:

Scans every file for classes decorated with @Module(). For each module:

  • Extracts the decorator argument (the metadata object)
  • Parses imports, exports, providers, and controllers arrays
  • Creates a ModuleNode

Pass 2, edge building:

For each module's imports array, creates directed edges in the graph. Also builds the providerToModule reverse index, mapping each provider name back to its containing module.

Same-name modules

Two @Module() classes sharing one name are handled by directory:

  • Same directory: metadata is unioned. This is the multi-bootstrap pattern, where standalone.ts and lambda.ts each declare a BootstrapModule with a different import list. The graph sees one node holding both lists, and filePaths records every declaration file.
  • Different directories: the last declaration wins. Two unrelated SharedModule classes in different features are not merged.

Package imports

A bare package specifier is anything that is neither a relative path nor a tsconfig path alias. Names imported from one are recorded in packageImports alongside their specifier.

In a monorepo merge, such a name binds to a module in another sub-project under one condition. The specifier, or a parent of it, must itself be a scanned workspace package that declares the module.

  • import { SharedModule } from '@myorg/shared' creates a cross-project edge when @myorg/shared is a workspace package in the scan.
  • A module that happens to share its name with an npm export does not.

Import resolution

The graph builder resolves import expressions recursively to extract module names. It covers the patterns NestJS codebases use in practice.

Supported patterns:

PatternExampleResolves to
Plain identifierUsersModule"UsersModule"
Dynamic module methodConfigModule.forRoot({ ... })"ConfigModule"
forwardRefforwardRef(() => UsersModule)"UsersModule"
Spread of variable...extraImportscontents of variable
Spread of function call...getCommonImports()return value of function
.concat() chain[A].concat([B])both A and B
Function call as valuegetImports()return value of function
Variable as valuecommonImportscontents of variable
Cross-file function callgetImports() (imported from ./helpers)return value of function in other file
Cross-file variablecommonImports (imported from ./shared)contents of variable in other file
Chained cross-file callsgetA() calls getB() in another filerecursively follows the chain

Recognized dynamic module methods: forRoot, forRootAsync, forFeature, forFeatureAsync, forChild, forChildAsync, register, registerAsync.

Resolution rules:

  • Resolution is recursive, with a depth limit of 5.
  • Same-file functions: the resolver finds the function declaration, or the arrow function variable, and extracts identifiers from its return statements.
  • Same-file variables: the resolver finds the const/let/var declaration and resolves its initializer.
  • Cross-file resolution: a name missing from the current file is looked up through import { name } from './other-file' declarations and export { name } from './other-file' re-exports. The resolver then continues in that file.
  • Conditionals: a ternary, ?? or || contributes both sides.
  • Unresolvable expressions: other computed values return empty. The import is skipped, with no crash.

Before this resolution engine, ConfigModule.forRoot(...) was stored as the raw string "ConfigModule.forRoot({ isGlobal: true })". That string never matched a module key, so the edge was dropped silently. Three consequences followed:

  • False negatives in circular dependency detection
  • False positives in orphan module detection
  • False positives in unused export detection

One module can mix every supported pattern:

const commonImports = [SharedModule, LoggingModule];
 
function getAuthImports() {
  return [AuthModule, SessionModule];
}
 
@Module({
  imports: [
    UsersModule,                          // plain identifier
    ConfigModule.forRoot({ isGlobal: true }), // dynamic module method
    TypeOrmModule.forFeature([User]),      // dynamic module method
    forwardRef(() => OrderModule),        // forwardRef
    ...commonImports,                     // spread of variable
    ...getAuthImports(),                  // spread of function call
  ],
})
export class AppModule {}
 
// Graph builder extracts:
// → UsersModule, ConfigModule, TypeOrmModule, OrderModule,
//   SharedModule, LoggingModule, AuthModule, SessionModule

When imports are split across several files, the resolver follows the chain recursively:

app.module.ts
  imports: getServiceAppCommonImports({...}).concat([AdminAuthModule])
    ↓ follows import to libs/app-shared.ts
  getServiceAppCommonImports() → getAppCommonImports().concat([DatabaseModule])
    ↓ follows import to libs/common-imports.ts
  getAppCommonImports() → [ConfigModule.forRoot({...}), LoggerModule, HealthModule]

The graph builder resolves the full chain and extracts ConfigModule, LoggerModule, HealthModule, DatabaseModule, and AdminAuthModule.

Circular dependency detection

findCircularDeps() uses depth-first search with a recursion stack to detect cycles:

function findCircularDeps(graph: ModuleGraph): string[][] {
  // Returns array of cycles, each cycle is an array of module names
  // e.g., [["ModuleA", "ModuleB", "ModuleA"]]
}

Helper functions

  • findProviderModule(graph, providerName): finds which module owns a provider.
  • traceProviderEdges(fromModule, toModule, providers, providerToModule, project, files): finds which providers or controllers in one module depend on providers in another. Returns ProviderEdge[] with { consumer, dependency }. Used by no-circular-module-deps to generate concrete fix suggestions.

Debugging tips

  • If a module does not appear in the graph, verify that it carries the @Module() decorator. Check too that its file is included by the file collector.
  • If a dynamically imported module such as .forRoot() does not appear in the graph edges, check the method name. Only the recognized dynamic module methods listed above are resolved.
  • Circular dependency detection works on the imports graph. traceProviderEdges goes further and traces provider-level dependencies across module boundaries, which is what makes concrete extraction suggestions possible.
  • If a cross-file function or variable is not resolved, verify that it is exported from its source file. It must also be imported with a named import in the module file. Relative paths (./ or ../) are followed, and so are tsconfig path aliases, so an @app/shared import resolves. A bare specifier matching no alias, like @nestjs/common, is skipped.
  • The providerToModule index is built from @Module({ providers: [...] }). A provider absent from every module's providers array does not appear in this index.