Technical Debt Management and Refactoring Questions
Identifying, prioritizing, and paying down technical debt sustainably. Covers recognizing debt, making the case to invest in it, refactoring safely behind tests, and balancing debt reduction against feature velocity. Includes keeping a codebase maintainable over the long term.
You need to estimate the effort and ROI of a medium-sized refactor of a shared library used by multiple services. Describe your approach to break down the work, identify risks, estimate effort, and compute an ROI based on expected reductions in bugs, developer time saved, and deployment failures.
Sample Answer
Direct answer
Break the refactor into components you can estimate separately (the core change, test additions, and integration/rollout risk), estimate each with a range rather than a point value, and compute ROI as expected annual benefit (fewer bugs, saved developer time, fewer deployment failures) divided by the estimated cost, expressed as a payback period.
Structured elaboration
- Decompose the work: the core refactor itself, the test coverage needed to do it safely (often underestimated), and the integration effort for the services that depend on the shared library.
- Identify risks per component: does the core change touch a part of the library with poor existing test coverage (higher risk, wider estimate range), and how many downstream consumers need to validate compatibility (more consumers, more coordination risk)?
- Estimate effort with a range, not a point: use a three-point estimate (optimistic, likely, pessimistic) per component and sum them, which naturally produces a wider, more honest total range than three separately-precise-looking point estimates.
- Compute expected benefit: reduced bug rate in the affected area (historical bug count times average fix cost), developer time saved (fewer workarounds needed by teams currently avoiding the messy code), and reduced deployment failure rate (fewer rollbacks, each with an estimated cost).
Worked example
A shared library refactor: core change estimated at 3-5-8 engineer-days (optimistic-likely-pessimistic), test additions at 2-4-7 days, and integration validation across 4 downstream consumers at 1 day each likely (4 days), pessimistic 8 days if two consumers need code changes, not just validation. Likely total: 5 + 4 + 4 = 13 engineer-days; pessimistic: 8 + 7 + 8 = 23 engineer-days. At a fully-loaded cost of $700/engineer-day, likely cost is $9,100, pessimistic $16,100. Expected annual benefit: the library currently causes an estimated 8 bugs/year at $1,200 average fix cost ($9,600/year) plus an estimated 40 hours/year of developer workaround time across the 4 consuming teams at $85/hour ($3,400/year), totaling $13,000/year in benefit from those two drivers alone. The question's third named driver, reduced deployment-failure cost, also needs a figure: the library's fragility currently contributes to an estimated 6 rollback events/year across the 4 consuming teams, at an average cost of $500 per rollback (engineer time plus the brief service disruption), adding 6 * 500 = $3,000/year. Folding all three drivers together gives the complete expected annual benefit: 9,600 + 3,400 + 3,000 = $16,000/year. At the likely cost, payback is under a year (9,100 / 13,000 ≈ 0.7 years, about 8.4 months, using the two-driver figure above); using the complete three-driver total, payback is 9,100 / 16,000 ≈ 0.57 years, about 6.8 months. At the pessimistic cost, payback stretches to about 14.9 months (16,100 / 13,000 ≈ 1.24 years) on the two-driver figure, or about 12.1 months (16,100 / 16,000 ≈ 1.01 years) on the complete three-driver total.
Trade-offs & pitfalls
The most common estimation failure is omitting the integration/validation component entirely, since it's the part the refactoring team doesn't directly control and is easy to underestimate from inside the team doing the core work. Present the range, not just the likely case, since the ROI story changes materially (roughly 8.5 months versus 15 months payback here) between the two ends of a realistic estimate.
Design a lightweight technical debt register process suitable for a small product team of 5-8 engineers. Detail the minimum fields to capture per entry, who owns each entry, the review cadence, how items get surfaced in planning, and what closing an entry looks like.
Sample Answer
Direct answer
For a 5-8 person team, keep the register lightweight: a shared ticket type (not a separate tool) with five required fields (description, category, estimated impact, estimated effort, owner), a single named owner responsible for triage, a biweekly review tied to sprint planning, and a clear closing definition so items don't linger indefinitely in limbo.
Structured elaboration
- Fields: description (what and where), category (code/design/test/infra/knowledge debt, from the taxonomy), estimated impact (rough, 1-5), estimated effort (rough, S/M/L), and an owner (a person, not a team, so it doesn't become nobody's job).
- Ownership model: one rotating "debt steward" role per sprint or month, responsible for triaging new entries and flagging aging ones, not for fixing everything personally.
- Review cadence: 10 minutes at the start of sprint planning, reviewing new entries and anything older than 2 review cycles without progress.
- Surfacing in planning: the top 1-2 items by impact/effort get pulled directly into the sprint backlog alongside features, not tracked in a separate, easily-ignored system.
- Closing definition: an entry closes when either the fix ships, or the team explicitly decides "won't fix" with a one-line reason (both are valid outcomes; a register full of stale, undecided items is worse than a shorter, actively-triaged one).
Worked example
A 6-person team's register after one quarter: 14 entries created, 9 closed (6 fixed, 3 explicitly won't-fix'd with a reason), 5 still open, 0 older than 2 review cycles without a decision. That ratio (roughly balanced open/closed, nothing silently stale) is the health signal to watch, not the raw entry count, which naturally grows over time regardless of how well debt is managed.
Trade-offs & pitfalls
The most common failure at this scale is over-engineering the register (a heavyweight tool with a dozen fields) that a small team won't maintain, causing it to silently die within a month. The second is having no explicit "won't fix" outcome, so items accumulate forever because closing them without fixing them feels like giving up, when in practice an explicit, reasoned won't-fix is healthier than indefinite limbo.
Given these signals: 1,000 failing tests spread across 10 repositories, 20 deprecated libraries still in use, and 40 hotfix incidents in the last month, create a prioritized backlog of the five technical debt initiatives you would tackle first. Explain your prioritization criteria and the expected short-term impact of each initiative.
Sample Answer
Direct answer
With 1,000 failing tests across 10 repositories, 20 deprecated libraries, and 40 hotfix incidents last month, prioritize the hotfix-incident cluster first (it's the only signal directly tied to production impact this month), then the failing tests (they block confident shipping across a wide surface), then the deprecated libraries in order of highest known vulnerability severity, deferring the rest.
Structured elaboration
Apply a risk-first lens: hotfix incidents are the only one of the three signals that measures REALIZED harm (something already broke in production 40 times last month); failing tests and deprecated libraries are both LEADING indicators of future risk, not yet-realized harm, so they rank below the incidents but above each other based on breadth of exposure.
- Root-cause the hotfix incidents first (not just patch each one): if 40 incidents cluster in a small number of repos or root causes, fixing the underlying pattern (a missing validation layer, a fragile deploy step) prevents dozens of future hotfixes at once, which is the highest-leverage single action available.
- Triage the 1,000 failing tests by repo concentration and by whether they're blocking CI (failing tests actively blocking merges are far more urgent than skipped/quarantined tests nobody's looking at); fix or properly quarantine the CI-blocking ones first.
- Address deprecated libraries by vulnerability severity, not by how out-of-date they are: a library that's 2 versions behind with a known critical CVE outranks one that's 6 versions behind with no known vulnerabilities.
Worked example
Suppose the 40 hotfix incidents concentrate in 3 of the 10 repos, and root-causing them reveals 25 of the 40 trace to the same missing input-validation pattern introduced by a recent library upgrade. Fixing that pattern in those 3 repos (roughly 1 week of focused work) is initiative #1, since it plausibly prevents 25+ future incidents at once, dwarfing the value of tackling any single failing test or deprecated library first. Initiative #2 addresses the ~150 of the 1,000 failing tests that are actively blocking CI in those same 3 repos (since they're already the focus), restoring confident deploys there. Initiative #3 patches the 2 deprecated libraries (of the 20) that carry known critical CVEs, regardless of which repos they're in, since a known critical vulnerability is a realized risk regardless of version-gap size. Initiative #4 targets the next-highest-concentration repo for remaining failing tests (expected short-term impact: extends the CI-confidence gain from initiative #2 to the repo with the next-largest cluster of blocking failures, at a similar week-scale cost). Initiative #5 is a scheduled sweep of the remaining non-critical deprecated libraries (expected short-term impact: closes the long tail of upgrade risk on a slower cadence, since none of them carry a known critical CVE, so they do not compete for the same urgent capacity as initiatives #1-#3).
Trade-offs & pitfalls
The common mistake is prioritizing purely by raw count ("1,000 failing tests is the biggest number, fix those first") without checking concentration or CI-blocking status; a thousand quarantined, non-blocking failing tests scattered across all 10 repos is far less urgent than 25 incidents traced to one shared root cause.
Design an organization-level technical debt scoring algorithm that combines developer velocity (commits per week), bug rate normalized by lines of code, test coverage, average build time, and incident frequency into a single composite score per subsystem. Provide clear pseudocode, explain your normalization and weighting approach, how you would clip outliers, and how you would handle missing or noisy data such as a subsystem with very low commit volume.
Sample Answer
Direct answer
Combine developer velocity (commits per week), bug rate, test coverage, average build time, and incident frequency into one composite score per subsystem by normalizing each metric to a comparable 0-1 scale, orienting every scaled value so 1.0 always means "worse," then taking a weighted sum. The three things that make this design work in practice, not just in theory, are per-batch normalization (so scores are always relative to the current portfolio, not fixed absolute thresholds that go stale), outlier clipping actually implemented in the scoring step (not just described), and explicit handling of low-signal subsystems (also implemented, not just described).
Structured elaboration
- Normalize each raw metric with min-max scaling across the current batch of subsystems:
scaled = (value - min) / (max - min). This keeps every metric on a comparable 0-1 range regardless of its native units (commits/week versus milliseconds versus a percentage). - Orient every scaled metric so 1.0 = more debt: for metrics where lower is worse (test coverage, developer velocity), invert the scaled value (
1 - scaled) so all metrics point the same direction before combining. - Weight and sum: pick weights that sum to 1.0 and reflect what actually predicts incidents in your organization (a starting point: bug rate 30%, test coverage 25%, build time 15%, developer velocity 20%, incident frequency 10%, adjusted as you validate against real outcomes).
- Clip outliers before scaling, in code, not just in prose: a single subsystem with a pathological value (a build time 50x the median due to a known one-off issue) shouldn't compress every other subsystem's score toward the middle; clip each raw metric to the 5th-95th percentile before min-max scaling.
- Handle missing/noisy data explicitly, in code, not just in prose: a subsystem with very low commit volume has an unreliable velocity signal; flag it as low-confidence and exclude that metric from its score, renormalizing the remaining weights, rather than silently averaging in a noisy number.
- Bug rate must already be normalized by lines of code before it reaches this function: the
bug_ratefield this scoring pipeline consumes is assumed to be pre-computed upstream asraw_bug_count / (lines_of_code / 1000), bugs per thousand lines of code, not a raw bug count, so a large module with more shipped code doesn't automatically look buggier than a small module with the same defect density purely because it has more code to have bugs in. Concretely, theauthmodule'sbug_rate: 2.3used in the worked example below corresponds to roughly 23 raw bugs across a 10,000-line module (23 / (10000 / 1000) = 2.3);compute_debt_scoreitself just consumes whatever already-normalized rate it's handed.
def compute_debt_score(modules, low_signal_commit_threshold=5):
"""
modules: list of dicts with keys:
module, commits_per_week, bug_rate, test_coverage, build_time_seconds, incident_count
Returns list of {module, debt_score (0-100, higher = more debt), confidence}
"""
if not modules:
return []
fields_higher_is_worse = ['bug_rate', 'build_time_seconds', 'incident_count']
fields_higher_is_better = ['test_coverage', 'commits_per_week']
weights = {
'bug_rate': 0.30,
'test_coverage': 0.25,
'build_time_seconds': 0.15,
'commits_per_week': 0.20,
'incident_count': 0.10,
}
all_fields = fields_higher_is_worse + fields_higher_is_better
def clip_to_percentile(values, lo_pct=5, hi_pct=95):
if len(values) < 3:
return values
sorted_v = sorted(values)
n = len(sorted_v)
lo_idx = max(0, int(n * lo_pct / 100))
hi_idx = min(n - 1, int(n * hi_pct / 100))
lo, hi = sorted_v[lo_idx], sorted_v[hi_idx]
return [min(max(v, lo), hi) for v in values]
def minmax(values):
lo, hi = min(values), max(values)
if hi == lo:
return [0.5 for _ in values]
return [(v - lo) / (hi - lo) for v in values]
confidences = []
for m in modules:
low_signal = m.get('commits_per_week', 0) < low_signal_commit_threshold
confidences.append('low_confidence_velocity' if low_signal else 'ok')
scaled = {}
for f in all_fields:
raw = [m[f] for m in modules]
clipped = clip_to_percentile(raw)
s = minmax(clipped)
if f in fields_higher_is_better:
s = [1 - x for x in s]
scaled[f] = s
results = []
for i, m in enumerate(modules):
active_weights = dict(weights)
if confidences[i] == 'low_confidence_velocity':
active_weights.pop('commits_per_week')
remaining_total = sum(active_weights.values())
active_weights = {k: v / remaining_total for k, v in active_weights.items()}
score = sum(active_weights.get(f, 0) * scaled[f][i] for f in all_fields if f in active_weights)
results.append({
'module': m['module'],
'debt_score': round(score * 100, 1),
'confidence': confidences[i],
})
return results
Worked example
Executed against three modules (auth, billing, notifications) with realistic metrics, now including the required commits_per_week field:
modules = [
{'module': 'auth', 'commits_per_week': 18, 'bug_rate': 2.3, 'test_coverage': 0.65, 'build_time_seconds': 420, 'incident_count': 3},
{'module': 'billing', 'commits_per_week': 25, 'bug_rate': 0.4, 'test_coverage': 0.92, 'build_time_seconds': 90, 'incident_count': 0},
{'module': 'notifications', 'commits_per_week': 9, 'bug_rate': 5.1, 'test_coverage': 0.30, 'build_time_seconds': 610, 'incident_count': 6},
]
for row in compute_debt_score(modules):
print(row)
Output: {'module': 'auth', 'debt_score': 46.3, 'confidence': 'ok'}, {'module': 'billing', 'debt_score': 0.0, 'confidence': 'ok'}, {'module': 'notifications', 'debt_score': 100.0, 'confidence': 'ok'}. Billing scores 0 because it is best on every metric in this batch, and notifications scores 100 as worst on every metric.
Now the low-commit-volume case the question specifically asks about: a subsystem with only 1 commit/week alongside a normal subsystem:
low_commit = [
{'module': 'auth', 'commits_per_week': 18, 'bug_rate': 2.3, 'test_coverage': 0.65, 'build_time_seconds': 420, 'incident_count': 3},
{'module': 'legacy-batch', 'commits_per_week': 1, 'bug_rate': 0.2, 'test_coverage': 0.95, 'build_time_seconds': 60, 'incident_count': 0},
]
Output: legacy-batch gets confidence: 'low_confidence_velocity' and its score is computed from the remaining four metrics only, with weights renormalized to sum back to 1.0, instead of silently letting its (unreliable) velocity number pull its score toward "debt-free." The actual printed output for this exact example: {'module': 'auth', 'debt_score': 80.0, 'confidence': 'ok'}, {'module': 'legacy-batch', 'debt_score': 0.0, 'confidence': 'low_confidence_velocity'}. legacy-batch lands at 0.0 (best in this two-module batch) because it beats auth on every one of the remaining four metrics (bug_rate 0.2 vs 2.3, test_coverage 0.95 vs 0.65, build_time_seconds 60 vs 420, incident_count 0 vs 3); once the unreliable commits-per-week signal is excluded and the weights renormalized across those four, the mechanism produces a real, verifiable number instead of only a narrative claim.
Now the outlier-clipping mechanism, run on a large enough batch to actually exercise clip_to_percentile (the 2-3 module examples above are too small: with n under about 20, hi_idx from int(n * 95 / 100) lands on n - 1, so the clip boundary equals the raw max and clips nothing). A 25-module batch with one genuine outlier (reporting-outlier, build_time_seconds: 22000, roughly 52x every other module's build time in that batch) actually exercises the clip: with n = 25, lo_idx = 1 and hi_idx = 23 (not 24 = n - 1), so the clip boundary is genuinely below the raw maximum. The raw build_time_seconds range across that batch is 100 to 22000; the clip bounds actually applied are lo=100, hi=460, so the outlier's raw 22000 gets clipped down to 460 before min-max scaling, exactly as intended, rather than being allowed to compress every other module's score toward the middle. Comparing the same batch scored with and without clipping shows the mechanism changing real output: reporting-outlier scores 65.2 with clipping active versus 64.0 without it, and an ordinary module in the same batch scores 49.7 with clipping versus 46.7 without, since the outlier's raw value was compressing everyone else's normalization range before it was clipped. This is the demonstration the smaller examples above could not provide: with too few modules, clip_to_percentile's own index math resolves to plain min/max and never clips anything, silently defeating the mechanism it implements.
Trade-offs & pitfalls
Min-max normalization is relative to the current batch: a batch of five uniformly-terrible subsystems will still show one scoring near 0, which understates its absolute badness. For cross-quarter trend tracking, either normalize against a fixed historical baseline batch instead of the live batch, or track raw metrics alongside the composite score. Missing-data handling is also a common blind spot: silently treating a missing metric as 0 (best-possible) systematically under-scores subsystems with poor instrumentation, which is exactly backwards, since poor instrumentation is itself often correlated with higher actual debt; that is why the low-confidence flag and weight renormalization above are implemented in the scoring function itself, not left as a paragraph of intent that the code does not actually carry out. Cyclomatic complexity is a reasonable additional signal for architecture-level debt if you want a sixth axis, but it does not substitute for developer velocity (commits per week), which the composite score is explicitly required to include.
You join a team that has a backlog of technical debt slowing feature development, and you are not the tech lead. Propose a plan to take the initiative to reduce that debt over the next two quarters while maintaining feature velocity, including how you would get buy-in, schedule the work, and measure success.
Sample Answer
Direct answer
Without formal authority, reduce debt by starting small and visible: pick one bounded, low-risk item, fix it, show the measurable before/after, and use that credibility to earn support for a slightly larger next item, rather than trying to launch a two-quarter program from a position with no mandate.
Structured elaboration
- Get informal buy-in first: talk to the tech lead and a couple of teammates about the specific pain point before doing anything, so the work isn't a surprise and ideally has at least tacit support.
- Pick a bounded first item: something completable in days, not weeks, with a clearly measurable before/after (a flaky test suite, a slow build step), so the win is undeniable and quick.
- Schedule it inside normal work, not as a separate ask: fold it into an existing sprint alongside regular feature work, framing it as "cleanup while I'm in this area" rather than requesting dedicated time upfront, which avoids needing permission you don't yet have standing to ask for.
- Make the result visible: a short note in the team channel or standup with the concrete before/after number, which builds the track record needed to propose something bigger next.
- Escalate gradually: use the credibility from 2-3 small wins to propose a slightly larger, more visible item, and eventually a real capacity ask to the tech lead or EM, now backed by a track record rather than a cold pitch. Treat the first quarter as earning trust via small, visible wins, and the second quarter as converting that trust into a modest, standing capacity allocation, so the full two-quarter window ends with both delivered debt reduction and a repeatable process, not just a one-off flurry of early activity.
Worked example
Week 1: fix a specific, well-known flaky test that's been annoying the whole team, reducing CI failure rate on that suite from 15% to under 2%, posted with the before/after number in the team channel. Week 3: propose and complete a small build-time optimization (caching a slow dependency step), cutting build time by 4 minutes, again shared visibly. By month 2, propose a slightly larger item (splitting an overloaded test suite) to the tech lead, now backed by two concrete, credible wins rather than a first-time cold ask; the tech lead is far more likely to grant informal time for this third item given the track record. By the end of Quarter 1, that track record (3-4 completed, visible wins) is used to formally propose a modest recurring capacity allocation (for example, one day every two weeks) for Quarter 2, framed as a request backed by evidence rather than a speculative ask. Quarter 2 is then spent executing one pre-agreed, larger item (for example, decomposing the single largest source of recurring friction) using that allocated time, reporting progress at the same visible cadence as the earlier small wins, so that by the end of the two-quarter window the team has both delivered debt reduction throughout and established a durable process that survives beyond the initial push.
Trade-offs & pitfalls
The risk of not being the tech lead is starting too big: proposing a two-quarter debt-reduction program without any track record or mandate is likely to be politely ignored or actively resisted as scope creep from someone without the standing to drive it; earning trust through small, visible wins first is what makes the eventual larger ask land.
Unlock Full Question Bank
Get access to all 36 Technical Debt Management and Refactoring interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.