Dependency Graphs Explained: How to Read the Shape of Code

A dependency graph is the fastest honest picture of how a codebase hangs together. Learn what it shows, how to generate one, and how to read the shapes that keep showing up.

By The OpenVisio Team · · 11 min read

Open any non-trivial repository and the folder tree tells you how files are arranged. It does not tell you how they depend on each other, which is what you actually need to know before changing anything. A dependency graph does. It is the single most useful structural picture of a codebase, it is cheap to generate for almost every language, and once you know a handful of common shapes you can read one in minutes.

This post explains what a dependency graph shows, how to generate one for the major ecosystems, how to read it without drowning in edges, and what the recurring shapes usually mean for maintenance and refactoring.

Key takeaways

  • A dependency graph is a directed graph: nodes are modules (files, packages, crates), and an edge from A to B means A needs B to compile or run.
  • Distinguish internal graphs (your own modules) from external graphs (third-party packages). They answer different questions.
  • Two numbers per node do most of the work: fan-in (how many depend on it) and fan-out (how many it depends on).
  • Collapse files into folders or packages before reading. File-level graphs of large repos are unreadable.
  • Learn the common shapes: layers, hubs, god modules, stars, cycles and islands. Each suggests a specific action.
  • Use the graph for impact analysis: the reverse edges from a node are the blast radius of changing it.

What the graph actually represents

A dependency graph has a node for each unit of code and a directed edge for each "uses" relationship. If orders/service.ts imports payments/client.ts, there is an edge from orders/service.ts to payments/client.ts. The direction matters: it points from the thing that depends to the thing depended upon.

Which "uses" relationship you capture changes the graph:

  • Import graph: edges come from import, require, use, #include. This is what most tools build, because it is precise and cheap to extract.
  • Build graph: edges come from build files (Bazel deps, Gradle dependencies, Cargo [dependencies], workspace package.json). Coarser, but it is what determines build order and caching.
  • Call graph: edges between functions rather than files. More detailed and harder to build correctly; covered separately in call graphs explained.
  • Runtime graph: edges observed in traces or network calls between services. Captures what static analysis misses, like HTTP calls configured by environment variables.

Most of this post is about import graphs, because they are the right starting point for understanding a single repository.

Internal versus external

The external dependency graph is your package manifest expanded transitively. Every ecosystem ships a command for it:

npm ls --all                 # Node
pipdeptree                   # Python (pip install pipdeptree)
cargo tree                   # Rust
go mod graph                 # Go modules
mvn dependency:tree          # Maven
./gradlew dependencies       # Gradle

These answer questions like "why is this vulnerable version of a library installed?" (npm explain <pkg>, cargo tree -i <crate>, pipdeptree -r -p <pkg>). Useful, but they say nothing about how your own code is structured.

The internal graph is the one that explains architecture. Tools for it:

# TypeScript / JavaScript
npx madge --extensions ts,tsx --ts-config tsconfig.json --json src/ > graph.json
npx depcruise src --include-only "^src" --output-type dot | dot -T svg > graph.svg

# Python
pydeps myapp --cluster --max-bacon 3 -o graph.svg

# Go: one line per package with its in-module imports
go list -f '{{ .ImportPath }}: {{ join .Imports " " }}' ./... | grep "$(go list -m)"

# Java
jdeps -verbose:package -recursive target/classes

Rendering to an image uses Graphviz (brew install graphviz or your package manager) for madge, dependency-cruiser and pydeps.

Fan-in and fan-out

Two numbers per node get you most of the insight:

  • Fan-in (also called afferent coupling): the number of modules that depend on this one. High fan-in means many things break if this changes.
  • Fan-out (efferent coupling): the number of modules this one depends on. High fan-out means this module is affected by changes in many places.

You can compute both from madge's JSON in a few lines:

// fan.mjs: node fan.mjs graph.json
import { readFileSync } from 'node:fs'

const graph = JSON.parse(readFileSync(process.argv[2], 'utf8'))
const fanIn = {}
for (const [file, deps] of Object.entries(graph)) {
  fanIn[file] ??= 0
  for (const dep of deps) fanIn[dep] = (fanIn[dep] ?? 0) + 1
}

const rows = Object.keys(graph).map((f) => ({ file: f, in: fanIn[f], out: graph[f].length }))
rows.sort((a, b) => b.in - a.in)
console.table(rows.slice(0, 20))

Sort by fan-in to find the foundations of the codebase. Sort by fan-out to find orchestrators, entry points, and modules that know too much. A module high on both lists deserves attention, which is where module boundaries and coupling picks up with formal metrics like instability.

Making large graphs readable

A raw file-level graph of a 2,000-file repository is a hairball. Four techniques fix that:

  1. Collapse. Group files into folders or packages. In dependency-cruiser, --collapse "^src/[^/]+" turns every file under src/orders/ into a single src/orders node. In Python, graph packages rather than modules. Most architecture conversations happen at this level anyway.
  2. Filter out the noise. Logging, config, types and utility modules are imported everywhere and dominate the picture. Exclude them (--exclude "^src/(utils|types|lib/log)") and look at them separately.
  3. Focus on a neighborhood. Instead of the whole graph, look at one node plus everything within one or two hops. madge can start from a single file (npx madge src/orders/index.ts), and --depends lists the reverse direction.
  4. Show cycles only. npx madge --circular src/ or a dependency-cruiser no-circular rule reduces the graph to its most problematic part.

Common shapes and what they mean

After a while you start seeing the same few shapes. Each one suggests a question to ask.

Layers

Edges mostly flow in one direction: routes to services, services to repositories, repositories to the database client. No edges flow back up. This is usually the intended design of a web backend, and it is healthy. The question to ask: are there any upward edges? A single repository module importing a route handler is a small bug in the architecture that tends to grow.

Hub

One node with very high fan-in and low fan-out. Typical examples are a types module, a database client, or a design system package. Hubs are fine when they are stable and small. They are risky when they change often, because every change ripples outward. Check how often the hub changes in git log before worrying about it; the code hotspots post shows how.

God module

High fan-in and high fan-out. Everything depends on it, and it depends on everything. Often called utils, helpers, common or core, and often over a thousand lines. This is the shape most associated with slow, risky change. The fix is usually to split it by responsibility, moving each piece closer to its callers.

Star (orchestrator)

One node with very high fan-out and low fan-in: it imports many things and few things import it. Entry points (main.go, app.ts, server.py), dependency injection setup, and route registries look like this, and that is expected. A star that is not an entry point is often a feature module doing too much coordination.

Cycle

A path that leads from a module back to itself: A imports B, B imports C, C imports A. Cycles create initialization-order bugs in JavaScript and Python, make modules impossible to test or reuse independently, and defeat incremental builds. Small cycles between two closely related files are common and usually easy to break. Large cycles spanning several folders are an architectural problem. See how to find and fix circular dependencies for the playbook.

Island

A cluster of modules with no edges to the rest of the graph, or a single file with no incoming edges at all. Islands are either separate entry points (scripts, CLI tools, workers), tests, or dead code. npx madge --orphans src/ lists files nothing imports. Check them against your build config and entry points before deleting anything.

Spaghetti

No discernible structure: every folder depends on most other folders. This is less a shape than the absence of one. In practice it usually means boundaries were never defined, and the path forward is to choose two or three boundaries that matter and enforce them, rather than attempting a big-bang restructure.

Using the graph for impact analysis

The most practical everyday use of a dependency graph is answering "what will this change affect?" Walk the edges in reverse from the node you want to change. Everything you reach is a potential caller that could break.

# Which files depend on src/payments/client.ts?
# madge module ids are relative to the scanned folder, so drop the src/ prefix
npx madge --extensions ts --ts-config tsconfig.json --depends payments/client.ts src/

Combine that with test coverage and you have a quick risk estimate before a refactor. A change to a leaf node with no dependents is cheap. A change to a hub with 300 transitive dependents needs a plan, which the impact analysis before refactoring post covers in detail.

Limits of dependency graphs

A dependency graph is only as good as the edges it can see. Be aware of what it misses:

  • Dynamic imports and reflection. import(`./plugins/${name}`), Python's importlib.import_module, Java reflection and Spring component scanning create edges no static tool resolves reliably.
  • Path aliases and build tricks. If the tool is not given your tsconfig.json paths or webpack aliases, imports like @/lib/db show up as unresolved or as external packages.
  • Runtime connections. HTTP calls, queues, and shared database tables connect modules without any import.
  • Type-only imports. In TypeScript, import type creates no runtime edge. Some tools include it, some do not, and the difference matters when you are hunting runtime cycles.

None of these make the graph useless. They mean you should treat missing edges with some suspicion and verify surprising gaps with a text search.

Getting a graph without the setup

Generating and filtering a dependency graph by hand is worth doing once so you understand the mechanics. For day-to-day use, most people want the graph to already exist. OpenVisio's Explore view builds the module graph from a repository in the browser and lets you click through groups and files; for coding agents, the same graph is available over MCP through tools like get_dependents and get_neighborhood, so an agent can ask what depends on a file before editing it. Whichever route you take, the habit is the same: look at the graph before you change the code, and let the shape tell you where to be careful.

dependency graphsoftware architecturecode analysisrefactoring

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading