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, andcontrollersarrays - 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:
| Pattern | Example | Resolves To |
|---|---|---|
| Plain identifier | UsersModule | "UsersModule" |
| Dynamic module method | ConfigModule.forRoot({ ... }) | "ConfigModule" |
forwardRef | forwardRef(() => UsersModule) | "UsersModule" |
| Spread of variable | ...extraImports | contents of variable |
| Spread of function call | ...getCommonImports() | return value of function |
.concat() chain | [A].concat([B]) | both A and B |
| Function call as value | getImports() | return value of function |
| Variable as value | commonImports | contents of variable |
| Cross-file function call | getImports() (imported from ./helpers) | return value of function in other file |
| Cross-file variable | commonImports (imported from ./shared) | contents of variable in other file |
| Chained cross-file calls | getA() calls getB() in another file | recursively 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
returnstatements - Same-file variables: the resolver finds the
const/let/vardeclaration 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 andexport { 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 providertraceProviderEdges(fromModule, toModule, providers, providerToModule, project, files)— find which providers/controllers in one module depend on providers in another module. ReturnsProviderEdge[]with{ consumer, dependency }. Used byno-circular-module-depsto 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
importsgraph. Additionally,traceProviderEdgestraces 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
providerToModuleindex is built from@Module({ providers: [...] }). If a provider is not listed in any module's providers array, it will not appear in this index.