Change Coupling: The Dependency Graph Only Git Can See
Every file your code imports is a dependency you can read. Every file it keeps getting edited alongside is a dependency too, and no parser will find it. I built both graphs for five JavaScript codebases and counted the edges only git has.
A dependency nobody wrote down
The five earlier posts in this series all read the same artifact. Fitness functions, the Modularity Maturity Index, propagation cost and decoupling level are all computed from what the code says about itself. Architecture debt added version history, but used it to price groups of files rather than to map them. None of them can see a dependency that was never written down.
Harald Gall, Karin Hajek and Mehdi Jazayeri put that gap into words in 1998. They were working through twenty releases of a ten-million-line telecom switching system. Their complaint about code metrics is still the clearest statement of the problem:
Some dependencies are not written down either in documentation or in the code. The software engineer just “knows” that to make a change of a certain type, he or she has to change a certain set of modules.
Gall, Hajek & Jazayeri, ICSM 1998
They called what they found logical coupling, as opposed to the syntactic coupling a compiler sees. They read it off release histories: which modules kept changing in the same release. The idea has been renamed several times since. Thomas Zimmermann and colleagues called it evolutionary coupling in 2004. CodeScene calls it temporal coupling, and the microservice literature calls it logical coupling again. The measurement underneath has barely changed. Take the commits, find the files that keep arriving together, and treat that as evidence of a dependency.
Zimmermann's team built a tool called ROSE. It suggested further
edits from mined co-change rules across eight large open-source
archives. Its accuracy was modest: about 30 percent precision and
34 percent recall for a single suggestion. But when it made any suggestion at all, a correct
location was among its top three more than 70 percent of the time.
One of their stated aims was the point of this whole idea, that
such a tool should
“show up item coupling that is undetectable by program
analysis”. Their favourite example is a C function in
_Qdmodule.c that always changed with a Python
function in qdsupport.py. As they put it, detecting
that would require cross-language program analysis. Git needed no
analysis at all.
So the claim is old and the mechanism is simple. What I wanted was the size of the effect. If I build the co-change graph and the import graph over the same files, how much of the first is missing from the second?
Two graphs over the same files
I ran this on the same five JavaScript repositories as the last three posts, at the same pinned commits, so the numbers stay comparable. Those are webpack, eslint, prettier, rollup and vite. Both graphs use the same node set, which is the source files that exist at that commit.
The import graph is the one from the earlier
posts. It resolves relative require, import
and export … from specifiers to files and
ignores external packages.
The co-change graph comes from
git log --name-only. Two files share an edge when
they appear in the same non-merge commit. Two thresholds decide
which of those edges count as real. A pair needs at least five
co-changes, the threshold the microservice work uses. It also
needs a History Coupling Probability of at least 0.5: when one of
them changes, the other changes too at least half the time.
Support alone would just reward files that change constantly. I
also drop any commit touching more than 25 source files. A single
sweeping rename would otherwise mint thousands of pairs. Both
choices get tested later.
$ node analyze.mjs
webpack: 633 files, 2675 import edges, 177 co-change edges, 63.8% invisible, lift 41.8x
eslint: 388 files, 665 import edges, 13 co-change edges, 84.6% invisible, lift 20.3x
prettier: 527 files, 1654 import edges, 86 co-change edges, 76.7% invisible, lift 25.1x
rollup: 269 files, 1721 import edges, 227 co-change edges, 63.4% invisible, lift 11.5x
vite: 130 files, 651 import edges, 20 co-change edges, 40% invisible, lift 17.8x
Four files, six couplings, no imports
Before the totals, one group that shows the whole shape of the
result. webpack generates the code that loads chunks at runtime,
and it has a separate module per target environment:
ReadFile and Require for node,
Jsonp for the browser, ImportScripts for
web workers. All four are chunk-loading runtime modules. Every one
of the six possible pairs among them co-changes strongly, and not
one of them imports another.
The strongest pair, ReadFile and Require,
landed together 26 times with an HCP of 0.96. When one changed,
the other almost always changed in the same commit. Nothing in the
source says so. What binds them is that all four implement the
same contract against the same runtime globals, so a change to how
chunk loading works is a change to four files at once.
This is the same pattern the previous post priced as a modularity violation. Here I want the map rather than the bill: if a codebase is full of groups like this, how much of its real dependency structure is the import graph missing?
Two thirds of the coupling has no import behind it
Across the five repositories there are 523 pairs that clear both thresholds. 342 of them, just over 65 percent, have no import in either direction.
Read on its own, that number invites a conclusion I do not think is true: that the import graph is not much use. The opposite check says otherwise. There are far more pairs of files that do not import each other than pairs that do, so the fair question is what an import edge does to the odds. In webpack, 2.4 percent of import edges become strong co-change pairs, against 0.06 percent of non-importing pairs. An import makes strong co-change about 42 times more likely. Across the five repos that multiplier runs from 12 to 42.
Both readings are correct, and they answer different questions. The import graph is a good predictor of what will change together. It is also a small minority of what actually does.
| repo | source files | commits mined | import edges | co-change pairs | no import | odds vs chance |
|---|---|---|---|---|---|---|
| webpack | 633 | 7,379 | 2,675 | 177 | 63.8% | 41.8× |
| eslint | 388 | 3,910 | 665 | 13 | 84.6% | 20.3× |
| prettier | 527 | 4,107 | 1,654 | 86 | 76.7% | 25.1× |
| rollup | 269 | 2,334 | 1,721 | 227 | 63.4% | 11.5× |
| vite | 130 | 3,892 | 651 | 20 | 40.0% | 17.8× |
eslint is worth a note. It produces only 13 strong pairs from 3,910 commits, the thinnest result in the set, because its commits are unusually narrow: one rule, one file. A codebase with genuinely independent parts produces very little change coupling, which is the outcome the metric is supposed to reward. Percentages over 13 pairs are still fragile, and I read eslint's 84.6 percent as a direction rather than a number.
But almost none of it is far away
I expected the unimported pairs to be scattered across the system, connected by nothing but a shared idea. That is not what they look like. For each of the 342 pairs I measured the shortest path between the two files through the import graph, ignoring direction.
309 of the 342 pairs sit exactly two hops apart, and every single
one is reachable. There is no hidden parallel universe here. What
the co-change graph adds are shortcuts across a graph I already
had. webpack's four runtime modules are two hops apart because
they all import RuntimeModule, Template
and five other files. rollup's Chunk.ts and
finalisers/amd.ts are two hops apart for a different
reason. Chunk imports the finaliser index and calls
finalisers[format](…), so the real dependency
runs through a lookup table.
That reframing makes the signal easier to act on. "These two files have nothing to do with each other but keep changing together" would be a mystery. "These two files hang off the same parent and change together far more often than their siblings do" is a question with an answer, and the parent is usually where the answer lives.
Distance also separates the cases worth attention. 27 percent of the unimported pairs cross a directory boundary. Sibling files in one folder co-changing is close to expected. A pair that spans two parts of the tree is the one to look at.
The shape that keeps appearing
Reading the strongest unimported pairs in each repository, the same structure turns up in four of the five. Every time, it is a family of parallel implementations of one interface.
webpack has the four chunk-loading runtime modules. rollup has its
output finalisers, where iife.ts and
umd.ts shared 33 commits at an HCP of 0.85, and
amd.ts and umd.ts another 29. prettier
has two such families at once: the parser adapters, where
meriyah.js and typescript.js shared 19
commits, and the language plugins, where all 21 unimported
pairs among seven language-*/index.js files are
present, the strongest at an HCP of 0.93.
The prettier language plugins are the most extreme case, and they
sit four hops apart rather than two. Each is a seven-line file
exporting a printer, its languages, its options and its parsers.
They are structurally identical and structurally unrelated. The
plugin registry loads them through a dynamic
import() that my resolver does not follow. So a
change to the plugin contract is a change to seven files, and
neither the import graph nor a directory listing groups them.
Only the commits do.
Once you have seen the shape, the interpretation is not complicated. A set of files implementing one contract will change together whenever the contract changes. That is not a defect, and extracting a shared base class is often the wrong fix. It is simply a real dependency, and it is worth knowing it exists before someone edits three of the four and ships.
The two graphs disagree about which files matter
If change coupling were just a noisy view of the import graph, the files each one considers central would broadly agree. I took the ten highest-degree files in each graph per repository and compared the lists.
| repo | busiest by imports | busiest by co-change | shared in top 10 |
|---|---|---|---|
| webpack | RuntimeGlobals.js | RequireEnsurePlugin.js | 1 |
| eslint | rules/index.js | rules/max-depth.js | 3 |
| prettier | document/index.js | language-yaml/index.js | 0 |
| rollup | ast/nodes/shared/Node.ts | Chunk.ts | 1 |
| vite | node/utils.ts | middlewares/time.ts | 2 |
That is 7 shared files out of 50. The import graph nominates the
files everything depends on, which tend to be stable utilities and
base classes: RuntimeGlobals, a document builder, an
AST node base. The co-change graph nominates the files that
maintenance actually flows through. They are different questions,
and a codebase where the answers coincide is one where the
hardest-working files are also the most depended-on ones.
Does it grow over time?
The microservice work on logical coupling reports that it increases as a system ages. That claim comes from a registered report by Dario Amoroso d'Aragona and colleagues, which is a study design rather than a result, describing an earlier validation over 145 open-source microservice projects where coupling “tends to increase over time”. I wanted to know whether the same holds for a monolithic codebase.
So I rebuilt both graphs once per calendar year. The import graph comes from that year's tree; the co-change graph comes from a trailing 24-month window, because a cumulative window can only ever grow and would manufacture the trend I was testing for.
It does not replicate here. webpack moves the other way, from 86 percent in 2017 to 52 percent in 2026. rollup swings from 68 percent in 2019 to 23 percent the following year. Only prettier trends upward, from 58 to 74 percent between 2019 and 2023.
I do not read this as contradicting the microservice finding. A monolith can absorb its own coupling. When webpack's plugin families acquire a shared base module, pairs that were invisible become imports, and the share falls without any coupling disappearing. Services split across repositories have no such path: the coupling stays logical because it cannot become structural. So the trend may say more about how a system is packaged than about how it ages.
Where my numbers bend
The honest list, in the order I would attack my own results.
The thresholds move the answer. Sweeping support across 3, 5 and 10 and confidence across 0.3, 0.5 and 0.7 keeps webpack between 63 and 71 percent and rollup between 47 and 69, but prettier runs from 66 to 95 percent and vite from 0 to 43. The finding is stable for the two repos with hundreds of pairs and soft for the ones with dozens.
The commit-size cap moves it more. This is the confounder the microservice literature calls apparent coupling. Drop the cap entirely and webpack reports 2,331 pairs at 89.5 percent invisible, most of them manufactured by a handful of sweeping commits. Tighten it to 8 files and the same repo reports 58 pairs at 53 percent. My cap of 25 is a judgement call sitting between those, and anyone reproducing this should report theirs.
My import resolver is a regex, not a bundler. It
does not follow dynamic import(), which is exactly
why prettier's language plugins measure as four hops apart rather
than two. Every unresolved specifier inflates the invisible share.
Co-change is not causation. Two files can land in the same commit because a release script touched both, or because one person prefers large commits. The HCP threshold filters much of this, since a file that changes with everything has a low conditional probability with each partner. It does not filter all of it.
One bug deserves its own paragraph, because it nearly became a
result. My resolver checked candidate paths against the
filesystem, and existsSync happily says yes to a
directory. On a case-insensitive volume webpack's
require("./Stats") resolved to the
lib/stats/ folder, so the real edge to
Stats.js was silently dropped. Every missing import
edge turns a visible pair into an invisible one, which is exactly
the number I was reporting. Resolving against the analysed file
set instead removed the last genuinely unreachable pair from the
whole corpus and moved a handful of figures in the previous post,
which I have corrected.
What I would do with it
Change coupling tells you where to ask questions rather than what to fix. CodeScene's own documentation is blunt about this: coupling “in itself is neither good nor bad; it all depends on what modules are coupled and for what reason they co-evolve”.
Pairs that cross a directory boundary and share no import are the first place to look, because both signals say the files belong to different parts of the system and history says otherwise. Families of parallel implementations are worth documenting rather than refactoring, since the coupling is the contract working as intended and the risk is a partial edit. And a file that ranks high in the co-change graph but low in the import graph is carrying maintenance that its structure does not explain, which is the case I would want a name for before it grows.
What I did not find is the thing the framing promised. There is no second dependency graph hiding in git. There is one graph, and history draws a set of shortcuts across it that the source never mentions. Two thirds of the strongest couplings take one of those shortcuts, and nine in ten of them land two hops from where they started.
Files that change together are coupled whether or not they import each other. Across five JavaScript codebases, 65 percent of the strongest co-change pairs had no import between them, and 90 percent of those were still only two hops apart. An import makes co-change 12 to 42 times more likely, which makes the import graph a good predictor of coupling and a poor inventory of it.
References
- Harald Gall, Karin Hajek, Mehdi Jazayeri, "Detection of Logical Coupling Based on Product Release History" — ICSM 1998. The origin of the idea, and the source of the quote above. Twenty releases of a ten-million-line switching system with eight subsystems and up to 2,300 programs.
- Thomas Zimmermann, Peter Weißgerber, Stephan Diehl, Andreas Zeller, "Mining Version Histories to Guide Software Changes" — IEEE TSE 2005, extending ICSE 2004. ROSE, eight open-source archives, and all the precision, recall and top-three numbers quoted above.
- Dario Amoroso d'Aragona, Luca Pascarella, Andrea Janes, Valentina Lenarduzzi, Rafael Peñaloza, Davide Taibi, "On the Empirical Evidence of Microservice Logical Coupling. A Registered Report" — 2023. A study design rather than a results paper; the source of the five-co-change threshold and of the 145-project validation it describes.
- Marco D'Ambros, Michele Lanza, Romain Robbes, "On the Relationship Between Change Coupling and Software Defects" — WCRE 2009. The line of work connecting change coupling to defects.
- CodeScene, "Change Coupling" — the practitioner definition, including coupling by shared ticket ID and by author within a time window, and the refusal to give a threshold.
- Lu Xiao, Yuanfang Cai, Rick Kazman, Ran Mo, Qiong Feng, "Identifying and Quantifying Architectural Debt" — ICSE 2016. The previous post in this series, and the source of the History Coupling Probability used here.