backend / code quality / 06_tech_debt_and_complexity.md

Technical Debt and Complexity

6 interview angles 6 min read source

Technical Debt and Complexity

Senior engineers don’t just write code — they manage the health of a codebase over time. That means having a real model of technical debt (not “code I don’t like”), measuring complexity, and being able to make the business case for paying debt down.

What technical debt actually is

The metaphor (Ward Cunningham): you take on debt to ship faster now, and you pay interest — every future change in that area is slower and riskier — until you pay down the principal.

The key distinction — not all “bad code” is debt, and not all debt is bad:

Type What it is Response
Deliberate + prudent “we know the clean way; we’re shipping the quick way to hit the deadline, and we’ll fix it” fine — track it, pay it down
Deliberate + reckless “no time for design” avoid — this is just bad engineering
Inadvertent + prudent “now that it’s built, we see how it should have been done” normal — learning; refactor when you next touch it
Inadvertent + reckless “what’s layering?” a skills/process gap, not really “debt”

(Fowler’s quadrant.) The useful framing: debt is a deliberate, tracked trade-off — speed now for interest later. “Code I’d write differently” is not debt; it’s preference. “Code with no tests in a critical path” is debt — it has real interest (every change is risky).

Where debt actually hurts

Debt’s cost is interest, and interest is only paid on code you touch. So:

  • Debt in a hot, frequently-changed area = high interest = pay it down.
  • Debt in a stable, never-touched module = ~zero interest = leave it (paying it down is pure cost, no return — see refactoring’s “when not to”).

This is the single most important prioritization insight: prioritize debt by interest rate (change frequency × pain), not by ugliness. A beautiful refactor of code nobody touches is wasted effort; a modest cleanup of the file everyone fights with weekly is huge.

Measuring complexity

You can’t manage what you don’t measure. Useful (imperfect) signals:

Metric What it captures Tool
Cyclomatic complexity number of independent paths through a function — proxy for “how hard to test/understand” radon, ruff (C901), xenon
Cognitive complexity how hard a human finds it to follow (nesting penalized more than length) ruff, SonarQube
Lines per function/file crude but real — 500-line functions are a smell linters
Churn × complexity files that are both complex and frequently changed — the actual hotspots git log analysis, CodeScene
Test coverage on changed lines is the risky stuff tested? coverage.py --diff / diff-cover
Duplication copy-paste that should be an abstraction (after rule of three) linters, SonarQube
# cyclomatic complexity, flag anything over 10
radon cc myapp/ -nc --min C
# ruff: fail on high-complexity functions
# pyproject.toml: [tool.ruff.lint] select = ["C901"]; mccabe.max-complexity = 10

The metric to actually act on: churn × complexity. A complex file that never changes is fine; a complex file that changes every sprint is where bugs live and where cleanup pays back.

Caveat: metrics are signals, not goals. Optimizing the metric (gaming cyclomatic complexity by extracting meaningless one-line functions) is worse than the original. Use them to find hotspots, then apply judgment.

Making the business case — the senior skill

Leadership rarely says yes to “we should clean up the code.” They say yes to a quantified trade-off. (See behavioral/15_tech_debt_advocacy.md for the conversation; this is the framing.)

Translate debt into business terms:

  • Velocity — “every change to the billing module takes 3× longer because of X; here’s the data from the last 5 PRs.”
  • Risk / incidents — “4 of our last 6 production incidents traced to the untested payment path.”
  • Onboarding cost — “new engineers take 2 weeks to be productive in this area because of Y.”
  • Opportunity cost — “we can’t ship feature Z without first untangling this; the cleanup is 1 week, the feature is blocked indefinitely otherwise.”

Then scope it small and concrete: not “rewrite the billing system,” but “extract the 5 worst-tested functions and add characterization tests — 1 sprint, here’s the expected velocity gain.” A scoped, quantified, time-boxed proposal gets approved; “we need to fix tech debt” doesn’t.

Strategies for paying it down

  • Opportunistic / campsite rule — clean up the bit you’re touching anyway, scoped to your change. Steady, low-ceremony, doesn’t need approval.
  • Dedicated allocation — a fixed fraction of each sprint (e.g. 20%) or every Nth sprint for debt. Makes it routine instead of a fight.
  • The strangler-fig — for large structural debt, incrementally route around the old code (see 05_refactoring.md). Never the big-bang rewrite.
  • Boy-scout a hotspot — pick one churn×complexity hotspot per quarter and deliberately invest in it.
  • Tracking — a debt register / labeled issues. Untracked debt is invisible and never gets prioritized. Deliberate prudent debt especially must be written down at the moment it’s taken on, or it becomes inadvertent debt nobody remembers.

What good looks like

  • Debt is tracked, not just felt.
  • Cleanup is prioritized by interest (churn × pain), not by ugliness.
  • Proposals are scoped, quantified, time-boxed — and made in business terms.
  • The team has a routine for it (allocation or campsite rule), so it doesn’t accumulate to a crisis.
  • Some debt is consciously left alone — stable, untouched code isn’t worth the risk of touching.

Common gotchas

  • “Tech debt” = “code I don’t like” — preference isn’t debt. Debt is a tracked trade-off with real interest.
  • Prioritizing by ugliness — the ugliest code might be the most stable. Prioritize by churn × pain.
  • “We need to fix tech debt” — too vague to approve. Scope it, quantify it, time-box it, frame it in business terms.
  • Big-bang rewrite as the debt strategy — re-discovers every old edge case in prod, ships no value meanwhile. Strangle incrementally.
  • Untracked debt — invisible, never prioritized. Especially: deliberate-prudent debt not written down becomes inadvertent debt.
  • Gaming the metrics — extracting meaningless functions to lower cyclomatic complexity. Metrics find hotspots; judgment fixes them.
  • Never leaving debt alone — paying down debt in stable untouched code is cost with no return.

Interview angle

  • “What is technical debt?” — a deliberate, tracked trade-off: speed now in exchange for interest later (every future change in that area is slower and riskier) until you pay down the principal. Crucially, “code I’d write differently” is preference, not debt — debt has measurable interest.
  • “How do you prioritize which debt to pay down?” — by interest rate, not ugliness: churn × pain. Debt in a frequently-changed area has high interest and is worth fixing; debt in a stable, never-touched module has near-zero interest — leave it. The ugliest code is often the most stable.
  • “How do you measure code complexity?” — cyclomatic / cognitive complexity (radon, ruff C901), lines per function, duplication — but the metric to act on is churn × complexity, the files that are both complex and frequently changed. Metrics are signals to find hotspots, not goals to optimize.
  • “How do you get leadership to approve paying down debt?” — translate it into business terms (velocity loss with data, incident attribution, onboarding cost, a blocked feature) and bring a scoped, quantified, time-boxed proposal — “extract these 5 functions, add tests, 1 sprint” — not “we should fix tech debt.”
  • “How do you keep debt from accumulating?” — a routine: campsite-rule cleanup scoped to what you touch, plus a dedicated sprint allocation so it’s normal rather than a fight. And a tracked debt register — deliberate-prudent debt written down the moment it’s taken on, or it becomes invisible.
  • “Is all debt bad?” — no. Deliberate, prudent, tracked debt to hit a real deadline is a legitimate engineering trade-off. Reckless debt (“no time for design”) is just bad engineering. And some debt should be consciously left unpaid — risk with no return on stable code.