nestjs-doctorGitHub

Module Graph

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

What

Builds a directed dependency graph of NestJS @Module() classes and their relationships.

Why

Project-scoped rules need to understand module relationships. Circular dependency detection, unused export analysis, orphan module identification, and cross-module boundary violations all require a graph representation of the module system.

Input

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

Output

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
  classDeclaration: ClassDeclaration
  imports: string[]               // from @Module({ imports: [...] })
  exports: string[]               // from @Module({ exports: [...] })
  providers: string[]             // from @Module({ providers: [...] })
  controllers: string[]           // from @Module({ controllers: [...] })
}

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.

Import Resolution

The graph builder recursively resolves import expressions to extract module names from a variety of patterns commonly used in NestJS codebases.

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 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: if a function or variable is not found in the current file, the resolver follows import { name } from './other-file' declarations and export { name } from './other-file' re-exports to locate the definition in the external file, then continues resolving there
  • Unresolvable expressions (ternaries, computed values) gracefully return empty — no crash, the import is silently skipped

Why this matters:

Before this resolution engine, ConfigModule.forRoot(...) was stored as the raw string "ConfigModule.forRoot({ isGlobal: true })", which never matched a module key and silently dropped the edge. This caused:

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

Example — multiple patterns in one module:

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

Example — cross-file resolution chain:

When imports are split across multiple 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, AdminAuthModule

Circular Dependency Detection

findCircularDeps() uses DFS (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) — find which module owns a provider
  • traceProviderEdges(fromModule, toModule, providers, providerToModule, project, files) — find which providers/controllers in one module depend on providers in another module. 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 has the @Module() decorator and is in a file included by the file collector.
  • If a dynamically-imported module (e.g. via .forRoot()) does not appear in the graph edges, verify the method name is one of the recognized dynamic module methods listed above.
  • Circular dependency detection works on the imports graph. Additionally, traceProviderEdges traces provider-level dependencies across module boundaries, enabling concrete suggestions about which providers to extract to break a cycle.
  • If a cross-file function or variable is not resolved, verify the function/variable is exported from its source file and imported with a named import in the module file. Only relative path imports (starting with ./ or ../) are followed — bare specifiers (e.g., @nestjs/common) are skipped.
  • The providerToModule index is built from @Module({ providers: [...] }). If a provider is not listed in any module's providers array, it will not appear in this index.