Your manager does not care that the payment module is “a mess.” They have heard that sentence about six different modules from four different engineers this quarter, and every one of them wanted two weeks to rewrite something that currently works. From where they sit, “this code is bad” is indistinguishable from “I do not enjoy working in this file.”

I have watched good refactoring proposals die in this exact spot. The engineer was right about the code and still lost the argument, because they argued in a language the person holding the budget does not speak. The fix is not a better slide deck. The fix is translation. Technical debt is not a code quality problem you need leadership to understand. It is a business cost you need to measure and put in front of them in their own units.

Why “the code is bad” never wins

Here is the naive pitch, the one that fails almost every time:

“The auth service is full of technical debt. It is hard to work in, nobody understands it, and we keep introducing bugs. We need two sprints to refactor it.”

Read that as a manager. Every clause is a feeling. “Full of technical debt” is not a number. “Hard to work in” is not a number. “Keep introducing bugs” is closer, but you did not say how many or what they cost. And “two sprints” is a very precise cost sitting next to a completely vague benefit.

The manager’s job is to allocate scarce engineering time against outcomes the business can see: features shipped, revenue protected, customers retained. Your pitch gave them a definite price and an indefinite return. Of course they defer it. They are not being short-sighted. You handed them a bet with unknown odds and asked them to pay up front.

The problem is that you translated a business cost back into an engineering complaint. Debt already shows up in numbers the business tracks. You just have not connected your file to those numbers yet.

The four numbers leadership already watches

You do not need to invent a framework. DORA (DevOps Research and Assessment) has spent a decade correlating four metrics with organizational performance, and leadership at most companies has already heard of them. They are the bridge you are looking for.

MetricWhat it measuresWhat debt does to it
Deployment frequencyHow often you ship to productionDebt slows it: fragile code means longer manual verification before each release
Lead time for changesCommit to running in productionDebt inflates it: tangled code takes longer to change and review safely
Change failure ratePercent of deploys causing an incidentDebt raises it: hidden coupling means changes break things far away
Time to restore serviceHow fast you recover from an incidentDebt lengthens it: nobody understands the code well enough to fix it fast

The insight is that technical debt is not invisible to the business. It has been showing up in these four numbers the whole time. It just got attributed to “engineering being slow” or “the team having a rough quarter” instead of to a specific, fixable cause. Your job is to name the cause and attach a number to it.

There is a fifth number that is not strictly DORA but closes the deal: engineer-hours per feature. If shipping a comparable feature in the debt-ridden module takes 3x the hours it takes in a clean module, that ratio is your entire argument in one line.

Naive to measured: instrument before you pitch

The jump most engineers skip is measurement. They go straight from “I feel the pain” to “give me two sprints.” The winning version puts one step in between: quantify the pain in the module you want to fix, using data you already have.

You do not need a fancy platform. Your git history, your ticket tracker, and your incident log hold everything.

# Pull change-failure and lead-time signals straight from git + tickets.
# The point is not precision to three decimals. It is a defensible number.

from datetime import datetime
from collections import defaultdict

def module_health(commits, incidents, module_path):
    """
    commits:  list of dicts {sha, author, ts, files, ticket, merged_ts, first_commit_ts}
    incidents: list of dicts {ts, root_cause_files, minutes_to_restore}
    """
    touched = [c for c in commits if any(f.startswith(module_path) for f in c["files"])]

    # Lead time: hours from first commit on a change to merge, per ticket.
    lead_times = []
    by_ticket = defaultdict(list)
    for c in touched:
        by_ticket[c["ticket"]].append(c)
    for ticket, cs in by_ticket.items():
        start = min(c["first_commit_ts"] for c in cs)
        end = max(c["merged_ts"] for c in cs)
        lead_times.append((end - start).total_seconds() / 3600)

    # Change failure rate: deploys touching this module that caused an incident.
    incident_files = {f for inc in incidents for f in inc["root_cause_files"]}
    failing = [c for c in touched if any(f in incident_files for f in c["files"])]
    change_failure_rate = len(failing) / max(len(touched), 1)

    # Restore time attributable to this module.
    restore = [inc["minutes_to_restore"] for inc in incidents
               if any(f.startswith(module_path) for f in inc["root_cause_files"])]

    return {
        "changes": len(touched),
        "median_lead_time_hrs": _median(lead_times),
        "change_failure_rate": round(change_failure_rate, 3),
        "incidents": len(restore),
        "median_restore_min": _median(restore),
    }

def _median(xs):
    if not xs:
        return 0
    xs = sorted(xs)
    n = len(xs)
    return xs[n // 2] if n % 2 else (xs[n // 2 - 1] + xs[n // 2]) / 2

Run this against the module you want to refactor and against a healthy module for contrast. Now you have a comparison, not a complaint. “Changes to the auth service take a median of 34 hours to merge and fail at 22 percent. Changes to the notifications service, similar size, take 9 hours and fail at 4 percent.” That sentence does the work your two-sprint ask could not.

Turn the numbers into money and time

Metrics move managers. Money and headcount move managers faster, because that is what they defend in their own budget meetings. So do one more translation: convert the gap into recurring cost.

The model is simple. You are paying an ongoing tax. Refactoring is buying down the tax.

Debt tax per month
===================

Extra lead time:
  8 changes/month to auth x (34 - 9) extra hours = 200 engineer-hours/month
  at ~$60/hr fully loaded                          = $12,000/month

Incidents:
  2 auth incidents/month x 90 min restore
  x 4 engineers pulled in x $60/hr                 = $720/month
  plus SLA credits / churn risk (conservative)     = $3,000/month

Slower shipping:
  1 fewer feature/quarter reaches customers
  attributed to auth drag                          = (revenue team estimates)

Recurring tax (measurable portion)          ~= $15,000/month, and rising

Notice what this reframes. The refactor is not a $40,000 cost (two sprints, four engineers). It is a $40,000 investment that stops a $15,000 per month bleed and pays for itself in under three months. That is a payback-period argument, and payback periods are a language every manager and finance partner already speaks. You have moved the conversation from “should we let engineers polish code” to “should we keep paying $180,000 a year to avoid a $40,000 fix.” Framed that way, deferring it is the irresponsible choice, and now that pressure is on the other side of the table.

The approval template

Here is the one-page structure I have seen get refactoring approved. It fits on a single screen on purpose. If it needs a meeting to understand, it is too long.

REFACTORING PROPOSAL: Auth Service Decoupling

Problem (in business terms)
  The auth service is our slowest and most failure-prone area to change.
  It is now on the critical path for three roadmap features this half.

Evidence (measured, last 90 days)
  - Median lead time: 34 hrs   (comparable module: 9 hrs)
  - Change failure rate: 22%   (comparable module: 4%)
  - Incidents: 6, avg restore 90 min, 4 engineers each
  - Recurring cost of the gap: ~$15k/month and rising with load

Proposal
  Extract session handling from auth core; add a test seam and
  contract tests at the boundary. No behavior change for users.

Cost
  2 sprints, 2 engineers. Feature-flagged, incremental, revertible.

Expected result (how we will know it worked)
  - Lead time on auth changes: 34 hrs -> under 15 hrs
  - Change failure rate: 22% -> under 8%
  - Re-measured 60 days after merge, reported back to you

Risk of doing nothing
  Three roadmap features inherit the 22% failure rate and the drag.
  Cost compounds as we add load to the module this half.

Four things make this template work.

It leads with the business, not the code. The first line a manager reads is about roadmap risk, not architecture. The architecture is one line, buried in the middle, where it belongs for this audience.

Every claim has a number and a baseline. A number alone (“22% failure rate”) means nothing without the comparison (“versus 4% next door”). The baseline is what turns a statistic into an argument.

It commits to re-measurement. This is the part engineers skip and the part that builds trust for the next ask. You are promising to come back in 60 days and show the same metrics moved. That converts your proposal from a request for faith into a testable claim, and it is why your second and third refactoring pitches get approved faster than your first.

It scopes small and reversible. Two sprints, feature-flagged, incremental. Nobody approves a three-month rewrite. Everybody can approve a two-week, revertible experiment with a defined payoff. If the module is huge, slice off the highest-tax piece and pitch only that.

Where this approach breaks down

Be honest about the failure modes, because the template is not magic.

You cannot measure everything, and forcing it looks worse than prose. Some debt (a bad abstraction that makes every new engineer take a month longer to onboard) resists clean quantification. Do not fabricate a number to fill the template. State the cost qualitatively and back it with the metrics you can measure. A made-up dollar figure that a finance partner pokes a hole in costs you all your credibility on the real numbers.

If your DORA numbers are bad everywhere, this does not isolate a target. The comparison is the whole trick. If every module deploys slowly and fails often, you have a systemic problem (CI, testing culture, release process) that a single refactor will not fix, and pitching one module as special will mislead everyone. Fix the pipeline first.

Re-measurement can prove you wrong. If you refactor and the metrics do not move, you either misdiagnosed the cause or the debt was not the bottleneck. That is genuinely useful, but it stings, and it means you should reserve this heavy machinery for debt you are confident is the actual constraint. Do not use it to justify a rewrite you wanted for aesthetic reasons.

Small teams may not have the data. If you deploy twice a month and had one incident all year, DORA metrics are too coarse to say anything. At that scale, the honest pitch is different: “this will slow down the next three features, here is a concrete example of a change that took a week and should have taken a day.” Specificity replaces statistics.

What to actually do

Stop calling it technical debt in the room. That phrase has been worn down to noise. Instead, walk in with three numbers from the module you want to fix, the same three numbers from a healthy module, and the monthly cost of the gap. Ask for the smallest reversible slice of work that moves those numbers, and promise to re-measure in 60 days.

The uncomfortable truth is that most refactoring pitches deserve to lose. They are requests to spend real, scarce time against a benefit the engineer feels but has not measured. When you do the measurement and the numbers are strong, the argument makes itself, and you rarely need the template at all. When you do the measurement and the numbers are weak, you have just saved yourself from spending two sprints on the wrong thing. Either way, measuring first is the move. Your manager was never the obstacle. The missing translation was.