How Nx builds the project graph and what it actually shows

The project graph is Nx's model of your monorepo: every app and library is a node, and every dependency between them is an edge. Nx uses it to decide which projects a change affects, what order to build in, and what can run in parallel. If you own CI pipelines or shared backend libraries, this graph also tells you where coupling has accumulated.

Nx builds the graph from two inputs. The first is project discovery: it finds projects through their configuration files and through any plugins that infer projects from the workspace. The second is dependency detection. Nx statically analyzes source files, parses import and require statements, and resolves them to projects using TypeScript path mappings and package manifests. Edges from code are then merged with edges you declare yourself, such as implicitDependencies in project configuration. Because most edges come from parsing real imports, the graph shows what the code actually depends on. That can differ from the architecture diagram on the wiki, and the difference is often what you are looking for.

The graph has limits you should know before trusting it:

  • Non-literal imports. A module path assembled from a string at runtime cannot be resolved statically, so it produces no edge.
  • Runtime loading. Plugins loaded by configuration, dependency injection containers wired by name, and generated code may create no visible edge.
  • Stale manual links. implicitDependencies entries are maintained by hand. Nothing checks them against the code, so they can describe relationships that no longer exist.
  • Granularity. Nx detects cycles between projects. A file-level cycle inside a single library does not show up in the graph.
// libs/orders-api/src/lib/order-service.ts
// Shows which dependencies Nx can see when it parses this file.

// Static import with a literal path: resolved through the tsconfig path
// mapping for @acme/billing, so Nx records an edge orders-api -> billing.
import { createInvoice } from '@acme/billing';

// CommonJS require with a literal string: also parsed, also an edge.
const { audit } = require('@acme/audit-log');

export async function placeOrder(region: string, orderId: string) {
  await createInvoice(orderId);
  audit('order.placed', orderId);

  // Dynamic import with a literal path: still statically resolvable,
  // so Nx can record an edge orders-api -> tax-rules.
  const { taxFor } = await import('@acme/tax-rules');

  // Path built at runtime: Nx cannot know which project this loads.
  // No edge is recorded, even though orders-api depends on
  // @acme/shipping-eu or @acme/shipping-us at runtime.
  const shipping = await import(`@acme/shipping-${region}`);

  return shipping.schedule(orderId, taxFor(region));
}

// To make the hidden dependency visible, it has to be declared by hand in
// libs/orders-api/project.json, and kept in sync manually:
//
//   "implicitDependencies": ["shipping-eu", "shipping-us"]
//
// Nothing checks that entry against this file. If the region-based loading
// is removed later, the declared edges remain in the graph.

A library file annotated to show which imports Nx turns into project graph edges, which one it misses, and how implicitDependencies fills the gap at the cost of manual upkeep.

How does cycle detection work?

Once Nx has the graph, a circular dependency is a purely structural property: a path of edges that starts at a project and returns to it. Nx does not need to run your code to find it. It only needs the nodes and edges described above.

The pipeline from source code to a reported cycle has four steps:

  1. Parse. Nx reads source files in each project and extracts the module specifiers from imports, requires and literal dynamic imports.
  2. Resolve. Each specifier is mapped to a project, for example through a TypeScript path alias such as @acme/billing or through a workspace package name. Specifiers that resolve to npm packages become edges to external nodes, not to workspace projects.
  3. Merge. Code-derived edges are combined with declared edges such as implicitDependencies. Nx caches the computed graph and recomputes the affected parts when files change, so repeated commands do not reparse the whole workspace.
  4. Traverse. Consumers of the graph walk it. @nx/enforce-module-boundaries checks, for each import in a file, whether the imported project already has a path back to the importing project. If it does, the import closes a loop and the rule reports a circular dependency. A script reading the exported JSON can do the same with a depth-first search or a strongly connected components algorithm, as shown later in this article.

Cycles matter to Nx beyond code hygiene. Task ordering, such as building a project's dependencies first, assumes the dependencies can be ordered. A cycle between buildable libraries means there is no valid order in which each library is built after everything it depends on. Cycles also widen nx affected, because a change to any member of a loop reaches every other member and all of their dependents.

When should you use the project graph for cycle hunting?

Use the graph when the question is about relationships between projects rather than about a single file. Typical triggers:

  • The lint rule reports a circular dependency and you need to see the full loop, not just the one import that closed it.
  • nx affected selects far more projects than the change seems to justify. A cycle or a high fan-in library is usually the cause, and the graph shows which one.
  • Builds of buildable or publishable libraries fail or behave inconsistently because their dependencies cannot be ordered.
  • You are about to split, merge or move a library and want to know who imports it and what it imports before you change anything.
  • You are introducing tags and boundary rules and need a baseline of existing violations to plan the rollout.
  • You are reviewing architecture drift, comparing what the code depends on with what the team believes it depends on.

It is the wrong tool when the cycle is between files inside one library, since project-level analysis cannot see it, or when the dependency is created at runtime through configuration or computed paths, since the graph has no edge for it. In very small workspaces where every project is rebuilt anyway and the full graph fits on one screen, a quick look at the imports is often enough.

Reading the interactive graph with nx graph

Run nx graph from the workspace root. Nx computes the graph and opens it in a browser, starting with a project list rather than the full graph. That default is useful: an empty canvas makes you pick a question before you look.

Focus on one project

Select a project and use focus to limit the view to that node and its direct neighbors. Then toggle between dependencies (what it imports) and dependents (what imports it). Dependencies tell you what a change here might break at build time. Dependents tell you how many consumers a change here affects. You can open this view directly with nx graph --focus=<project>.

Filter and trace

The text filter matches project names. It works best when names follow your tag scheme, for example a scope- or type- prefix, because a single prefix then isolates one domain. To answer "why does billing-api depend on ui-shared?", select both projects and trace the path between them. The intermediate nodes are the libraries to inspect, since the unwanted edge usually enters through one of them.

Reading a cycle

An nx circular dependency appears as edges that lead back to their starting node. In the simplest case, A points to B and B points back to A. Longer cycles run through three or more projects, such as A → B → C → A. Trace from one member to the next and note which edge looks wrong. That edge is the one to cut.

Where it breaks down

The visual graph works for dozens of projects. With hundreds, the full graph becomes too dense to read, and focused views hide the global patterns you are trying to find. At that point, export the graph as JSON and analyze it with a script.

nx graph

nx graph --focus=billing-api

nx graph --focus=billing-api --exclude=billing-api-e2e

nx graph --groupByFolder

nx graph --file=graph.json

jq -r '.graph.dependencies["billing-api"][].target' graph.json

Diagnostic uses of nx graph: open the interactive view, focus on one project, exclude noise, group by folder, and export to JSON once the visual graph becomes too dense to read.

Scripting against the graph JSON to rank dependency hotspots

Let a script do the ranking. Running nx graph --file=graph.json writes the project graph to disk instead of opening the browser. The file contains a node per project and, for each project, a list of dependency edges pointing at targets. The exact shape can shift between Nx versions, so inspect your own file before relying on field names.

From that edge list, a short script can compute three things:

  • Fan-out: outgoing edges per project. High fan-out means the project is fragile, because a change anywhere below it marks it affected.
  • Fan-in: incoming edges, counted directly and transitively. High fan-in means the project has a large blast radius. Every change to it pulls in many dependents.
  • Cycles: a depth-first search that flags back edges, or Tarjan's strongly connected components algorithm to group every project caught in the same loop.

Edge counts are heuristics. A types or constants library with 60 dependents is harmless if it changes twice a quarter. Weigh fan-in against change frequency, which you can pull from git log per project root. A project that scores high on both is a real hotspot.

Illustrative estimate (assumed inputs, not measured): a util library has 40 dependents and changes in 30% of PRs. Splitting it moves the volatile code into a library with 5 dependents. Assume that library still changes in 30% of PRs, and the stable remainder (35 dependents) changes in 2%.

Scenario Dependents PRs touching it Expected dependents affected per PR
Before: single util 40 30% 12
After: volatile part 5 30% 1.5
After: stable part 35 2% 0.7

Under these assumptions, the typical nx affected set drops from about 12 dependents to about 2.2 per PR. That means fewer builds, fewer tests and less cache churn.

// Usage: nx graph --file=graph.json && node rank-hotspots.js graph.json
const fs = require('fs');
const raw = JSON.parse(fs.readFileSync(process.argv[2] || 'graph.json', 'utf8'));
// Recent Nx versions nest edges under raw.graph.dependencies; inspect your file and adjust if needed.
const deps = (raw.graph || raw).dependencies;
const names = Object.keys(deps);
const isProject = (t) => t in deps; // skip external nodes such as npm packages

// Direct fan-in: how many workspace projects depend on each project.
const fanIn = Object.fromEntries(names.map((n) => [n, 0]));
for (const n of names) for (const e of deps[n]) if (isProject(e.target)) fanIn[e.target]++;
// Direct fan-out: how many workspace projects each project depends on.
const fanOut = (n) => deps[n].filter((e) => isProject(e.target)).length;

// Cycle detection: DFS with an "on stack" marker; reaching a node still on the stack is a back edge.
const state = {}; // undefined = unvisited, 1 = on stack, 2 = done
const stack = [];
const cycles = [];
function visit(n) {
  state[n] = 1;
  stack.push(n);
  for (const { target } of deps[n]) {
    if (!isProject(target)) continue;
    if (state[target] === 1) cycles.push([...stack.slice(stack.indexOf(target)), target].join(' -> '));
    else if (!state[target]) visit(target);
  }
  stack.pop();
  state[n] = 2;
}
names.forEach((n) => state[n] || visit(n));

// Rank by blast radius first, fragility second. Weigh the result against git change frequency.
const rows = names
  .map((n) => ({ project: n, fanIn: fanIn[n], fanOut: fanOut(n) }))
  .sort((a, b) => b.fanIn - a.fanIn || b.fanOut - a.fanOut);
console.table(rows.slice(0, 15));
console.log(cycles.length ? 'Cycles:\n' + cycles.join('\n') : 'No cycles found.');

A Node.js script that reads the file written by nx graph --file=graph.json, ranks projects by direct fan-in and fan-out, and prints any dependency cycles found with a depth-first search.

Why cycles and hotspots form: common causes

Most cycles don't start as a design decision. They build up from small imports that each looked reasonable when they were added.

Three recurring causes

Feature libs reaching into each other. orders needs a pricing helper from billing, and later billing needs an order status type from orders. Neither team meant to couple the two libs, but Nx sees an edge in each direction and reports a cycle. The real problem is that both libs depend on concepts that belong in a third, lower-level lib.

Shared libs importing upward. A shared/util lib picks up one import from a feature lib, usually a type or constant that was "already defined over there." The shared lib now depends on a feature. Every other feature that uses the shared lib is now indirectly connected to that feature too.

Broad barrel files. An index.ts that re-exports everything makes import { x } from '@org/foo' resolve to the whole library. If any file behind that barrel imports back into the caller's lib, you get a cycle, even when the symbol you wanted has no such dependency.

Hotspot shapes

High fan-in libs are depended on by many projects. They should be small, stable, and rarely changed. In practice they tend to grow, because they are convenient. High fan-out libs depend on many projects. They are usually app-level wiring, such as routing, composition, or configuration, that ended up in a library.

Fan-in drives CI cost. A change to a lib marks every transitive dependent as affected in nx affected. When a widely used lib also changes often, most PRs rebuild and retest a large part of the workspace.

Enforcing boundaries with @nx/enforce-module-boundaries

Tags make structure enforceable. Add a tags array to each project's configuration, using two axes: type:feature, type:data-access, or type:util for the layer, and scope:billing or scope:shared for the domain. In the root ESLint config, give @nx/enforce-module-boundaries a depConstraints list. For example, type:util may depend only on type:util, and scope:billing may depend only on scope:billing or scope:shared. The rule also reports circular dependencies between projects without any tag setup, because it reads the same project graph that nx graph renders.

In CI, run nx affected -t lint on every PR with the rule set to error. A new cycle or a forbidden import then fails the build before merge, which is cheaper than finding it later in the graph.

When not to go strict

Do not turn the rule on as error across a repo that already has dozens of violations. Every team is blocked on the first day. The usual reaction is eslint-disable comments or broad exceptions to get CI green, and each one hides a cycle that keeps growing. A better approach is to start at warn, record the current violations as a baseline, and switch to error one scope at a time as each scope is cleaned up.

Approach Catches Weakness
Lint rule Project cycles, tag violations, per PR Suppressions accumulate silently
Scripted graph JSON check Fan-in thresholds, violation counts against a baseline You maintain the script
Manual nx graph review Design drift, odd groupings Not repeatable, easy to skip
File-level cycle tools Import cycles inside one library Do not see project boundaries
// eslint.config.mjs (repo root, ESLint flat config)
// Projects declare tags in project.json, e.g.
//   "tags": ["type:feature", "scope:billing"]
import nx from '@nx/eslint-plugin';

export default [
  ...nx.configs['flat/base'],
  ...nx.configs['flat/typescript'],
  {
    files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
    rules: {
      // Start at 'warn' in a repo with existing violations;
      // switch to 'error' once the baseline is cleaned up.
      // Project cycles are reported even without depConstraints.
      '@nx/enforce-module-boundaries': [
        'error',
        {
          enforceBuildableLibDependency: true,
          // Keep this empty: every entry is a hole in the boundary.
          allow: [],
          depConstraints: [
            // Layer axis: lower layers never import higher ones.
            { sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:feature', 'type:data-access', 'type:util'] },
            { sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
            { sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
            // Domain axis: billing may use itself and shared code only.
            { sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
            { sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
          ],
        },
      ],
    },
  },
];

// CI (per PR): fail the build on new cycles or tag violations
//   npx nx affected -t lint --base=origin/main --head=HEAD

A root ESLint flat config that tags projects on layer and scope axes and configures @nx/enforce-module-boundaries so project cycles and tag-constraint violations fail nx affected -t lint in CI.

Refactoring patterns that break a cycle: a worked example

There are four standard ways to break a cycle. Pick one based on what the shared code actually is.

Fix When it fits Main cost
Extract to a lower-level lib Both sides need the same types, constants or pure helpers One more project to configure, tag and cache
Invert through an interface A low-level lib calls up into a feature, for example to send a notification Wiring code, plus indirection that is harder to trace
Merge the two libs They always change together and neither is used alone Larger cache unit, so more rebuilds per change
Move code to its owner One function landed in the wrong lib Touches every importer

Worked example

Suppose orders imports calculateInvoice from billing, and billing imports the Order type from orders:

orders ⇄ billing

The function genuinely belongs to billing. The type, however, is a contract that both libs need. Move Order into a new order-contracts lib that has no workspace dependencies:

orders → billing → order-contracts orders → order-contracts

The cycle is gone because the type now sits below both consumers. A change to orders logic no longer affects billing, so Nx no longer marks billing as affected. The code example that follows shows the extracted lib.

Trade-offs

Do not extract by reflex. Every new lib adds a project config, tags, lint rules and cache entries, and it makes the graph harder to read. If order-contracts ends up holding half of orders, merging the two would have been more honest.

Removing a barrel file to cut a cycle that runs through index.ts re-exports also has a cost. Deep imports then have to be rewritten across the workspace. Schedule that as its own reviewable change rather than mixing it into the fix.

An incremental cleanup workflow and its impact on cost and engineering time

For a large workspace, run the cleanup as a tracked migration instead of a one-off sweep.

  1. Snapshot the graph with nx graph --file=graph.json and store it as a CI artifact or commit it.
  2. Record the current cycles and the top hotspots by fan-in from that file. This is your baseline.
  3. Add tags and @nx/enforce-module-boundaries constraints at warn severity so nothing blocks merges yet.
  4. Assign a named owner for each cleanup area. Warnings without an owner become background noise and get ignored.
  5. Fix the worst offenders first. Start with cycles that pass through high fan-in libraries, because they inflate affected sets the most.
  6. Once a constraint reaches zero violations, switch it to error so it cannot regress.
  7. Re-snapshot regularly and diff against the baseline to confirm that cycles and fan-in are actually falling.

The cost is real. It includes refactoring effort, more libraries to configure and maintain, and extra review load. The return is smaller affected sets, fewer accidental couplings, and reviews that involve fewer owners.

Illustrative estimate (assumed numbers, not a benchmark): monthly savings = PRs × CI minutes saved per PR × cost per minute + reviewer hours saved × hourly rate. Assume 400 PRs, 6 minutes saved, $0.05/min, and 10 hours at $100: 400 × 6 × 0.05 + 10 × 100 = $1,120/month. Divide the refactoring cost by this figure to get the payback period in months.

Skip this work in small repos where everything already builds quickly. Also leave stable high fan-in libraries alone. A shared types library is a hub by design, not a hotspot.

What are the alternatives?

The Nx project graph is not the only way to find circular dependencies. Each alternative works at a different granularity, and they are often used together.

  • File-level import cycle detectors. Open-source tools such as madge and dependency-cruiser build a graph of individual modules, and the import/no-cycle rule from eslint-plugin-import flags cycles during linting. They catch what Nx cannot: loops between files inside one library. They do not know about Nx projects, tags or affected sets, so they complement project-level checks rather than replace them. Expect them to be slower on large codebases, because the graph has far more nodes.
  • TypeScript project references. If libraries are compiled as separate TypeScript projects with references, the compiler rejects reference graphs that form a loop. This only covers projects set up that way, and the references have to be kept in sync with the actual imports.
  • Package-manifest dependencies. In a workspace where each library is a package with its own package.json, declared dependencies give a second, coarser graph. Cycles there are visible to the package manager and to anyone reading the manifests, but the declarations can drift from what the code imports.
  • Custom scripts on the exported graph. The approach shown earlier in this article. It gives full control over thresholds and reporting, at the cost of maintaining the script as the JSON shape changes.
  • Architecture review and documentation. Decision records and reviewed dependency diagrams catch intent problems that no tool can, such as a dependency that is legal under the tags but wrong for the domain. They are not repeatable checks and go stale without upkeep.

A practical combination for most Nx workspaces is the boundary lint rule for project-level cycles and tag rules, a file-level cycle check for the inside of large libraries, and a periodic scripted report from the graph JSON to track hotspots over time.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nx circular dependency, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.