Everyone calls bad architecture "debt", but almost nobody can name the interest rate. A 2016 paper worked out how to compute one from version history. I rebuilt an approximation of it and ran it on five JavaScript codebases.

From metaphor to a number

The four earlier posts in this series measured structure at one instant. Does the architecture pass its fitness functions? How mature is its modularity? How far can a change propagate, and how much of the system is replaceable? None of them said anything about money. Technical debt is a financial metaphor, and a loan has two numbers: the principal and the rate. Every static metric estimates principal at best.

Lu Xiao, Yuanfang Cai, Rick Kazman, Ran Mo and Qiong Feng closed that gap in "Identifying and Quantifying Architectural Debt" at ICSE 2016. Their definition is worth reading twice, because every word of it is measurable:

A group of architecturally connected files that incur high maintenance costs over time due to their flawed connections.

Xiao, Cai, Kazman, Mo & Feng, ICSE 2016

The method has three steps. Find the flawed file groups. Add up what each group costs, release after release. Then fit a curve to the cost and read the interest rate off the curve. On seven Apache projects, the groups they found consumed 51 to 85 percent of all maintenance effort. The five most expensive groups per project held 11 to 32 percent of the error-prone files but consumed 27 to 67 percent of the effort. Debt, it turns out, is concentrated.

One matrix, two kinds of coupling

The instrument behind the method is a DSM with a twist. Files are rows and columns, as in the propagation cost post. But each cell now records two different facts. A structural mark, dp, says the row file imports the column file. A percentage says how often the pair changed together. That is the HCP: the probability that the column file changes when the row file does, computed from the commit log.

one matrix, two kinds of coupling (1)dp, 62%31%(2)78%80%(3)A.js1B.js2C.js3 Cell (row, column): “dp” — the row file imports the column file. “62%” — when the row file changes, the column file changes too, 62% of the time. B and C co-change with no import between them.
A structural mark and a historical percentage in the same matrix. The interesting cells are the ones where the two disagree.

Structure and history usually agree. A file that imports another tends to change with it, and files that ignore each other tend to stay strangers. The debts live where the two disagree, and the paper names four shapes of disagreement.

anchor hubvite: config.ts ⇄ css.ts, 59 shared commitsdp, %dpdp, %%anchor dominantwebpack: CssParser and its Icss dependencies%%dpdpanchor submissiverollup: Chunk.ts follows its importers, HCP 81%dp, %dp, %modularity violationwebpack: 10 plugins, no imports, 19 shared commits%%%%%% the four ways a debt shows up in the matrix dp — the row file imports the column file · % — the pair changes together in history
The four flaw patterns, each with the strongest example my approximation found. The marks show the shape; the numbers in the captions are measured.

An anchor hub imports its members and is imported by them, and the whole knot changes together. This is spaghetti with evidence. An anchor dominant file is an unstable interface: dependents import it, and when it changes, they change. An anchor submissive file is the reverse, and to me the more interesting smell. Its dependents import it, yet history shows the anchor changing after them. The abstraction leaks upward.

The fourth pattern is the one this series could not see until now. A modularity violation is a pair of files that share no import in either direction yet keep changing in the same commits. They share a secret, a copied algorithm or an assumption about ordering. No parser can find that edge, because the edge is not in the code. Propagation cost and decoupling level both scored these files as independent. The commit log knows better. In the paper's benchmark this invisible category was the most expensive of the four.

Rebuilding it on git history alone

The authors' tooling became DV8, a commercial product, and there is no open source reference implementation. Like the decoupling level experiment, my version is an approximation built from the papers, so I want to be precise about what it does. It is about 350 lines of JavaScript with no dependencies:

  • Structure: resolve every relative require and import in the repo's source tree into a file-to-file edge set.
  • History: replay git log, skip commits touching more than 25 source files as refactor noise, and count how often each file pair appears in the same commit. A pair qualifies as coupled at six shared commits and an HCP of at least 0.5.
  • Cost: a commit whose message matches fix, bug, patch, or an issue-closing phrase is a bug fix. A debt's penalty is the lines added plus deleted by bug fixes on its files.
  • Interest: sample ten release tags spaced evenly across the project's life, accumulate each group's penalty at every boundary, and fit linear, logarithmic and exponential curves. If none reaches R² 0.9, the debt is fluctuating and gets a cubic instead.

One filter matters more than it looks. I only report a modularity violation when both files still exist at the head commit. Without that rule, every file that was ever split or renamed becomes a phantom pair: heavy co-change in history, nothing at head. My first run made this mistake. It reported eslint's old eslint.js and rule-context.js as the repo's biggest debt. Both were deleted years ago in the flat-config rewrite. The debt was real once, and eslint paid it off the expensive way.

$ node analyze.mjs
webpack: 633 files, 46 debts, debts 50.6% of effort, top5 24.6%
eslint: 393 files, 4 debts, debts 3.5% of effort, top5 3.5%
prettier: 534 files, 13 debts, debts 17.3% of effort, top5 13.9%
rollup: 272 files, 54 debts, debts 81.1% of effort, top5 52.5%
vite: 138 files, 32 debts, debts 66.5% of effort, top5 32.4%

webpack's plugin family, at 17 percent

The clearest single debt in the five repos is a family of ten webpack plugins. It spans APIPlugin, DefinePlugin, HotModuleReplacementPlugin, NodeStuffPlugin and ProvidePlugin, plus five module-system plugins under dependencies/. I checked by hand: none of the ten imports any other. Structurally they are ten independent modules, and every static metric in this series would praise them.

History disagrees. AMDPlugin and CommonJsPlugin landed in the same commit 19 times, with an HCP of 0.53. APIPlugin and HotModuleReplacementPlugin shared 18 commits. Each plugin re-implements the same pattern over webpack's parser hooks, so a change to the pattern is a change to all of them. That is a textbook modularity violation: the coupling lives in a shared idea, not in an import.

The family cost 4,106 lines of bug-fix churn across ten release checkpoints from 2014 to 2026. The cumulative curve fits an exponential with R² 0.975. The exponent puts the growth near 17 percent per checkpoint. That is compound interest in the plain financial sense. Each period costs about a sixth more than the one before, and half of the family's total cost landed in the last three checkpoints.

cumulative bug-fix cost, measured points and fitted curve 53850release period 1 → 10linear — stable ratevite — css.ts + moduleGraph.ts, R² 0.9937830release period 1 → 10logarithmic — decreasing ratevite — packages.ts + resolve.ts, R² 0.9839020release period 1 → 10exponential — accelerating ratewebpack — the plugin family (10 files), R² 0.9720970release period 1 → 10polynomial — fluctuating ratewebpack — CSS runtime pair, R² 0.99
One measured debt per curve type. Dots are cumulative bug-fix lines at each release checkpoint; the line is the fitted model.

The other three panels show the shapes the paper predicts. vite's css.ts and moduleGraph.ts pay a steady 500 lines per checkpoint, a plain subscription fee. vite's packages.ts and resolve.ts fit a logarithm. They were expensive early and then settled, which is what a paid-down debt looks like. webpack's CSS runtime pair was free for six checkpoints and then surged. No simple curve reaches the fit floor, so it counts as fluctuating. Across my five repos, I classified every debt with at least 500 lines of penalty. The split: 55 percent linear, 28 percent fluctuating, 13 percent logarithmic and 4 percent exponential. The paper's split over seven Java projects was 53, 25, 14 and 8. I did not expect a rough approximation to land that close. The matching order is the single result I trust most.

Few groups, most of the bill

The paper's headline claim is concentration. A handful of flawed groups eats far more than its share of maintenance. That replicates clearly in four of my five repos.

the top five debts: share of files vs share of cost webpack5.5% of error-prone files24.6% of bug-fix efforteslint2.9% of error-prone files3.5% of bug-fix effortprettier9.9% of error-prone files13.9% of bug-fix effortrollup30.1% of error-prone files52.5% of bug-fix effortvite5.3% of error-prone files32.4% of bug-fix effort
Share of error-prone files inside the five most expensive debts, against the share of total bug-fix lines those files account for.
repo source files commits mined debts effort in debts top-5: files → effort
webpack6337,3794650.6%5.5% → 24.6%
eslint3933,97943.5%2.9% → 3.5%
prettier5344,1251317.3%9.9% → 13.9%
rollup2722,3705481.1%30.1% → 52.5%
vite1383,9013266.5%5.3% → 32.4%

In webpack, the files inside the top five debts are 5.5 percent of the error-prone files. They account for 24.6 percent of all bug-fix lines, a factor of four and a half. In vite the factor is six. rollup is the indebted one. 81 percent of its entire bug-fix effort flows through detected debt groups. Most of that runs through one 45-file cluster in the AST node hierarchy, whose strongest pair co-changed 36 times with an HCP of 0.86.

eslint is the outlier, and the reason is the phantom filter. The flat-config rewrite dissolved its historically expensive groups. The files no longer exist, so the debts no longer count. What remains measures 3.5 percent. I read that as the metric working. A repo that just paid off its architectural debt should score near zero, and eslint paid with a multi-year rewrite.

Where my numbers bend

The honest list, in the order I would challenge my own results. My bug-fix heuristic reads commit messages, where the paper followed issue-tracker links. It misses fixes with terse messages and counts some refactors that mention a bug. My structural graph is import-only. Dependency injection, dynamic require and type-only references are invisible to it, and each one can turn a truly coupled pair into a false modularity violation. My release checkpoints are ten tags sampled across each project's life, not the paper's curated stable releases. Coarse sampling flattens curve shapes. My pattern rules are heuristic reconstructions of DV8's detectors, without the design rule hierarchy underneath. And these are five JavaScript repos measured with one method. The paper studied seven Java projects with issue trackers. The numbers rhyme, but they are not the same experiment.

One mistake deserves its own sentence. An early version of this experiment confidently reported debts made of deleted files, and nothing in the math objected. Any metric built on history needs to check that its files still exist.

Which debts to pay first

The title asked for an interest rate, so here is how I would use one. The curve type is a triage rule. Exponential debts get paid first, whatever their current size. webpack's plugin family is four thousand lines so far, and the rate is still climbing. Linear debts are a predictable fee. Pay them down when the fee starts to rival the cost of the refactor. Logarithmic debts are already paying themselves off, so leave them alone. Fluctuating debts get watched, not paid, until the shape settles. And the groups worth checking first are the ones no static tool will ever flag: files that keep meeting in commits without a single import between them.

An architecture debt is a group of files. Its principal is their bug-fix churn, and its interest rate is the fitted slope of that churn across releases. The most expensive coupling never appears in the import graph, but it shows up in every commit.

I'm Maxat, a software engineer testing architecture research against real repositories. The mining scripts, pinned commits and raw results behind every number live in this site's repository. The method is about 350 dependency-free lines you can point at your own git history.

References