Principal vs interest: what technical debt cost actually is
Technical debt is easier to reason about once you split it into two parts. Principal is the one-off effort needed to fix an item: rewriting the module, replacing the brittle integration, automating the manual step. Interest is what you keep paying while the item stays as it is. It turns up as recurring costs:
- extra hours on every change that touches the affected code
- slower builds and longer feedback loops
- reruns of flaky tests
- manual deploy steps someone has to remember
- on-call pages, and exposure to incidents when the fragile path fails
Only interest justifies spending budget now. Principal tells you what a fix costs. Interest tells you what it costs to do nothing. If you treat debt as a recurring cost line, interest per period sets how urgent an item is, and the comparison becomes interest per period × periods remaining against principal.
That framing also tells you what to ignore. Some debt was a sensible trade-off: you shipped a rough version to test demand, and that bought information quickly. Ugly code in a stable module that nobody changes has close to zero interest. Rewriting it costs principal and saves nothing, and the rewrite brings its own risk. It is usually best left alone.
This approach is for backend and platform teams that own long-lived services and have to compete with feature work for capacity. Saying "the code is bad" doesn't win that argument. Showing a cost that recurs every sprint does.
How to estimate debt interest without perfect data
You rarely have clean data on debt interest, but most teams already collect enough signals to build a defensible range.
Signals you already have
Version-control history. Churn shows where engineers keep returning. To count commits touching a path over a window, run git log --since="6 months ago" --oneline -- path/to/module | wc -l. To rank files by change frequency, run git log --since="6 months ago" --name-only --format="" | sort | uniq -c | sort -rn. Files that change often and sit in the module you suspect are your candidates.
On-call and incident logs. Count pages, manual restarts and runbook steps traced to the module. This toil is interest paid outside normal feature work, so it rarely shows up in sprint estimates.
CI timing. Slow or flaky suites in the affected area cost time on every change. Note the median wall-clock time and how often engineers rerun jobs.
Ask the people who touch it
Ask each engineer who changes the code regularly for an hours-per-change range: how much extra time a typical change costs compared with a clean module. Ask for low and high bounds, not a single number. Then combine:
interest per period ≈ (changes per period × extra hours per change) + toil hours + CI overhead hours
Know the limits
Churn is a proxy. A module can churn because the product changes quickly, not because the code is poor. Low churn can also hide debt: if engineers avoid touching a module, its interest shows up as features that were never built. Self-reported estimates are subjective and easy to inflate. Record each figure as a range, and note the evidence behind it, such as commit counts, incident IDs or CI dashboards, so anyone can challenge it later.
#!/usr/bin/env bash
# Rough debt-interest range for one module over a time window.
# Every input below is an illustrative assumption; replace it with your team's evidence.
set -euo pipefail
MODULE="src/billing" # path you suspect carries debt
WINDOW="6 months ago"
# Change frequency: commits touching the module in the window
changes=$(git log --since="$WINDOW" --oneline -- "$MODULE" | wc -l)
# Evidence: the 10 most-changed files inside the module (blank lines removed)
echo "Top changed files since $WINDOW:"
git log --since="$WINDOW" --name-only --format="" -- "$MODULE" \
| sed '/^$/d' | sort | uniq -c | sort -rn | head -n 10
# Illustrative inputs, recorded as ranges with their source noted
extra_low=1 # engineer-reported extra hours per change, low bound
extra_high=4 # engineer-reported extra hours per change, high bound
toil_hours=30 # from pages and manual restarts in the incident log
ci_hours=20 # from reruns x median job time on the CI dashboard
# interest ~= (changes x extra hours per change) + toil + CI overhead
awk -v c="$changes" -v lo="$extra_low" -v hi="$extra_high" \
-v t="$toil_hours" -v ci="$ci_hours" 'BEGIN {
printf "Commits touching module: %d\n", c
printf "Interest range (illustrative estimate): %.0f-%.0f hours\n", c*lo + t + ci, c*hi + t + ci
}'
A shell script that counts commits and ranks the most-changed files in one module with git log, then combines engineer-reported hours per change, toil and CI overhead into a low and high interest range, using placeholder inputs labelled as an illustrative estimate.
A worked example: payback period for a fragile config loader
Take a config loader that every service imports and that breaks in subtle ways when someone adds a key. The figures below are an illustrative estimate. Every input is assumed, not measured.
Assumed inputs:
- About 6 PRs a month touch the loader, and each costs roughly 3 extra hours in debugging, reviewer confusion or re-runs. That is 18 hours.
- About 1 page a month traces back to the loader, costing roughly 4 hours of on-call time.
- Interest is therefore about 22 hours a month.
- Principal, a rewrite with tests, is assumed at 60 hours.
Two ratios come out of this. Interest saved per hour of principal is 22 ÷ 60 ≈ 0.37 hours a month for each hour invested. Payback period is 60 ÷ 22 ≈ 2.7 months, so about three.
Point estimates hide uncertainty, so rerun the calculation with the ranges from your register:
| Scenario | PRs × extra hrs | Pages × hrs | Interest/month | Fix (hrs) | Saved per principal hr | Payback |
|---|---|---|---|---|---|---|
| Pessimistic | 4 × 2 | 1 × 3 | 11 | 90 | 0.12 | ~8 months |
| Central | 6 × 3 | 1 × 4 | 22 | 60 | 0.37 | ~3 months |
| Optimistic | 8 × 4 | 1 × 5 | 37 | 45 | 0.82 | ~1 month |
The spread is wide. Even so, the pessimistic case still pays back within a year. That tells you more than the central figure does: the item is worth doing under most plausible assumptions.
Treat these numbers as a tool for ranking items against each other, not as accounting. They show which debt to pay first. They do not predict what finance will see on a timesheet.
Prioritisation: ranking by payback, then adjusting for risk
Rank the backlog by payback period: estimated principal in hours divided by interest saved per month. A tie-breaker is interest saved per hour of effort, which favours small fixes that remove a frequent irritant. Neither metric considers how ugly the code looks. A tangled module that nobody touches costs nothing each month, so it ranks near the bottom. A tidy-looking deploy script that fails once a week can rank near the top.
When to override the ranking
Some debt does not behave like a steady drain. Its cost is a low-probability, high-impact tail. Examples include security exposure, end-of-life dependencies, compliance gaps, single points of failure and data-integrity hazards. If you average a rare breach or corrupted dataset into "hours per month", the result looks small, and the item sinks in the list. Give these items a separate risk lane with its own reserved capacity, ranked by severity and exposure rather than by hours.
When not to pay at all
Skip debt with no interest, such as stable code that rarely changes and rarely breaks. Also skip code you are likely to delete before the payback period ends.
How to pay
| Approach | Principal estimate | Time to first payback | Risk | Ongoing cost |
|---|---|---|---|---|
| Big rewrite | Often unreliable, tends to grow | Delayed until cutover | High at cutover | One system after cutover |
| Incremental (strangler-style) | Smaller, more predictable slices | Early, per slice | Lower, reversible steps | Two code paths running for a while |
Incremental paydown usually suits a ranked backlog better, because each slice delivers its own payback. Plan an explicit date to remove the old path so the duplication does not become new debt.
Structuring a protected refactoring budget, and the alternatives
A standing allocation works best when it is boring and predictable. Reserve a fixed share of team capacity, or a recurring slot such as one day per sprint, and treat it like on-call: it isn't renegotiated each planning cycle. Keep a short ranked backlog of three to five items drawn from your payback ranking, and name one owner who decides what enters it. A short list stops the slot turning into a grab bag. A single owner stops the queue from being whatever the loudest engineer wants that week.
| Approach | Fits when | Main weakness |
|---|---|---|
| Protected standing budget | Debt is spread across many modules and interest accrues steadily | Can become a use-it-or-lose-it bucket for pet projects |
| Dedicated cleanup sprint | A large item needs sustained focus, such as a schema or framework migration | Debt accrues again between sprints, and the sprint is easy to postpone |
| Boy scout fixes | Fixes are small, local and low-risk | Never reaches structural problems |
| Attach to feature work | The feature already touches the indebted code | Inflates feature estimates and invites scope cuts |
The standing budget has two predictable failure modes. Without a ranked backlog, it gets spent on interesting work rather than high-payback work. Without explicit protection, it is the first thing cut in a crunch, which is usually when interest is highest.
Paydown also has its own costs. Refactors cause regressions. Migrations need windows during which two paths run in parallel. Review load also tends to concentrate on the few senior engineers who understand the old code. Count that review time against the budget rather than treating it as free.
Keeping a lightweight debt register
A register is worth keeping only if each entry carries enough information to be ranked and challenged. The minimum fields are:
- Item: a one-line description specific enough that two engineers would agree on when it is fixed.
- Affected system: the service, library or pipeline, so the entry can be linked to incidents and change history.
- Interest estimate and its evidence: hours lost per month, plus where that number came from, such as ticket links, on-call notes or a count of workaround commits. An estimate with no evidence attached cannot be checked later.
- Principal estimate: the cost to fix, in engineer-days.
- Risk notes: blast radius, security exposure and coupling to other work.
- Owner: one named person, not a team.
- Date last reviewed.
The example entry that follows records the config loader from section 3 using these fields.
Review cadence
Review the register on a fixed schedule. Monthly or once per planning cycle suits most teams. Each review does two jobs. First, it compares recorded interest against what actually happened. If the config loader was estimated at six hours a month but caused two incidents, the estimate and its payback ranking change. Second, it confirms closure. An item counts as paid off only when the interest stops, for example when the workaround commits and related tickets disappear. Merging the refactor is not enough.
Keep it small
A register full of entries nobody has reviewed in a year teaches people to ignore it, and then it carries no weight in budget discussions. Include only items that currently cost time or carry measurable risk. Delete aesthetic complaints and dormant code.
# One register entry. Every field is required; an entry missing evidence
# or an owner should not be ranked against the others.
- id: DEBT-014
# Specific enough that two engineers agree on when it is fixed.
item: "Config loader re-parses all YAML files on every request instead of caching at startup"
affected_system: billing-api / internal/config
interest:
hours_per_month: 6 # illustrative estimate, not a measured value
evidence:
# Where the number came from, so the next review can recheck it.
- "3 on-call pages last quarter tagged 'config-reload' (tickets OPS-201, OPS-219, OPS-233)"
- "11 workaround commits touching internal/config in the last 90 days"
principal:
engineer_days: 4 # cost to fix: add cache, invalidation, tests
risk_notes: >
Blast radius is every billing-api endpoint. A malformed file currently
fails per request rather than at deploy. Refactor overlaps with the
planned secrets migration, so schedule them together.
owner: Priya Raman # one named person, not a team
last_reviewed: 2026-10-01
status: open # open | fixing | paid_off
# Closure check for future reviews: mark paid_off only after a full
# review period with no config-reload pages and no new workaround
# commits, not when the refactor merges.
closure_criteria: "Zero config-reload pages and zero workaround commits for one review period"
A YAML debt register entry for the config loader that records each minimum field, ties the interest estimate to checkable evidence, and states the condition that confirms the item is paid off.
Impact on cost and engineering time: verifying paydown and making the case
Paydown counts as done only after you re-measure the signals that justified it. Use the same method and a similar time window as before: cycle time for changes that touch the module, pages traced to it, CI duration, and logged toil hours. Report the before and after figures in hours and incidents. Leave out complexity or lint scores. A manager can't weigh them against a feature, and a better score with the same number of pages means the interest was coming from somewhere else.
Illustrative estimate (assumed numbers, not a benchmark)
Monthly interest = (pages × hours per page) + (changes touching the module × extra hours per change) + toil hours
- Before: 3 × 4 + 20 × 1.5 + 6 = 48 h/month
- After: 1 × 4 + 20 × 0.5 + 1 = 15 h/month
- Recovered: 48 − 15 = 33 h/month, about four engineer-days
- Payback period = paydown effort ÷ recovered hours = 80 h ÷ 33 h ≈ 2.4 months
The recovered hours become feature capacity only if they appear in planning. Add them to the next quarter's available capacity. Otherwise they get absorbed and nobody can point to them.
To make the case to product and engineering managers, describe it as a trade measured in the same hours as features: "The config loader costs about 48 hours a month. Fixing it takes 80 hours this quarter, and from next quarter it frees roughly 30 hours a month for roadmap work." Managers can weigh that against a feature. "The code is messy" doesn't give them anything to compare.
The process runs as a loop:
Measure interest → Rank by payback → Fund from budget → Pay down → Re-measure → Update register → Measure interest
Re-measuring keeps future estimates honest. If the measured savings keep falling short of the projections, scale down the next estimates to match.
+------------------+ +------------------+ +------------------+
| 1. Measure | | 2. Rank by | | 3. Fund from |
| interest |--->| payback |--->| budget |
| pages, CI time, | | effort / hours | | quarter capacity |
| toil, cycle time | | recovered | | allocation |
+------------------+ +------------------+ +------------------+
^ |
| v
+------------------+ +------------------+ +------------------+
| 6. Update | | 5. Re-measure | | 4. Pay down |
| register |<---| same signals, |<---| the module |
| scale estimates | | same window | | |
| to actual saving | | before vs after | | |
+------------------+ +------------------+ +------------------+
|
v
+------------------+
| Recovered hours |
| added to next |
| quarter capacity |
+------------------+
The paydown loop: measure interest, rank by payback, fund from the budget, pay down, re-measure the same signals over a similar window, then update the register, with recovered hours added to next quarter's planned capacity.
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 technical debt cost, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.