What is Nx graph dependency analysis?
Nx graph dependency analysis uses the project graph that Nx already builds for task scheduling and affected detection to measure how your libraries are coupled. You read the graph, or export it as data, and compute structural numbers per project: fan-in, fan-out, blast radius, dependency depth and cycles. Then you combine those numbers with git history to find the libraries that are expensive to change.
It is not a separate tool. The inputs are the nx graph command, its JSON export, your git log and the nx show projects --affected command. The outputs are a ranked list of hotspots, a set of fixes such as splits, contracts libs and inverted dependencies, and lint rules that stop the same problems from returning.
Who should use it?
- Backend and platform engineers who maintain an Nx monorepo with many shared libraries and need to know which ones are risky to change.
- Tech leads and architects who plan refactors and want evidence about which split will shrink affected sets.
- Engineers who own CI cost or pipeline speed. Oversized affected sets usually trace back to a few high-fan-in libraries.
It is less useful in a small workspace with a handful of projects. There you can keep the whole graph in your head, and the interactive view answers most questions.
When should you use it?
- CI times keep growing, or small changes mark far more projects as affected than the change seems to warrant.
- You are about to split, merge or restructure shared libraries and want to compare candidates before committing to one.
- You are introducing tags and the module boundary lint rule, and need to know where the existing violations are.
- Ownership has changed after a team reorganisation, and domain boundaries need to be redrawn.
- On a schedule, such as each release or weekly, so you can catch boundary erosion as a trend instead of discovering it during an incident.
How does it work?
The audit is a short pipeline. Each step is covered in a section below.
- Build the graph. Nx statically analyses imports, resolves them through path mappings and package manifests, and adds any dependencies you declared explicitly. The result is a graph of projects and typed edges.
- Narrow and inspect. Use
nx graphwith focus and exclude options to form hypotheses about suspicious libraries. - Export and measure. Write the graph to JSON and compute fan-in, fan-out, blast radius and max depth per library.
- Add change frequency. Join the metrics with commit counts per project root. Confirm candidates by probing the affected set with a trivial edit.
- Fix structural problems. Break cycles and split or restructure hotspot libraries.
- Enforce and repeat. Encode the boundaries as tags checked by the module boundary lint rule, and track a few metrics over time in CI.
What the Nx project graph captures and what it misses
The Nx project graph models every project in your workspace as a node and every dependency between projects as an edge. This guide is for backend and platform engineers running Nx monorepos who need to know which libraries are risky to change. The graph is only useful for that audit if you understand where its edges come from.
Nx builds the graph from two inputs. The first is static analysis of source files: Nx parses imports, resolves them through the workspace's TypeScript path mappings and package manifests, and maps each import to the project that owns the target. The second is anything you declare explicitly, such as implicitDependencies in project configuration. Each edge carries a type:
| Edge type | Source | Typical example | Reliability |
|---|---|---|---|
| Static | Top-level import/require resolved to a project |
A service importing a shared validation lib | High, if aliases follow conventions |
| Dynamic | import() expressions |
Lazy-loaded plugin or handler module | Medium; non-literal specifiers are missed |
| Implicit | Manually declared in config | An e2e project depending on an API app | Only as accurate as the declaration |
The graph only sees import-level coupling. Two services that share a Kafka topic, write to the same database schema, or get wired together through environment variables have no edge unless someone declares one. Some import-level edges are also wrong. Dynamic imports built from variables are missed. Path aliases that bypass the configured mappings, such as deep relative imports across project roots, can attach an edge to the wrong project or drop it. Code generated at build time, like ORM clients or protobuf stubs, may not exist when Nx analyzes the workspace.
Before you trust the numbers, spot-check the edges of your five to ten most critical libraries against their actual imports and runtime integrations.
INPUTS NX PROJECT GRAPH
+----------------------+ +------------------------------------+
| Source files | | orders-api --static--> validation |
| import / require |------->| | |
| import() literals | | +--dynamic--> plugin-loader |
| tsconfig path maps | | |
+----------------------+ | api-e2e ..implicit..> orders-api |
+----------------------+ | |
| Project config |------->| --- static (high reliability) |
| implicitDependencies| | --- dynamic (literal paths only) |
+----------------------+ | ... implicit (as good as config) |
+------------------------------------+
BLIND SPOTS (not reflected correctly in the graph)
+----------------------------+ +-----------------------------------+
| INVISIBLE unless declared | | MISSING OR MISLEADING EDGES |
| shared Kafka topics | | import(variable) specifiers |
| shared DB schemas | | deep relative imports across |
| env-variable wiring | | project roots |
+----------------------------+ | build-time generated code |
+-----------------------------------+
=> spot-check the edges of critical libs before trusting the graph
Nx builds the project graph from static import analysis and declared config, producing static, dynamic and implicit edges, while runtime coupling and some import patterns stay invisible or misattributed.
Using the nx graph command without drowning in noise
Running nx graph with no arguments opens the interactive view in a browser. In a workspace with a few dozen projects, that view is enough to answer quick questions, such as which libraries orders-api pulls in or whether a new payments-domain lib accidentally depends on an HTTP adapter. The defaults show every project and every edge at once, so narrow the view before you read it.
Narrowing the view
- Focus on one project. Passing
--focuswith a project name, or selecting the project in the sidebar, shows only that project and its direct and transitive neighbors. Use this when a single library feels "heavy" and you want to see what it touches. - Trace a path between two projects. The UI can show the path or paths connecting two selected projects. This is the fastest way to explain why a change in a shared util lib marks an unrelated service as affected.
- Exclude noise. Use
--exclude, or the sidebar toggles, to hide e2e projects, apps, and tooling or generator projects. Backend audits are about library-to-library edges, and apps add fan-in that carries no useful signal. - Switch to the task view. The task view shows how targets like
builddepend on each other across projects. This is useful when a slow pipeline traces back to a task chain rather than to imports.
Flag names and UI options change between Nx versions, so check them against nx graph --help for your installed version.
Visual inspection stops working at a few hundred projects. The layout turns into a hairball, and you cannot sort, diff, or count anything. At that point, treat the interactive view as a way to form hypotheses, then export the graph as data so you can measure it.
npx nx graph --help
npx nx graph
npx nx graph --focus=orders-api
npx nx graph --exclude=orders-app,orders-app-e2e,workspace-tools
npx nx graph --focus=payments-domain --exclude=orders-app,orders-app-e2e,workspace-tools
npx nx graph --file=project-graph.json
Narrowing nx graph with --focus and --exclude before reading it, then exporting the graph to JSON with --file once visual inspection no longer scales.
How to export the graph and rank libraries by fan-in, fan-out and blast radius
Run nx graph --file=graph.json to write the project graph to disk instead of opening the browser. The output is plain JSON: a map of project nodes and, for each project, a list of dependency edges with a source, a target and a type. The exact nesting has changed across Nx releases, so open the file from your installed version and confirm where nodes and edges live before writing the loader. Keep the script to a few dozen lines and fail loudly if an expected key is missing. A small script that refuses to run on an unexpected shape is safer than a clever one that quietly reports zeros.
From the edges, build two adjacency maps: dependencies (outgoing) and dependents (incoming). Then compute four numbers per library:
| Metric | How it is computed | What a high value signals |
|---|---|---|
| Fan-in | Count of direct dependents | Many consumers, so API changes are expensive |
| Fan-out | Count of direct dependencies | The library knows too much and is fragile |
| Blast radius | Size of the transitive dependents set (BFS over incoming edges) | One edit invalidates a large affected set |
| Max depth | Longest dependency chain below the node | Slow builds and layering problems |
Filter out npm and external nodes before ranking. They inflate fan-out without telling you anything about your own code.
Sort by blast radius first and use fan-out as the tiebreaker. A library with a large blast radius and low fan-out is usually a healthy shared foundation. A library with a high value on both is the real hotspot: it is unstable and widely consumed. Max depth points you to the chains that are worth flattening.
#!/usr/bin/env python3
import json, sys
from collections import deque
with open(sys.argv[1] if len(sys.argv) > 1 else "graph.json") as f:
data = json.load(f)
try: # Shape checked against our installed Nx version; fail loudly if it differs
nodes = set(data["graph"]["nodes"])
raw = data["graph"]["dependencies"]
except KeyError as e:
sys.exit(f"graph.json has no key {e}; inspect the file for your Nx version")
deps = {n: {e["target"] for e in raw.get(n, []) if e["target"] in nodes} for n in nodes}
dependents = {n: set() for n in nodes}
for src, targets in deps.items():
for t in targets:
dependents[t].add(src)
def blast_radius(n): # BFS over incoming edges = every project an edit can affect
seen, q = set(), deque([n])
while q:
for d in dependents[q.popleft()] - seen:
seen.add(d); q.append(d)
return len(seen - {n})
memo = {}
def max_depth(n, visiting=frozenset()): # longest chain below n
if n in memo: return memo[n]
if n in visiting: return 0 # cycle guard: report, don't recurse forever
memo[n] = max((1 + max_depth(c, visiting | {n}) for c in deps[n]), default=0)
return memo[n]
rows = [(n, len(dependents[n]), len(deps[n]), blast_radius(n), max_depth(n)) for n in nodes]
rows.sort(key=lambda r: (-r[3], -r[2])) # blast radius first, fan-out breaks ties
print(f"{'project':40} {'fan-in':>6} {'fan-out':>7} {'blast':>6} {'depth':>6}")
for name, fin, fout, blast, depth in rows[:20]:
print(f"{name:40} {fin:>6} {fout:>7} {blast:>6} {depth:>6}")
A small Python script that loads the exported Nx graph.json, rejects an unexpected schema, drops external npm nodes, and ranks workspace projects by blast radius with fan-out as the tiebreaker, alongside fan-in and max dependency depth.
Combining graph metrics with git history and affected sets
Graph metrics describe structure at one point in time. They say nothing about how often that structure is disturbed. To add that, map each project's root directory from the exported graph to its commit count over a fixed window, for example by running git log --since="6 months ago" --format=%H -- libs/shared/util and counting the lines. Libraries that rank high on both fan-in and commit frequency are the ones costing you CI time and review effort.
| Fan-in | Change frequency | Interpretation | Action |
|---|---|---|---|
| High | Low | Stable foundation | Leave it alone |
| High | High | Hotspot | Split or extract volatile parts |
| Low | High | Active feature work | Monitor |
| Low | Low | Quiet leaf | Check for dead code |
A high fan-in library that rarely changes is expected and is not a reason to split it. Splitting adds project overhead without shrinking any affected set, because the library seldom triggers one.
To confirm a candidate, make a trivial edit on a branch and run nx show projects --affected against your main branch. Compare the result with the same edit made in a leaf library.
Illustrative estimate. These inputs are assumed, not measured. A workspace has 60 projects. A leaf library edit affects 3 projects, and libs/shared/util has 40 transitive dependents. If each project's lint, test and build costs 2 minutes of agent time, the leaf change costs 6 minutes and the util change costs 82 minutes. If that library changes 30 times a month, the extra cost adds up to roughly 38 agent-hours per month.
That monthly figure gives you a way to rank splitting work against other priorities.
#!/usr/bin/env bash
set -euo pipefail
WINDOW="6 months ago"
npx nx graph --file=graph.json
jq -r '
.graph as $g
| $g.nodes | to_entries[]
| .key as $n
| [$n, .value.data.root,
([$g.dependencies[][] | select(.target == $n)] | length)]
| @tsv' graph.json |
while IFS=$'\t' read -r name root fanin; do
# Commits touching this project's directory in the window = volatility.
commits=$(git log --since="$WINDOW" --format=%H -- "$root" | wc -l)
# The product is only a sort key. High fan-in with near-zero commits scores low on purpose,
# because a stable foundation is not a hotspot.
printf '%s\t%s\t%s\t%s\n' "$name" "$fanin" "$commits" "$((fanin * commits))"
done | sort -t$'\t' -k4,4nr | head -n 15 |
column -t -s$'\t' -N PROJECT,FAN_IN,COMMITS,SCORE
echo "Affected projects vs origin/main:"
npx nx show projects --affected --base=origin/main --head=HEAD | wc -l
A script that ranks projects by combining direct fan-in from the exported Nx graph with commit counts per project root, then counts the affected set for a probe edit so you can compare a candidate hotspot with a leaf library.
Finding and breaking circular dependencies in Nx
Cycles rarely show up as an obvious loop in the graph view. In a large workspace they look like two libraries with edges running in both directions, or a path that leaves a library and returns to it three hops later. Select a suspect project, show its dependencies and dependents together, and check whether any project appears in both sets. The module boundary lint rule is more direct: it fails with a circular dependency error that names the chain of projects involved. That chain tells you which import to remove.
Common backend causes
- Barrel files that re-export everything. A library that re-exports its whole surface through one index file makes every consumer depend on all of it. A single cross-import inside that surface can then close a loop that no one intended.
- Shared types in a domain lib. When
ordersdefines aCustomerReftype thatbillingneeds,billingnow depends onorders. Onceorderscalls anything inbilling, the two form a cycle. - Service libs importing each other. Two services that call each other's functions directly create the most common runtime-coupled cycle.
Fixes
- Extract an interface or contracts lib. Move the shared types and interfaces into a lib with no dependencies, which both sides can then import.
- Invert the dependency. The lower-level lib defines an interface, and the higher-level lib implements it and injects it.
- Merge libraries that are really one unit. If two libs always change together and call each other freely, the split was artificial.
Be careful with contracts libs. Without a named owner and a written scope, such as "types only, no runtime code, no domain logic," a contracts lib attracts every shared helper. Within months it becomes the next high fan-in catch-all, and you have moved the hotspot instead of removing it.
Common backend library hotspots and how to remediate them
The four hotspots below show up in most backend workspaces. Each has a recognisable graph signature and a standard fix.
| Hotspot | Graph signature | Remediation |
|---|---|---|
common / shared catch-all |
Highest fan-in, frequent commits, unrelated consumers | Split by reason to change (dates, errors, HTTP helpers), then delete the original |
| Database/ORM lib | Every service depends on it; schema edits mark all of them affected | Separate schema and migrations from per-domain repositories; services import only their repository lib |
| Config lib | Moderate fan-in, large external dependency footprint | Keep a lightweight typed-schema lib; move SDK clients and secret loaders into adapter libs |
| Test utilities | Edges from production projects into a test-only lib | Tag it test-only and forbid imports from non-test code |
The ORM case matters most for CI. One shared schema file makes every service affected by a column rename. Usually only two or three services read that table, so most of that work is wasted.
The config case is mostly about runtime weight. A service that only needs a port number should not bundle a cloud SDK.
Leaking test utilities are the hardest to spot. Fixtures and fakes that reach production code can change runtime behaviour, not just build time.
Not every split pays off. Smaller projects shrink affected sets and can raise cache hit rates. Each new project also adds configuration, targets and graph edges for someone to maintain. Skip the split when the consumers of a lib almost always change together, or when the lib rarely changes. A stable, high fan-in lib is not a hotspot. It is just a dependency.
Enforcing boundaries with tags and the module boundary lint rule
Tags turn the dependency rules you found in the audit into something the build can check. Each project carries tags in its configuration, and the @nx/enforce-module-boundaries ESLint rule compares every import against a list of dependency constraints keyed on those tags.
Use two dimensions. Type tags describe what a project is: type:app, type:feature, type:data-access, type:util. Scope tags describe who owns it: scope:billing, scope:orders, scope:shared. The type constraints set the direction of the layers. Apps depend on features, features depend on data-access and util, and util depends only on util. The scope constraints keep domains apart: scope:billing may import scope:billing and scope:shared, and nothing from scope:orders. Tags use colon-prefixed strings because each constraint matches on the full tag string, and the prefix keeps the two dimensions from colliding.
Run lint in CI on the affected set so the check stays cheap. A new violation then fails the pull request that introduced it, while the author still has the change in mind.
Most existing codebases fail the first run with hundreds of violations. If you enable the full rule set at once, someone will turn it off within a week. Roll it out in phases:
- Start with scope tags only. Leave type constraints permissive with a wildcard, and fix the cross-domain imports first. Those imports do the most damage to blast radius.
- Record the remaining violations as explicit allowances in the rule's
allowlist. Each entry should have an owner and a ticket. This keeps the backlog visible and stops it from growing. - Tighten in steps. Add the type constraints one layer at a time, and remove allowances as each one is fixed. Treat any new allowance as a review flag.
Running a repeatable audit loop: cost, time savings and alternatives
Schedule the export rather than running it by hand. A CI job on each release tag, or weekly, can write the graph JSON as an artifact and append three numbers to a running log: largest fan-in, cycle count and maximum dependency depth. Review the trend, not the snapshot. A shared logging lib with high fan-in may be fine if it is stable. A fan-in that grows every release, or a cycle count that returns after reaching zero, means boundaries are eroding faster than the lint rule catches.
Is the work worth it?
The costs are the initial audit, the library splits and ongoing tag maintenance. The savings come mostly from smaller affected sets: fewer projects rebuilt and tested per PR, and less time engineers spend waiting on CI.
Illustrative estimate (assumed numbers, not a benchmark):
- Monthly hours saved = PRs per month × CI minutes saved per PR × fraction of that wait that blocks someone ÷ 60
- Assumed inputs: 300 PRs, 6 minutes saved per PR, 50% blocking. That gives 300 × 6 × 0.5 ÷ 60 = 15 h/month.
- Payback in months = upfront hours ÷ (monthly hours saved − monthly maintenance hours)
- Assumed inputs: 40 upfront hours, 2 hours of maintenance per month. That gives 40 ÷ (15 − 2) ≈ 3 months.
Alternatives
| Approach | Sees | Misses | Best for |
|---|---|---|---|
| Interactive-only inspection | Local structure, ad hoc questions | Trends, regressions between looks | One-off investigation |
| Generic language-level analyzers | File/module imports, sometimes beyond Nx project granularity | Nx tags, affected sets, task graph | Fine-grained refactors inside a lib |
| Architecture reviews, contract tests | Runtime calls, shared databases, message schemas | Static import drift | Coupling the graph cannot see |
Use them together. The graph covers static coupling between projects. Reviews and contract tests cover the runtime and data coupling.
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 graph dependency analysis, 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.