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 analyzeThe 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, 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.
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.tsandlambda.tseach declare aBootstrapModulewith a different import list. The graph sees one node holding both lists, andfilePathsrecords every declaration file. - Different directories: the last declaration wins. Two unrelated
SharedModuleclasses 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/sharedis 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:
| 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 the 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: a name missing from the current file is looked up through
import { name } from './other-file'declarations andexport { 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, SessionModuleWhen 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. 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 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
importsgraph.traceProviderEdgesgoes 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/sharedimport resolves. A bare specifier matching no alias, like@nestjs/common, is skipped. - The
providerToModuleindex is built from@Module({ providers: [...] }). A provider absent from every module'sprovidersarray does not appear in this index.