Debugging & Devtools

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), @nestjs/devtools-integration 12.0.0, TypeScript 6 (6.0.3), Node.js 24 (20.19+ and 22.12+ also run a v12 application), Express 5 and Fastify 5, and Madge 8.0, all checked on 2026-10-02. The examples use the Nest 12 CLI project layout (ESM template, Vitest and supertest, plus plain tsc builds for the parts that must run as real Node.js ESM or CommonJS). The Devtools application at devtools.nestjs.com is a hosted service: this page describes what works locally (the snapshot option, the integration package’s introspection server and sandbox, the REPL, graph files, the request that GraphPublisher sends, Madge) from the running code, and what depends on the hosted service from its documentation, as set out in Free versus paid.

Most NestJS bugs are not logic bugs. They are wiring bugs: a provider that is not in the module that needs it, two files that import each other, a type that disappeared at compile time. They show up at start-up, with a long message, before the first request. This page teaches you to read those messages, to ask Nest to explain itself (NEST_DEBUG, the Devtools graph, the REPL), to stop at a breakpoint when the bug is in your own code, and to catch whole classes of wiring bugs before they ship (Madge, Devtools in CI). The running example is the small Bookshelf app that grows across this section.

Common errors

Each of these appears with Test.createTestingModule() as well as with NestFactory, and the messages below are the ones Nest 12.1.2 prints.

"Nest can’t resolve dependencies of the …​"

This is UnknownDependenciesException, and it is the most common start-up failure. Take a BooksService that needs a GenresService that lives in another module:

genres.module.ts
import { Module } from '@nestjs/common';
import { GenresService } from './genres.service.js';

// Forgot `exports: [GenresService]`
@Module({ providers: [GenresService] })
export class GenresModule {}
books.module.ts
import { Module } from '@nestjs/common';
import { BooksService } from './books.service.js';

// BooksService needs GenresService, but GenresModule is not imported here.
@Module({ providers: [BooksService] })
export class BooksModule {}

Starting an application that imports both modules fails with:

Output
UnknownDependenciesException: Nest can't resolve dependencies of the BooksService (?). Please make sure that the argument GenresService at index [0] is available in the BooksModule module.

Potential solutions:
- Is BooksModule a valid NestJS module?
- If GenresService is a provider, is it part of the current BooksModule?
- If GenresService is exported from a separate @Module, is that module imported within BooksModule?
  @Module({
    imports: [ /* the Module containing GenresService */ ]
  })

For more common dependency resolution issues, see: https://docs.nestjs.com/faq/common-errors

Read it in this order: the first line names the consumer (BooksService), the position of the unresolved constructor parameter ((?) stands for the one that failed, and index [0] is its position), and the token that could not be found (GenresService), and the module in which Nest looked (BooksModule). Nest searches the consumer’s own module, then the modules it imports (only their exports), then global modules. For a provider in another module there are therefore always two conditions, and the message cannot tell you which one you broke: the provider’s module must exports it, and the consumer’s module must imports that module. The fix is both halves:

import { Module } from '@nestjs/common';
import { BooksService } from './books.service.js';
import { GenresService } from './genres.service.js';

@Module({ providers: [GenresService], exports: [GenresService] })
export class GenresModule {}

@Module({ imports: [GenresModule], providers: [BooksService] })
export class BooksModule {}

The same exception has other causes. The causes below were each reproduced:

What the message says Usual cause Fix

the argument X at index [0] is available in the Y module

X is not in Y’s `providers, or its module is not imported, or not exported.

Register it, or import and export it as above. Do not copy the provider into a second module: you get two instances, and a duplicate that is registered in both a feature and the root module is instantiated twice.

argument SelfService at index [0] where the token equals the consumer

A provider injects itself.

Remove the constructor parameter. Self-injection is not allowed.

the argument at index [0] is available in the current module (no token name) and appears to be undefined at runtime

The parameter type is erased at runtime: an interface, a type alias, or a class imported with import type. TypeScript emits Object or nothing, so Nest has no token.

Inject a class, or use a custom token with @Inject(). Import classes with a normal import.

"ClockService" is decorated with @Injectable() and cannot appear in the "imports" array

A provider was put in imports (InvalidClassModuleException).

Move it to providers.

The "no token name" variant:

import { Injectable, Module } from '@nestjs/common';

export interface Mailer {
  send(to: string, text: string): void;
}

@Injectable()
export class NotifierByInterface {
  // The interface is erased at compile time: Nest sees `Object`
  constructor(private readonly mailer: Mailer) {}
}

@Module({ providers: [NotifierByInterface] })
export class InterfaceModule {}
Output
UnknownDependenciesException: Nest can't resolve dependencies of the NotifierByInterface (?). Please make sure that the argument at index [0] is available in the current module.

Potential solutions:
- The dependency at index [0] appears to be undefined at runtime
- This commonly occurs when using 'import type' instead of 'import' for injectable classes
...

The import type form produced exactly the same message. Why TypeScript erases these types, and what emitDecoratorMetadata records, is explained in TypeScript for NestJS; custom tokens are covered in Providers & Dependency Injection. A last variant appears in monorepos: the missing token is ModuleRef, which means two copies of @nestjs/core are installed (one nested under a library’s node_modules). Make the package manager resolve a single copy (Yarn nohoist, or a peerDependencies entry plus dependenciesMeta.injected with pnpm), as the Nest FAQ describes.

Seeing the error at all

By default a failure while the application is created makes Nest print the error and call process.exit(1). With logger: false the process exits with status 1 and prints nothing, which is a confusing way to meet this exception. Pass abortOnError: false to receive the exception as a rejected promise you can handle (this is also what the Devtools partial-graph workflow needs, see below), and keep the logger enabled while you debug:

import { NestFactory } from '@nestjs/core';

try {
  await NestFactory.create(BrokenAppModule, { logger: false, abortOnError: false });
} catch (error) {
  // UnknownDependenciesException: Nest can't resolve dependencies of the BooksService (?) ...
  console.error(error);
}

Source: NestJS: First steps - the abortOnError hint, with the behaviour confirmed in Getting Started & CLI.

NEST_DEBUG: watching the injector work

Set the NEST_DEBUG environment variable to any non-empty string and Nest logs every dependency lookup while it builds the container:

NEST_DEBUG=true npm run start

For the working Bookshelf books module the output (colours omitted) is:

Output
[Nest] 4916  - 10/02/2026, 7:14:15 PM     LOG [InjectorLogger] Resolving dependency BooksRepository in the BooksService provider
[Nest] 4916  - 10/02/2026, 7:14:15 PM     LOG [InjectorLogger] Looking for BooksRepository in BooksModule
[Nest] 4916  - 10/02/2026, 7:14:15 PM     LOG [InjectorLogger] Found BooksRepository in BooksModule
[Nest] 4916  - 10/02/2026, 7:14:15 PM     LOG [InstanceLoader] BookshelfPlainModule dependencies initialized +0ms
[Nest] 4916  - 10/02/2026, 7:14:15 PM     LOG [InjectorLogger] Resolving dependency BooksService in the BooksController provider
[Nest] 4916  - 10/02/2026, 7:14:15 PM     LOG [InjectorLogger] Looking for BooksService in BooksModule
[Nest] 4916  - 10/02/2026, 7:14:15 PM     LOG [InjectorLogger] Found BooksService in BooksModule

and for the broken example above the trace ends where the answer is:

Output
LOG [InjectorLogger] Resolving dependency GenresService in the BooksService provider
LOG [InjectorLogger] Looking for GenresService in BooksModule
LOG [InjectorLogger] Looking for GenresService in InternalCoreModule

There is no Found GenresService line, and the only modules searched are BooksModule and the global InternalCoreModule: the importing module never looked in GenresModule because it does not import it. Each line gives the host class of the dependency being injected, the dependency’s name or token and the module being searched. The variable is read on every lookup, so it can be set only for the failing run, and it is a debugging aid: do not leave it on in production, because it logs a line per injected dependency.

Circular dependencies

There are two different cycles and Nest reports them differently.

Providers that need each other in their constructors fail with the UnknownDependenciesException above, because one of the two classes is not yet defined when the other asks for it. Modules that import each other fail while the module graph is scanned. In a CommonJS build or under Vitest the message is:

Output
UndefinedModuleException: Nest cannot create the ReviewsModule instance.
The module at index [0] of the ReviewsModule "imports" array is undefined.

Potential causes:
- A circular dependency between modules. Use forwardRef() to avoid it. Read more: https://docs.nestjs.com/fundamentals/circular-dependency
- The module at index [0] is of type "undefined". Check your import statements and the type of the module.

Scope [RootTestModule -> CatalogModule]

The Scope […​] line is the import chain that led to the failure, which tells you which modules are involved. In an ESM application compiled with tsc and started with plain Node.js, the same two modules never reach Nest at all: the process dies earlier with

Output
ReferenceError: Cannot access 'CatalogModule' before initialization

because the compiled class is a let binding that is still in its temporal dead zone when the other file’s decorator reads it. A ReferenceError naming a module or provider class at start-up is the ESM face of the same problem, and Nest’s message is not involved in it. The module cycle used for both outputs:

catalog.module.ts
import { Module } from '@nestjs/common';
import { ReviewsModule } from './reviews.module.js';

@Module({ imports: [ReviewsModule] })
export class CatalogModule {}
reviews.module.ts
import { Module } from '@nestjs/common';
import { CatalogModule } from './catalog.module.js';

@Module({ imports: [CatalogModule] })
export class ReviewsModule {}

Fixes, in order of preference: remove the cycle by moving the shared code to a third module or provider; for constants that two files need, move them to a separate file (a module file that exports a constant imported by a service is the classic accidental cycle); only if the dependency is real, wrap both sides, modules and providers, in forwardRef():

import { Module, forwardRef } from '@nestjs/common';
import { ReviewsFixedModule } from './reviews-fixed.module.js';

@Module({ imports: [forwardRef(() => ReviewsFixedModule)] })
export class CatalogFixedModule {}

With forwardRef() on both sides the application starts. The full treatment (provider cycles, ModuleRef as the alternative, why a cycle is usually a design smell) is in ModuleRef, Circular Dependencies & Lazy Loading; Madge below finds cycles before start-up does.

"File change detected" loops endlessly

On Windows with TypeScript 4.9 or later, npm run start:dev (nest start --watch, which runs the TypeScript compiler in watch mode) can repeat these two lines forever without any change on disk:

XX:XX:XX AM - File change detected. Starting incremental compilation...
XX:XX:XX AM - Found 0 errors. Watching for file changes.

TypeScript 4.9 switched to file-system events for file watching, and on some machines that produces spurious change events. Tell the compiler to poll instead, in tsconfig.json after compilerOptions:

{
  "compilerOptions": {
    "outDir": "./dist"
  },
  "watchOptions": {
    "watchFile": "fixedPollingInterval"
  }
}

The loop itself is platform-specific (the official page describes it for Windows users on TypeScript 4.9 and later); the watchOptions block is accepted by TypeScript 6.0.3 (tsc -p exits with status 0 on a project with the setting) and is a TypeScript option, not a Nest one. Two other causes of an endless rebuild are worth ruling out first: a tool that writes into a watched folder (a logger or an upload directory inside src, or generated files written next to sources) and an output directory that the watcher itself reads. The SWC and Rspack builders have their own watchers; see Getting Started & CLI for them and for watchAssets.

Debugging in an IDE

When the problem is in your own code and not in the wiring, use the Node.js inspector. nest start --debug starts the compiled application with --inspect; the start:debug script that nest new generates adds --watch. The inspector listens on port 9229 on the loopback address by default; see Getting Started & CLI for the flag, a host:port value and the security note about binding it to 0.0.0.0 (the inspector protocol allows anyone who can reach the port to run code in your process).

VS Code can attach to that process, or start it itself. The two configurations below are valid launch.json files (checked by parsing them):

Attach to a process started with npm run start:debug
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "attach",
      "name": "Attach to Nest",
      "port": 9229,
      "restart": true
    }
  ]
}
Let the IDE run the debug script
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Nest (start:debug)",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "start:debug"],
      "console": "integratedTerminal",
      "restart": true
    }
  ]
}

WebStorm (and the other JetBrains IDEs) offers a "Node.js" run configuration for starting an npm script and an "Attach to Node.js/Chrome" configuration for the same port 9229. The attach flow is an IDE feature; see the JetBrains documentation for its settings. Chrome DevTools can attach too: open chrome://inspect and pick the target.

Breakpoints in .ts files work because the generated tsconfig.json sets sourceMap: true, so the debugger maps dist/*.js back to the sources. The same maps fix stack traces outside a debugger: Node.js ignores them unless asked. With a service that throws, compiled by tsc with sourceMap, the trace points at the compiled file by default:

Output of node run-fail.js
Error: Cannot lend Dune
    at explode (.../debugging-and-devtools/app/fail.js:3:11)

and at the TypeScript source with the --enable-source-maps flag (or NODE_OPTIONS=--enable-source-maps):

Output of node --enable-source-maps run-fail.js
Error: Cannot lend Dune
    at explode (.../src/debugging-and-devtools/app/fail.ts:5:9)

Two practical points. Debug an end-to-end test the same way: the ESM template’s test:debug script runs Vitest with --inspect-brk, which pauses before the first line so you can attach, and the CommonJS (Jest) one adds --runInBand so the tests run in the process you debug. And when the CLI starts a production build, make sure you attach to the SWC or tsc output that actually carries source maps (see the build settings in Getting Started & CLI). For startup-time problems, console.log is rarely the right tool; a logger with levels (Logging) or one of the introspection tools below is.

Nest Devtools

Nest Devtools has two halves that you must keep apart:

  • The integration, open source and installed in your application: the snapshot: true option of NestFactory, the classes exported from @nestjs/core that serialize the container (SerializedGraph, PartialGraphHost), and the package @nestjs/devtools-integration (DevtoolsModule, GraphPublisher).

  • The application, a hosted web UI at https://devtools.nestjs.com that draws what the integration exposes and stores published graphs.

flowchart LR subgraph local["Your machine"] app["Nest app
snapshot: true"] --> mod["DevtoolsModule
HTTP server :8000"] browser["Browser tab
devtools.nestjs.com"] end browser -- "GET /inspector/graph/snapshot
POST /inspector/graph/interact" --> mod subgraph ci["CI/CD"] build["npm run start
PUBLISH_GRAPH=true"] --> pub["GraphPublisher"] end pub -- "POST /organizations/.../builds" --> registry[("Devtools registry
graphs and reports")] registry --> ui["Previews and PR reports"]

The local half is free to use as the docs present it; the CI/CD half is the one that the docs mark as an Enterprise feature (see Free versus paid).

Setup: snapshot and DevtoolsModule

Install the integration package, turn on snapshot and register the module:

npm i @nestjs/devtools-integration
main.ts
import { NestFactory } from '@nestjs/core';
import { BookshelfModule } from './app/bookshelf.module.js';

async function bootstrap() {
  const app = await NestFactory.create(BookshelfModule, {
    snapshot: true, // collect the metadata Devtools needs
  });
  await app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
bookshelf.module.ts
import { Module } from '@nestjs/common';
import { DevtoolsModule } from '@nestjs/devtools-integration';
import { BooksModule } from './books.module.js';

@Module({
  imports: [
    BooksModule,
    DevtoolsModule.register({
      // Never expose the introspection server in production
      http: process.env.NODE_ENV !== 'production',
      port: Number(process.env.DEVTOOLS_PORT ?? 8000),
    }),
  ],
})
export class BookshelfModule {}

register() accepts two options, both optional (checked in the 12.0.0 typings): http (boolean) decides whether the module starts its introspection HTTP server at all, and port (default 8000) is where it listens. With http falsy the module exposes nothing, which is the safety net the docs recommend; the example above reads the port from the environment to avoid clashes between two apps on one machine. registerAsync() and register() both exist because the module is built with Nest’s configurable-module builder (see Dynamic Modules); registering DevtoolsModule without calling either throws "DevtoolsModule should be initialized with either "register" or "registerAsync" method."

Running the compiled application, the server behaved like this:

PORT=3917 DEVTOOLS_PORT=8917 node dist/main.js
curl -s http://127.0.0.1:3917/books
curl -s -H 'Origin: https://devtools.nestjs.com' http://127.0.0.1:8917/inspector/graph/snapshot
[{"id":1,"title":"Dune"},{"id":2,"title":"Emma"}]

The second call returns the serialized graph (a JSON object with nodes, edges, entrypoints, extras and status). The console also prints Sandbox session token: …​ about a second after start (the value is random per start and is shown here as <token>), followed by Use this token to interact with Devtools Playground. Started with NODE_ENV=production, nothing listens on the Devtools port.

The overhead is small, because snapshot: true collects the graph once while the application bootstraps and does nothing per request. Without snapshot: true the serialized graph exists but is empty (nodes=0 edges=0), so a Devtools page that shows an empty graph almost always means the option is missing. Devtools is a development tool. Treat the HTTP server like a debugger port: it exposes your container’s structure and (through the sandbox) can run code against it, so keep http off in any shared or deployed environment.

What the local server accepts

The integration package source (12.0.0) shows a deliberately narrow server, which is useful to know when you review it for a corporate environment:

  • Only two routes exist: GET /inspector/graph/snapshot and POST /inspector/graph/interact. Anything else answers 405.

  • Both check the Origin header against an allow-list of https://devtools.nestjs.com and http://localhost:4000 (the second is for developing the application itself). A request without an origin, or from another origin, gets 403 ("Request blocked to prevent DNS rebinding attacks"). A plain curl without Origin gets 403, one with Origin: https://evil.example gets 403, and one with the Devtools origin gets 200.

  • The response carries Access-Control-Allow-Origin for the Devtools application URL only (overridable with the DEVTOOLS_APP_URL environment variable, an implementation detail that the docs do not describe).

  • POST /inspector/graph/interact (the playground) additionally needs Content-Type: application/json and the x-sandbox-session-token header, compared in constant time with a random 32-byte token generated at each start. Without the header the answer is 401.

Judging from these rules (a browser page on the allowed origin calls your localhost server), the local views read the graph straight from your machine. The documentation does not say whether the hosted application stores anything it reads.

Source: NestJS: Devtools - Playground (the session token); the origin and token checks are the behaviour of the running integration server.

Graph explorer

With the application running (npm run start:dev), open Devtools and it draws the modules, providers and controllers as an interactive graph with two views, modules and classes. What the local HTTP endpoint serves is plain JSON, so you can see what the graph is made of. For the Bookshelf books module plus Devtools itself, the snapshot had 29 nodes and a node per class like this:

{
  "type": "provider",
  "internal": false,
  "sourceModuleName": "BooksModule",
  "durable": false,
  "static": true,
  "transient": false,
  "exported": true,
  "token": "BooksService",
  "initTime": 0.756
}

Edges are typed: module-to-module for imports and class-to-class for constructor (or property) injections, with the injection keyOrIndex and injectionType. entrypoints lists every handler (type: "http-endpoint", with requestMethod and path); extras holds the enhancers (guards, interceptors, pipes) that are attached or orphaned.

Things to know in the UI, from the docs:

  • Every module is connected to InternalCoreModule, a global module Nest always imports into the root module; tick Hide global modules to remove that noise.

  • Click a node to Focus it (isolate it) or to Inspect it (jump to the debug page with the node preselected); the sidebar search jumps to a node; the sidebar controls limit how far from the selected node edges are drawn, which is how you look at one subtree, for example before extracting a module into its own service.

  • Export as PNG saves the current view for a pull request or a document.

  • The graph is live: refresh after a change to modules or providers and it is redrawn, so the graph is the documentation that cannot go stale.

A graph for an application that does not start

The graph for a failed bootstrap is the most useful one. When start-up dies with "Cannot resolve dependency", Nest keeps what it had built (a partial graph) and PartialGraphHost (exported from @nestjs/core) can serialize it. It needs snapshot: true and abortOnError: false (see above):

main.ts
import { NestFactory, PartialGraphHost } from '@nestjs/core';
import { writeFileSync } from 'node:fs';
import { BrokenAppModule } from './errors/app.module.js';

async function bootstrap() {
  const app = await NestFactory.create(BrokenAppModule, {
    snapshot: true,
    abortOnError: false, // let the error reach our catch handler
  });
  await app.listen(process.env.PORT ?? 3000);
}

bootstrap().catch((err) => {
  console.error(err.constructor.name);
  writeFileSync('graph.json', PartialGraphHost.toString() ?? '');
  process.exit(1);
});

Source: NestJS: Devtools - Debugging "Cannot resolve dependency" errors (available from @nestjs/core 9.3.10).

Running the compiled file printed UnknownDependenciesException, exited with status 1 and left a graph.json. Its top-level fields are nodes, edges, entrypoints, extras, status and metadata, with status set to partial and metadata.cause describing the failure:

{
  "type": "unknown-dependencies",
  "context": { "index": 0, "dependencies": ["GenresService"], "name": "GenresService" },
  "moduleId": "-1644780919",
  "nodeId": "-971129877"
}

Drop the file into the Devtools application in Preview mode (switch from "Interactive" to "Preview" first) and it highlights the module and the class (BooksService here) and says which dependency was unavailable; the Classes view shows that GenresService is simply not reachable from BooksModule. The file contents above were inspected locally; the rendered dialog needs the hosted application and was not seen.

Saving a graph from a running application

To keep a graph for a bug report or to read it offline, serialize it yourself with SerializedGraph:

import { INestApplication } from '@nestjs/common';
import { SerializedGraph } from '@nestjs/core';
import { writeFileSync } from 'node:fs';

/** Save the graph of a running (or initialised) application for offline preview. */
export function saveGraph(app: INestApplication, file = './graph.json'): void {
  writeFileSync(file, app.get(SerializedGraph).toString());
}

Call it after app.listen() or app.init(), so that every provider is in the graph. Upload the file to the Devtools application the same way. The file is JSON, which makes it scriptable (see Bootstrap performance). It lists your module and class names and routes; review it before attaching it to a public issue.

Routes explorer

The Routes explorer page lists every entrypoint the application registers, grouped by host controller: HTTP routes, and also WebSocket handlers, gRPC methods, GraphQL resolvers and so on. Selecting one shows a flow graph of its whole execution path: every guard, interceptor and pipe bound to that route, in the order Nest will actually run them. It is the quickest way to answer "why does this guard not run here?" or "is this route covered by the global pipe?", especially on an application where the same guard is applied on the controller in one place and on a route in another. The order it draws is the request lifecycle described in Request Lifecycle.

The data behind it is in the snapshot. For GET /books the local server returned:

{
  "1887481872": [
    {
      "type": "http-endpoint",
      "methodName": "findAll",
      "className": "BooksController",
      "classNodeId": "1887481872",
      "metadata": { "key": "/books", "path": "/books", "requestMethod": "GET" },
      "id": "1887481872_findAll"
    }
  ]
}

Playground

The Playground page runs code against the live application, without a rebuild or a restart and without a separate HTTP client. The docs list the benefits: you can call a provider directly (so you skip the authentication layer and the login step), trigger events in an event-driven application, and see everything your code logs streamed to the Playground’s console. Requests are authenticated with the sandbox session token that DevtoolsModule prints on start (Sandbox session token: …​), which you give to the Devtools application.

Because the playground is just the POST /inspector/graph/interact route, its behaviour can be tested with curl-like requests. With the token printed at start, the following code sent to the sandbox returns the output below:

console.log(get($.BooksService).findAll());
table(get($.BooksService).findAll());
Output
[ { id: 1, title: 'Dune' }, { id: 2, title: 'Emma' } ]
<DEVTOOLS_table_start>[{"id":1,"title":"Dune"},{"id":2,"title":"Emma"}]<DEVTOOLS_table_end>

The sandbox exposes $ (a map from class names to node ids; console.log($) printed BooksService, BooksController, BooksRepository and the others), get(id), resolve(id, contextId) for transient and request-scoped providers, methods(id), console.log/warn/error/table and a global table() that the UI renders as a table (the <DEVTOOLS_table_start> markers). It is a JavaScript sandbox, not Node.js: process is not defined in it (ReferenceError: process is not defined), and module nodes and Nest-internal nodes cannot be fetched. get() fails for request-scoped providers (use resolve()), the same rule as the REPL (below).

The sandbox executes code inside your running application, so what it can reach is what your providers can reach. Treat the session token like a password for that process: it is regenerated at each start, never commit it, and never enable http where someone else can reach the port.

Source: NestJS: Devtools - Playground. The sandbox API ($, get, resolve, methods, table) comes from the 12.0.0 package source and works against a running server; the Playground UI is part of the hosted application.

Bootstrap performance

The Bootstrap performance page lists every class node (controllers, providers, enhancers) with its instantiation time, sorted so that the slowest stand out. It matters most where start-up is on the critical path, such as serverless cold starts (see Platform Agnosticism & Standalone Apps). The numbers come from the graph: every class node carries metadata.initTime in milliseconds, so you can get the same table from a saved graph without the UI:

import { readFileSync } from 'node:fs';

interface GraphNode {
  label: string;
  metadata: { type: string; internal?: boolean; initTime?: number; sourceModuleName?: string };
}

/** The slowest-to-instantiate application classes recorded in a saved graph. */
export function slowestProviders(file: string, top = 5) {
  const graph = JSON.parse(readFileSync(file, 'utf8')) as { nodes: Record<string, GraphNode> };
  return Object.values(graph.nodes)
    .filter((n) => n.metadata.type !== 'module' && !n.metadata.internal && n.metadata.initTime !== undefined)
    .map((n) => ({ class: n.label, module: n.metadata.sourceModuleName, ms: Number(n.metadata.initTime!.toFixed(3)) }))
    .sort((a, b) => b.ms - a.ms)
    .slice(0, top);
}
Output for the books module plus a trivial extra provider
[
  { "class": "BooksService", "module": "BooksModule", "ms": 0.571 },
  { "class": "BooksRepository", "module": "BooksModule", "ms": 0.412 },
  { "class": "SlowService", "module": "SlowModule", "ms": 0.4 },
  { "class": "BooksController", "module": "BooksModule", "ms": 0.033 }
]

Source: NestJS: Devtools - Bootstrap performance analyzer. initTime is a field of the serialized graph in 12.0.0, not documented, so treat the script as a convenience that may change.

Read the numbers with these experiments in mind. A constructor that busy-waits 30 ms and a provider with an async factory that waits 30 ms both showed about 31 ms. A provider whose onModuleInit() waited 30 ms, alone in the application, showed 0.5 ms: the figure measures instantiation, not lifecycle hooks (measure those with a timer, or see Lifecycle Events & Shutdown). And Nest instantiates providers concurrently, so when the slow factory and the hook were in the same application the hook’s entry also read 31 ms, inflated by its slow neighbour. Use the page to find candidates and then confirm one at a time, for example by temporarily removing the suspect. Common causes of slow bootstrap are heavy synchronous work in a constructor, an async factory that waits for a slow network call, and modules that instantiate far more than they use (the lazy-loading remedy is in ModuleRef, Circular Dependencies & Lazy Loading).

Audit

The Audit page analyses the serialized graph and lists errors, warnings and hints, like a linter for the application’s architecture. The docs name some of the built-in rules: a controller with many more routes than its neighbours, a module that pulls in an unusually large number of dependencies, a provider named like a guard (SomethingGuard) that was never registered as one, and a request-scoped provider that is a candidate for durable providers (see Injection Scopes). The list is a sample, and the rules run in the hosted application, so the exact rule set was not verified here. It finds problems your tests do not, such as a guard that exists but is not bound, which is the kind of mistake that is silent until it matters.

CI/CD integration: publishing graphs

Locally Devtools shows the current state. In CI it can keep a history: every build publishes its graph to a registry, and a pull request gets a report comparing its graph with the target branch’s, with added nodes in green, updated in light white and removed in red. A removed guard, a provider whose scope changed, a module made global (a burst of new edges) all show up as structural changes that a code review may miss. The registry, reports and build previews are hosted features, and the docs mark them as Enterprise (see Free versus paid).

The application-side change is GraphPublisher, exported from @nestjs/devtools-integration:

main.ts
import { NestFactory } from '@nestjs/core';
import { GraphPublisher } from '@nestjs/devtools-integration';
import { BookshelfModule } from './app/bookshelf.module.js';
import { githubPublishOptions } from './publish.js';

async function bootstrap() {
  const shouldPublishGraph = process.env.PUBLISH_GRAPH === 'true';

  const app = await NestFactory.create(BookshelfModule, {
    snapshot: true,
    preview: shouldPublishGraph,
  });

  if (shouldPublishGraph) {
    await app.init();
    await new GraphPublisher(app).publish(githubPublishOptions());
    await app.close();
  } else {
    await app.listen(process.env.PORT ?? 3000);
  }
}
await bootstrap();

PUBLISH_GRAPH is a variable of your own that tells a CI run from a normal start. preview: true bootstraps the application in preview mode: providers, controllers and enhancers are not instantiated, so their constructors and lifecycle hooks do not run and no database connection is needed, while the graph still has its routes. This was verified directly: a provider whose constructor and onModuleInit push to a list produced [] in preview mode and ["constructor","onModuleInit"] in a normal start. Nest prints a PREVIEW mode warning, and the Devtools HTTP server is not started (no sandbox token appears), so a CI publish does not open port 8000. preview is a general NestFactory option, also used to build documents offline (see OpenAPI & Swagger).

publish() takes a PublishGraphOptions object. Its fields, from the 12.0.0 typings:

Field Required Meaning

apiKey

yes

The project’s Devtools API key (generated in the hosted application; keep it in a CI secret).

owner

yes (checked at run time)

The organization or team slug, or the username for a personal project.

repository

yes (checked at run time)

The repository name (the project slug).

sha

yes (checked at run time)

The commit that triggered the pipeline; becomes the build’s hash.

branch

yes (checked at run time)

The current branch name.

trigger

yes

'pull' or 'push'.

target

no

The commit SHA of the build to compare against (for example the tip of main). The new build is compared to it to produce the report.

targetBranch

no

For CI systems that do not give you a target SHA: the last published commit of that branch is used instead.

A helper that maps GitHub Actions' environment into those options (the variable names are the docs' and are a convention, not an API):

import { PublishGraphOptions } from '@nestjs/devtools-integration';

export function githubPublishOptions(env: NodeJS.ProcessEnv = process.env): PublishGraphOptions {
  return {
    apiKey: env.DEVTOOLS_API_KEY ?? '',
    repository: env.REPOSITORY_NAME ?? '',
    owner: env.GITHUB_REPOSITORY_OWNER ?? '',
    sha: env.COMMIT_SHA ?? '',
    target: env.TARGET_SHA,
    trigger: env.GITHUB_BASE_REF ? 'pull' : 'push',
    branch: env.BRANCH_NAME ?? '',
  };
}

The GitHub Actions workflow publishes on pushes to the main branch and on every pull request, and takes the key from a repository secret (the docs' sample has a placeholder where this has the secret reference; GitLab CI and any other CI work the same way with their own variables, see the docs for the GitLab pipeline and for Bitbucket and CircleCI notes):

.github/workflows/publish-graph.yml
name: Devtools

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - '*'

jobs:
  publish:
    if: github.actor != 'dependabot[bot]'
    name: Publish graph
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - name: Install dependencies
        run: npm ci
      - name: Setup environment (pull request)
        if: github.event_name == 'pull_request'
        run: echo "COMMIT_SHA=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_ENV"
      - name: Setup environment (push)
        if: github.event_name == 'push'
        run: echo "COMMIT_SHA=$GITHUB_SHA" >> "$GITHUB_ENV"
      - name: Publish
        run: PUBLISH_GRAPH=true npm run start
        env:
          DEVTOOLS_API_KEY: ${{ secrets.DEVTOOLS_API_KEY }}
          REPOSITORY_NAME: ${{ github.event.repository.name }}
          BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
          TARGET_SHA: ${{ github.event.pull_request.base.sha }}

Source: NestJS: Devtools - CI/CD integration, GitHub Actions. The docs name the main branch master; this page uses main, so adapt it to your default branch. The docs recommend triggering push only for branches that represent a deployment environment (main, staging, production) and pull always, or at least for pull requests that target such a branch, so the registry holds a snapshot of every deployable state and every pull request has something to be compared with. No report is produced when no graph exists for the target.

What the publish step sends can be checked without an account. The integration package reads the registry address from an environment variable (DEVTOOLS_API_URL, not documented, defaulting to the hosted API), so the publish step can run against a local stand-in server. The compiled main.ts above, started with PUBLISH_GRAPH=true and the environment variables set, sends exactly one request, POST /organizations/acme/projects/bookshelf-api/builds with an Authorization: ApiKey …​ header and a JSON body with the keys branch, trigger, snapshot, hash and target. The snapshot field is the serialized graph, deflate-compressed and base64-encoded (inflating it gives back nodes, edges, entrypoints, extras, status). branch, trigger, hash (the sha) and target carry the option values. A 401 answer from the server makes the publisher print "API returned 401 status code. Please check your API key." and the response text, and a missing owner, repository, sha or branch throws GraphPublisher: "owner" parameter is required (and so on). Note what that implies for CI: a non-2xx answer is printed, not thrown, so the step does not fail the job on a 401, and you should check the log line, or wrap the call, if a failed publish must fail the pipeline. The real registry, API keys, report generation, the GitHub app, build previews and the responses of the hosted API need an Enterprise account, so the description of those parts comes from the documentation.

Free versus paid

This is the part that cannot be checked from code, so it is stated as what the documentation says, next to what you can check locally:

Capability What the docs say What you can check locally

snapshot: true, DevtoolsModule (local HTTP server), PartialGraphHost, SerializedGraph, saving graph files

Open-source integration in your application; no plan is mentioned for local use.

Everything: all of it runs locally with no account (graph JSON, server, token, partial graph).

Graph explorer, routes explorer, playground, bootstrap performance, audit

Presented in the Overview chapter as the local workflow: connect your running app to the Devtools application and open the pages.

The data and the local server. The pages themselves are rendered by the hosted application. The docs do not say whether any of them needs an account or has limits.

CI/CD integration: GraphPublisher, the registry, reports, build previews, the GitHub application

The chapter’s first note says CI/CD integration is available on the Enterprise plan, and an API key is generated for the project in the hosted application.

The request sent by GraphPublisher, against a local stand-in. Acceptance by the real service, and the plans themselves, need the hosted service.

The Devtools home page is a single-page application that exposes no readable plan or pricing text to an automated fetch, so this page does not quote prices or plan limits. Check https://devtools.nestjs.com directly before you plan around a feature; and keep in mind the docs' own framing that the Devtools application is separate from the integration in your code, so removing the paid part never removes the local diagnostics.

The REPL

Devtools needs a browser and a hosted UI; the REPL needs only a terminal. It starts the application context (no HTTP server, no listening port) and gives you a Node.js REPL in which you can fetch providers and call their methods. Create an entry file next to main.ts:

repl.ts
import { repl } from '@nestjs/core';
import { BookshelfPlainModule } from './bookshelf-plain.module.js';

async function bootstrap() {
  const replServer = await repl(BookshelfPlainModule);
  replServer.setupHistory('.nestjs_repl_history', (err) => {
    if (err) {
      console.error(err);
    }
  });
}
await bootstrap();

Start it with the CLI, naming the entry file (--watch reloads on code changes):

npm run start -- --entryFile repl
npm run start -- --watch --entryFile repl

Source: NestJS: REPL. The --entryFile option is the one listed in Getting Started & CLI. Running the compiled repl.js directly with node is equivalent, since that is what nest start --entryFile repl executes.

repl() returns a promise for a Node.js REPL server (and accepts Node’s REPL options as a second argument), so setupHistory() above keeps the command history between restarts, which watch mode otherwise discards. The history file is created with owner-only permissions (-rw-------); entries are written when the REPL runs in a terminal, and none are written when input is piped. Add the history file to .gitignore.

On start it prints LOG REPL initialized. The following session works against the books module; every class of the application is available as a variable, with no import:

> get(BooksService).findAll()
[ { id: 1, title: 'Dune' }, { id: 2, title: 'Emma' } ]
> books = get(BooksService)
BooksService {
  repository: BooksRepository { books: [ [Object], [Object] ] }
}
> methods(BooksController)

Methods:
 ◻ findAll

> debug()

BookshelfPlainModule:
BooksModule:
 - controllers:
  ◻ BooksController
 - providers:
  ◻ BooksService
  ◻ BooksRepository

> $.help
Retrieves an instance of either injectable or controller, otherwise, throws exception.
Interface: $(token: InjectionToken) => any
> select(BooksModule).get(BooksRepository)
BooksRepository {
  books: [ { id: 1, title: 'Dune' }, { id: 2, title: 'Emma' } ]
}

The built-in functions are all global; help() lists them and <function>.help shows a signature:

Function What it does Signature

get(token) or $(token)

Returns the instance of a provider or controller; throws if it is not found, or if it is request- or transient-scoped.

get(token: InjectionToken) ⇒ any

resolve(token, contextId)

Resolves a transient or request-scoped provider for a context id.

resolve(token: InjectionToken, contextId: any) ⇒ Promise<any>

select(module)

Navigates the module tree: returns an application context scoped to that module, so you can get() a provider from that module only. This is the way to tell apart two same-named providers in different modules.

select(token: DynamicModule | ClassRef) ⇒ INestApplicationContext

methods(token)

Prints the public methods of a provider or controller.

methods(token: ClassRef | string) ⇒ void

debug(module?)

Prints every module with its controllers and providers; with a module, only that module.

debug(moduleCls?: ClassRef | string) ⇒ void

help()

Lists the functions.

help() ⇒ void

Request-scoped and transient providers need resolve() and a context id, which is any object with an id. With a transient provider registered in the module, get() refused it with a clear message, and resolve() returned a distinct instance per context:

> get(RequestIdService)
InvalidClassScopeException: RequestIdService is marked as a scoped provider. Request and transient-scoped providers can't be used in combination with "get()" method. Please, use "resolve()" instead.
> ctx = { id: 1 }
{ id: 1 }
> a = await resolve(RequestIdService, ctx)
RequestIdService { id: '3rh5gt' }
> b = await resolve(RequestIdService, ctx)
RequestIdService { id: '3rh5gt' }
> c = await resolve(RequestIdService, { id: 2 })
RequestIdService { id: 'kxyfom' }
> a === b
true
> a === c
false

Source: NestJS: REPL - Native functions. Scopes are explained in Injection Scopes; a real request-scoped consumer would normally use ContextIdFactory.create() for the id.

What the REPL is good for: checking what a provider returns without writing an HTTP request, calling a service against a development database, confirming which module a provider really comes from (debug()), and exercising one-off maintenance code. What it is not: it boots the whole module graph (so use a module that does not start jobs, queues or listeners), it runs with the privileges of your process, and an application that runs scheduled work (@Cron, queue consumers) will start that work, so point it at a development environment, never at production data by accident. Because it needs no snapshot and no hosted service, it also works in an isolated network.

Madge: cycles before start-up

Nest reports a module cycle only when it hits one, and a file-level import cycle may not fail at all until a particular import order exposes it. Madge is a third-party tool (not part of NestJS) that builds the import graph of the source tree and lists circular imports, so a cycle is found in CI, not in a stack trace. The same tool is shown on the cycle example in ModuleRef, Circular Dependencies & Lazy Loading; here are the points specific to debugging. Run it on TypeScript sources and give it the tsconfig.json, so it resolves TypeScript files and path aliases:

npx madge --circular --extensions ts --ts-config tsconfig.json src

On the two cyclic modules above (and the forwardRef() variant) Madge 8.0.0 printed, with exit status 1:

Output
✖ Found 2 circular dependencies!

1) catalog-fixed.module.ts > reviews-fixed.module.ts
2) catalog.module.ts > reviews.module.ts

and on the Bookshelf books folder ✔ No circular dependency found! with exit status 0, so it works as a CI step or an npm script ("check:cycles": "madge --circular --extensions ts --ts-config tsconfig.json src"). It understood the .js import specifiers of an ESM project. Three behaviours to know:

  • A forwardRef() cycle is still an import cycle, so Madge reports it (the catalog-fixed pair above). That is correct: the cycle still exists, and Madge is how you keep the count from growing. Decide per cycle, or list accepted ones with --exclude.

  • Type-only cycles are reported by default. Two files that import each other only with import type are harmless at run time, yet Madge listed them (a.ts > b.ts). With a .madgerc file it can ignore them:

    { "detectiveOptions": { "ts": { "skipTypeImports": true } } }

    and the same sources then gave ✔ No circular dependency found!.

  • Other useful options: --json prints the whole dependency map (file to its imports), --summary lists files by number of dependents, and --orphans lists files nothing imports (in the errors folder of the example it lists the four files that nothing imports). --image <file> draws the graph but needs Graphviz installed (dot).

Madge sees files, Devtools sees the container: Madge finds the cycle between books.service.ts and reviews.service.ts, while the Devtools graph shows which module-level wiring a request or a provider actually depends on. Use both, and use the debugger for logic.

A debugging order that works

When an application will not start or misbehaves, work from the cheapest tool:

  1. Read the first line of the exception: consumer, index, token, module (above). Check exports/imports and whether the type exists at run time.

  2. If the cause is not obvious, set NEST_DEBUG=true and look for the Looking for X in Y lines that never reach a Found.

  3. A ReferenceError or undefined module points at a file-level cycle: run Madge.

  4. For "why does this route behave like this", use the routes explorer or the request-lifecycle rules (Request Lifecycle); for "what does this provider return", use the REPL.

  5. For a logic bug, attach the inspector and use source maps.

  6. Put what you learned into a test, and put a cycle check and (if your plan includes it) a graph publish into CI. For logs that explain a failure afterwards, see Logging, and for operating a service, Health Checks & Observability.

What changed recently

  • The "Cannot resolve dependency" message was reworded: it now lists the potential solutions, distinguishes a missing token from an undefined one (import type, interface, circular file import) and links the FAQ. Older posts and the book show the shorter message; the causes are the same.

  • InvalidClassModuleException ("cannot appear in the imports array") and the Scope […​] import chain on module errors point at the module that holds the mistake.

  • The Devtools chapter was rewritten in the September 2026 documentation restructure (the pages are now devtools/overview and devtools/ci-cd-integration; some older articles use the techniques paths). PartialGraphHost is available from @nestjs/core 9.3.10. The @nestjs/devtools-integration package is at 12.0.0 and requires @nestjs/core and @nestjs/common ^12.0.0 and Node.js 20.19 or later.

  • The REPL docs now show the ESM .js import extension in repl.ts and document debug(module) and history preservation in watch mode.

Where the book is outdated

The book’s testing and debugging chapters mention NEST_DEBUG, Devtools (DevtoolsModule) and Madge, and are right about the three tools, but a reader following them now should note: Devtools is documented under a different URL scheme and has grown the playground, the bootstrap performance and audit pages and the paid CI/CD integration, so "Devtools is free" is no longer the whole story (Free versus paid); the graph requires snapshot: true and is empty without it; the REPL is a first-party tool and needs no extra package; and Madge needs --ts-config (and .madgerc skipTypeImports) to handle a modern ESM TypeScript project well.