← all posts

The Dependency Graph Is the Real Artifact

5 min read #build-systems

A file tree answers one question: where things live. A dependency graph answers the questions you actually get paid to answer: what breaks if I change this, what can build in parallel, why is this binary 200 MB, and which of these ten thousand tests actually need to run. Most codebases have the first and only a vague, tribal-knowledge version of the second. I think that's backwards. The graph is the real artifact; the file tree is just its storage format.

I got religion on this from a side project: a static site generator whose incremental rebuilds kept being wrong. Pages wouldn't update when a shared partial changed. The fix wasn't smarter file watching — it was writing down, explicitly, which page depended on which template, and treating that graph as data I could query. Once the graph existed as a first-class object, four capabilities fell out almost for free. They're the same four that make tools like Bazel worth their complexity.

1. Reverse reachability: the blast radius query

The forward question — "what does X depend on?" — is what compilers need. The reverse question — "what depends on X?" — is what humans need, and file trees are structurally incapable of answering it. In Bazel it's one line:

# everything that could possibly be affected by touching :parser
bazel query "rdeps(//..., //lib:parser)"

# just the tests in that set — this is CI test selection
bazel query "kind(test, rdeps(//..., //lib:parser))"

That second query is the entire trick behind "only run affected tests" CI, which is the single largest CI cost reduction available to a monorepo. Diff the commit, map changed files to targets, take rdeps, intersect with tests. No machine learning, no flaky heuristics — graph traversal. If your build tool can't do this, you can get 80% of it with a hundred lines of Python over import statements:

import ast, collections

graph = collections.defaultdict(set)
for path in all_py_files:
    tree = ast.parse(path.read_text())
    for node in ast.walk(tree):
        if isinstance(node, ast.ImportFrom):
            graph[to_module(path)].add(node.module)

# invert once, then rdeps is a BFS
rgraph = collections.defaultdict(set)
for src, deps in graph.items():
    for d in deps:
        rgraph[d].add(src)

2. Cycle detection: finding the modules that are secretly one module

A dependency cycle is two or more components pretending to be separate while being, operationally, one unit: you can't build them independently, test them independently, or delete one without the others. Tarjan's strongly-connected-components algorithm finds every cycle in linear time, and running it on a codebase for the first time is always humbling. On my site generator I expected zero cycles and found three; on every real codebase I've pointed an SCC pass at, the largest component was something people described as "the core" with a resigned tone of voice.

The useful move isn't just detecting cycles — it's ratcheting: record the current SCC sizes, fail CI if a new edge grows one. You don't have to fix the legacy knot today. You just make it illegal to feed it.

3. Topological order: the schedule was inside the graph all along

Once dependencies are explicit, build scheduling stops being a heuristic and becomes a property you read off the graph. A topological sort gives you a legal order; the graph's width at each level tells you how much parallelism is available; and the critical path — the longest chain of dependent actions — is a hard floor on wall-clock time that no amount of hardware can beat.

This reframes performance work completely. If your build takes 40 minutes on 64 cores and the critical path is 38 minutes, buying more cores is burning money — the only fix is restructuring the chain: splitting the god-module in the middle of it, or breaking the "generate one giant header that everything includes" pattern that turns the whole graph into a line. bazel analyze-profile will hand you the critical path; for anything else, longest-path over a DAG is a fifteen-line dynamic program. I have watched people tune compiler flags for a week when the answer was a single header file sitting on the critical path of 4,000 actions.

4. The graph as a diff target

The quietest superpower: once the graph is data, you can diff it in review. A PR that adds an edge from storage to ui is architecture erosion happening in real time, and it's invisible in a file diff — it looks like one innocent import line. Emit the graph to a checked-in text form (sorted, one edge per line — deterministic output matters here too) and layering violations show up in git diff like any other regression:

# deps.txt — regenerate with ./tools/depgraph.py, sorted, committed
core/render → core/geometry
core/render → core/log
+ ui/panel  → storage/db   ← this line is a design review, not a nit

Add a tiny allowlist of legal layer edges and CI can reject the edge before a human ever has to be the bad guy.

The point

None of these four things — blast radius, cycle ratchets, critical-path analysis, edge review — can be done with a directory listing, and all of them are trivial once the graph exists as a queryable object. That's the actual argument for build systems that force you to declare dependencies, and it has nothing to do with build speed. The declarations aren't bureaucracy. They're the source code of the graph — and the graph is where the architecture actually lives.