What Is Technical Debt?
Technical debt is the trade-off between long-term quality and short-term speed. It happens when we cut corners—skipping tests, duplicating logic, or accepting a “temporary” design—knowing it should be fixed later
The metaphor is intentional:
Like a financial loan, technical debt offers immediate benefits but accrues interest until repaid. That interest shows up as:
-
Slower development
-
More bugs
-
Brittle tests
-
Frustrated developers
Martin Fowler distinguishes between deliberate and inadvertent technical debt. This article focuses on the deliberate kind: the corners you know you are cutting.
Key idea
Technical debt is not just “bad code.” It is the gap between the design you have and the design you know you should have , created in the name of speed.
Symptoms of Technical Debt
One of the hardest parts about technical debt is that it rarely screams. It whispers. You notice it through friction, not through big red error messages.
- New features are hard to add because everything feels tightly coupled.
- Seemingly small changes break unrelated areas of the system.
- Tests are brittle and need frequent updates for simple refactors.
- Onboarding new developers is slow because the code is hard to explain.
- Teams rely on workarounds and tribal knowledge instead of clear design.
Over time, these small points of friction accumulate into a major drag on productivity. The codebase feels “heavy,” and every new feature takes more effort than it should.
When Taking on Debt Is Sometimes Necessary
Not all technical debt is evil. There are moments when cutting a corner is justified:
- Meeting a hard external deadline.
- Shipping a thin slice of a feature to validate with real users.
- Responding to a production incident where time is critical.
- Prototyping or running an experiment where the code may be thrown away.
In these situations, taking on deliberate technical debt can be a rational business decision.⚠️ The danger isn’t taking on debt—it’s pretending temporary code can live forever.
Important
Taking on technical debt is not the problem. Taking it on without a plan to pay it back is.
Track the Debt or Lose Control
The moment you decide to compromise on quality, you should treat it like a loan: log it, name it, and give it a due date.
At a minimum, you should capture:
- What was compromised (design, test coverage, naming, architecture, etc.).
- Where in the codebase it lives.
- Why the compromise was made (deadline, experiment, incident, etc.).
- Estimated cost to clean it up.
- Risk / impact if it is not addressed.
The actual tracking mechanism can be whatever fits your team: a ticket in Jira or Azure Boards, a GitHub issue, or a dedicated “Technical Debt” register. The key is that it appears in the same place where product work is prioritized, not in a private notebook that only one developer sees.
Interest: The Hidden Cost of Delay
If you address the debt in the next iteration, the cost is usually small. Leave it for six months, and the interest becomes obvious.
Here are some examples of how that interest shows up:
| Debt | Interest You Pay |
|---|---|
| Duplicated logic instead of a shared abstraction | Bug fixes must be applied in multiple places; inconsistent behaviour appears. |
| Skipping automated tests for a feature | Manual regression testing grows every release; bugs slip into production. |
| Hard-coded configuration or secrets | Deployments become risky; environment differences cause subtle failures. |
| Rushed design decisions in core modules | Future features take much longer to build; refactors become multi-sprint efforts. |
When you make this interest visible—through extra story points, time spent on defects, or reduced team velocity—stakeholders start to see technical debt as a business problem, not just a technical one.
How Technical Debt Grows in Code (Examples)
It is easier to understand technical debt when you can see it in real code. Below are two small examples that show how “quick hacks” turn into long-term problems if we never go back to clean them up.
Example 1 (C#): Copy–Paste Logic That Becomes a Trap
Iteration 1 – quick win. A developer needs to apply a discount in one place, so they do it inline:
public decimal CalculateCartTotal(IEnumerable<CartItem> items)
{
decimal total = items.Sum(i => i.Price * i.Quantity);
// Quick hack: apply 10% promo discount
// We'll "refactor this later"
if (DateTime.UtcNow < new DateTime(2025, 12, 31))
{
total = total - (total * 0.10m);
}
return total;
}
This works, tests pass, and the feature ships. The team knows this logic should live somewhere central, but there is no time.
Iteration 3 – requirement grows. Someone needs the same discount in another flow and copy–pastes it:
public decimal CalculateOrderPreview(Customer customer, IEnumerable<CartItem> items)
{
decimal subtotal = items.Sum(i => i.Price * i.Quantity);
decimal shipping = ShippingCalculator.Calculate(customer, items);
decimal total = subtotal + shipping;
// Copy-pasted promo logic
if (DateTime.UtcNow < new DateTime(2025, 12, 31))
{
total = total - (total * 0.10m);
}
return total;
}
Now the discount logic lives in at least two places. When the promotion rules change (e.g., 15% only for certain customer segments), both places must be updated. If one is missed, subtle bugs appear. That is the interest.
Refactored version – debt repaid. A small abstraction removes the duplication:
public interface IDiscountPolicy
{
decimal Apply(decimal total, Customer customer);
}
public class PromotionalDiscountPolicy : IDiscountPolicy
{
public decimal Apply(decimal total, Customer customer)
{
if (DateTime.UtcNow > new DateTime(2025, 12, 31))
return total;
if (!customer.IsEligibleForPromo)
return total;
return total - (total * 0.15m);
}
}
Both calculation paths now depend on IDiscountPolicy, and future changes are localized.
Example 2 (Python): Hard-Coded Configuration That Blocks Change
Iteration 1 – “good enough for now”.
import smtplib
def send_welcome_email(to_address: str, body: str) -> None:
# Quick shortcut - config hard coded
server = smtplib.SMTP("smtp.mycompany.local", 25)
server.starttls()
server.login("noreply@mycompany.local", "SuperSecret123")
message = f"From: noreply@mycompany.local\r\nTo: {to_address}\r\n\r\n{body}"
server.sendmail("noreply@mycompany.local", [to_address], message)
server.quit()
This works on day one. But over time:
- Credentials change.
- The SMTP server name is different in each environment.
- Security wants secrets out of the source code.
Refactored version – easier to evolve and test.
import smtplib
from dataclasses import dataclass
@dataclass
class SmtpConfig:
host: str
port: int
username: str
password: str
use_tls: bool = True
class SmtpClient:
def __init__(self, config: SmtpConfig):
self._config = config
def send(self, sender: str, recipient: str, body: str) -> None:
server = smtplib.SMTP(self._config.host, self._config.port)
if self._config.use_tls:
server.starttls()
server.login(self._config.username, self._config.password)
message = f"From: {sender}\r\nTo: {recipient}\r\n\r\n{body}"
server.sendmail(sender, [recipient], message)
server.quit()
def send_welcome_email(client: SmtpClient, to_address: str, body: str) -> None:
client.send("noreply@mycompany.local", to_address, body)
Configuration now comes from one place (environment variables, config file, secret store), and the email logic can be tested with a fake SmtpClient. The same code that was “fast to write” at the beginning would be painful to maintain six months later.
Practical Guidelines for Managing Technical Debt
Here are some practical habits you can apply on your team:
1. Log Debt Immediately
The moment you say “we’ll tidy this later,” create a task. Do it before merging the pull request. This prevents “invisible debt” that exists only in someone’s memory.
2. Prioritize Debt Alongside Features
Technical debt tasks should show up in backlog refinement and sprint planning like any other work. Some will be critical; others can wait. What matters is that they are visible and deliberately prioritized.
3. Pay It Back Quickly
Whenever possible, schedule repayment in the next iteration. The longer you wait, the harder it becomes: more code depends on the compromised area, more tests are built around it, and more people assume “this is just how the system works.”
4. Keep a Debt Budget
Many successful teams reserve a portion of each sprint— say 10–20% of capacity—for refactoring and debt repayment. Others dedicate one “cleanup day” every couple of weeks. The exact percentage matters less than having something explicitly set aside.
5. Use Design Principles to Prevent New Debt
The best technical debt is the debt you never create. Applying core design principles like SRP, OCP, and DIP reduces the likelihood of messy shortcuts in the first place.
A Team That Manages Debt Builds Faster Long-Term
Technical debt is not automatically bad. Sometimes, taking on a little debt is exactly what you need to hit a deadline or validate an idea. But unmanaged debt quietly erodes your architecture, velocity, and morale.
Treat debt like money: borrow intentionally, track every loan, and pay it back quickly. Your future self—and your future teammates—will thank you.
Try this in your next sprint
- List the top 5 areas of technical debt in your current project.
- Create explicit backlog items for each one, with clear acceptance criteria.
- Reserve a fixed percentage of your next sprint to address at least one of them.
Small, consistent repayments today will save you from painful rewrites tomorrow.
Summary
Technical debt is the hidden cost of speed. It builds up quietly, slowing development, increasing bugs, and frustrating teams. While some debt is necessary, unmanaged debt erodes productivity and morale. The solution is simple: track it, make it visible, and repay it quickly. Teams that treat debt like a loan—borrowing intentionally and paying it back consistently—move faster and build stronger systems in the long run.