Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 048 / 100

Technical debt: talking about it and paying it down

Debt is a tool. Unmanaged debt is a tax.

Expona Admin7 min read

Technical debt: talking about it and paying it down

Most teams I've worked with treat technical debt like a ghost in the codebase—felt everywhere, discussed nowhere, and only mentioned when something breaks at 2 AM. The truth is simpler: debt is a tool. Unmanaged debt is a tax. When you stop naming it, measuring it, and systematically paying it down, every feature takes longer, every bug fix gets riskier, and every new person on the team loses weeks trying to understand why things work the way they do.

I've sat in sprint planning meetings where engineers vaguely point at a folder and say "this is a mess" without explaining what that costs. I've also sat in meetings where we carved out time to fix specific, named problems and watched delivery speed jump by 30%. The difference isn't luck—it's precision. This is about turning invisible drag into visible, manageable work.

Name it precisely

"The code is bad" is not actionable. "The order module has no tests, which means every change triggers manual QA regression cycles that block other work for 3–4 days" is. Precision matters because it forces you to understand the real impact and makes it possible to track progress.

When I inherited a payment processing service a few years ago, I heard complaints about "spaghetti code" and "hard to change." I dug deeper. The real problem: no separation of concerns between API contracts, business logic, and database queries. A single file held 2,400 lines. Changing tax calculation logic required touching payment routing code. Tests were impossible to write without mocking the entire database. Each change took 8 hours of careful, manual testing.

That specificity became our north star. Instead of vague backlog items like "refactor payments," we tracked:

  • Contract isolation: API request/response types live in their own module and don't import business logic.
  • Logic extraction: Tax and fee calculations live in pure functions, tested independently.
  • Database abstraction: Repository pattern isolates queries from business logic.

This clarity let us estimate the work, track progress, and—crucially—explain to leadership why we needed to spend two sprints on it. Vague debt lives in backlog limbo forever. Named debt gets paid down.

When you're naming debt, ask: What specifically is hard to do? Is it hard to add a feature? Hard to test? Hard to onboard new engineers? Hard to debug production issues? Each answer points to a different root cause and different solution.

Show the cost

Engineers think in technical terms. Business stakeholders think in delivery speed and risk. To get budget for debt paydown, you need to translate between languages.

I once worked on a ride-hailing platform where the matching algorithm was built as a massive procedural function—about 1,500 lines of nested conditionals. It worked, but every edge case (surge pricing, driver availability zones, accessibility requirements) meant diving into that tangled logic. The cost wasn't obvious in code reviews. It became obvious when:

  • Feature velocity dropped: A new driver-preference feature that should have taken 3 days took 10, because we had to understand and modify the matching logic.
  • Incident severity increased: A bug in the matching algorithm affected thousands of rides before we could locate and fix it. The root cause was buried in conditionals no one fully understood.
  • Onboarding time exploded: New engineers spent two weeks just reading that function to understand the flow. They still got it wrong the first time.

That's the language leadership understands: slower delivery, more incidents, higher ramp-up cost. Those are real costs in money and time.

Quantify what you can. If a codebase has no integration tests and you spend 4 hours per sprint on manual testing, that's 4 hours × 26 sprints × your hourly cost. If onboarding takes 3 weeks instead of 1 because documentation is stale, that's real productivity loss. If you lose one incident per quarter due to fragile code, that's downtime, customer impact, and emergency labor.

When you ask for debt paydown budget, frame it as prevention cost, not luxury:

"Our CRM module has no contract tests. In the last quarter, we had two incidents where API changes broke downstream services. Each incident took 6 hours to debug and fix, plus customer impact. Spending 20 hours now to add contract tests costs less than one more incident."

Budget for it

Debt paydown should never be "whenever we have time." Time never comes. Instead, budget a fixed percentage of every sprint for improvements—I recommend 15–25% depending on how acute your debt is. Make it non-negotiable, like you do for critical bugs.

This is where many teams fail. They say "yes, we need to pay down debt" but don't actually protect time for it. Then sprint planning happens, three urgent features land, and suddenly debt work vanishes. Every sprint. For three years. Now you're carrying massive technical drag.

Here's how I structure it in practice. If our sprint capacity is 40 story points, I reserve 8–10 points for debt reduction or infrastructure work. This isn't negotiable unless there's a genuine P1 incident. The team sees this as legitimate work, not a luxury. Product managers learn to expect it. Engineers know they have breathing room to improve things they touch.

What counts as debt work?

  • Adding tests to untested modules you're modifying for features.
  • Extracting pure functions from tangled logic.
  • Removing dead code paths.
  • Refactoring a section to make the next feature easier.
  • Updating documentation that's out of sync with reality.
  • Upgrading dependencies to close security gaps.
  • Improving error messages and observability.

What doesn't count: completely rewriting services, changing architectural patterns mid-project, or migrations that don't unblock feature work. Rewriting for rewriting's sake is debt creation, not paydown.

Pay down where you work

The most effective debt paydown happens incrementally, as part of feature work. When you're building a new feature in a module, improve the code you touch. Leave it slightly better than you found it. Over time, this compounds.

This contradicts the "big-bang rewrite" trap. I've seen teams allocate a full sprint to rewrite a module—everyone learns the old code, everyone builds the new one, high risk of bugs, and feature work stalls. Six months later, the rewritten module has accumulated new technical debt because nobody owns incremental improvement.

Here's a concrete example. You're adding a new filter to an order search endpoint. The existing code:

typescript
async function searchOrders(filters: any): Promise<Order[]> {
  let query = db.query('SELECT * FROM orders WHERE 1=1');
  
  if (filters.customerId) {
    query = query.where('customer_id', filters.customerId);
  }
  if (filters.status) {
    query = query.where('status', filters.status);
  }
  if (filters.minAmount) {
    query = query.where('amount', '>=', filters.minAmount);
  }
  
  // 20 more conditions...
  
  const results = await query.execute();
  return results.map(row => new Order(row));
}

Instead of rewriting the whole thing, you:

  1. Add your new filter in the existing style (debt incurred, but minimal).
  2. Extract the filter-building logic into a small helper, tested independently.
  3. Type the filters object properly instead of using any.
  4. Add one test that covers your new filter plus the existing filters.

Result: the endpoint is slightly better, your feature works, and you didn't blow up the sprint. Over 10 features, you've paid down significant debt without a big, risky rewrite.

typescript
type OrderFilters = {
  customerId?: string;
  status?: OrderStatus;
  minAmount?: number;
  dateRange?: { start: Date; end: Date };
};

function buildOrderQuery(filters: OrderFilters) {
  let query = db.query('SELECT * FROM orders WHERE 1=1');
  
  if (filters.customerId) query = query.where('customer_id', filters.customerId);
  if (filters.status) query = query.where('status', filters.status);
  if (filters.minAmount) query = query.where('amount', '>=', filters.minAmount);
  if (filters.dateRange) {
    query = query.where('created_at', '>=', filters.dateRange.start);
    query = query.where('created_at', '<=', filters.dateRange.end);
  }
  
  return query;
}

async function searchOrders(filters: OrderFilters): Promise<Order[]> {
  const results = await buildOrderQuery(filters).execute();
  return results.map(row => new Order(row));
}

Same outcome, better foundation, lower risk, and nobody has to explain a sprawling refactor ticket to the product owner.

Common mistakes

  • Perfectionism debt: Spending 3 sprints to make code "perfect" when 80% improvement would unblock everyone. Better to be good enough and move on.
  • Debt without measurement: Paying down debt that doesn't actually affect delivery or reliability. If it's not causing real friction, it's not urgent.
  • Vague debt tracking: A backlog full of "refactor," "cleanup," "technical improvements." None of it gets prioritized because none of it has a clear outcome or impact.
  • Rewarding avoidance: Treating debt paydown as optional fun-work instead of part of shipping reliable systems. This signals that quality is negotiable.
  • Skipping incremental wins: Waiting for "the right time" for a big rewrite instead of improving code as you touch it. The right time never comes.

Key takeaways

  • Name your debt precisely: specify what's hard, why it's hard, and what behavior it creates. "No tests in order module" beats "legacy code."
  • Translate to business impact: frame debt in terms of delivery speed, incident risk, and onboarding cost. Executives don't care about code smells; they care about risk and velocity.
  • Protect time systematically: reserve 15–25% of sprint capacity for debt reduction. Make it non-negotiable. Teams that don't go slower every quarter.
  • Pay down incrementally: improve code you touch for features. Skip big rewrites. Compounding small improvements outpace risky, all-in refactors.
  • Track progress: know which debt you've paid down and what remains. Invisible debt stays forever.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles