Technical debt has interest: what that means in practice
Technical debt isn't a synonym for bad code. Here's where the term comes from, why not all debt is a mistake, and how AI changed the math on when to pay it off.
Whether you’re a developer or not, you’ve probably heard the term “technical debt” at some point. It’s a common phrase in software engineering, thrown around in retrospectives, planning meetings, even over water cooler talk. The problem is that it’s been repeated so often it’s lost its meaning, and these days, “technical debt” has mostly become a polite way of saying “bad code.”
That’s not what it originally meant.
Put simply, technical debt is the cost, in time, effort, or confusion, that someone will pay later because of a decision made during development. It can be a conscious choice, made to move faster now, or a decision that seemed right at the time and only later turned out to be limiting. Either way, the software keeps working, it’s just carrying an obligation that someone will eventually have to settle.
The term comes from a metaphor by Ward Cunningham, a well-known figure in the field, among other things also the creator of the first wiki in history. In 1992, at the OOPSLA conference (short for Object-Oriented Programming, Systems, Languages, and Applications, one of the leading object-oriented programming conferences at the time), Cunningham wrote a short report on WyCash, a financial portfolio management system he was building at the time. That report is where the line that would become one of the field’s most repeated (and most misunderstood) terms first appeared:
“Shipping first time code is like going into debt.”
So far, it sounds simple: move fast now, pay for it later. But it’s worth reading the rest of what Cunningham wrote in that report, because that’s where the metaphor really clicks. For him, taking on a little debt speeds up development — as long as it’s paid back promptly, through a rewrite. And every minute spent maintaining code that isn’t quite right counts as interest on that debt.
In other words: debt doesn’t come from writing bad code on purpose to save time. It comes from shipping a first version based on your understanding of the problem at that moment (an understanding that’s incomplete by definition) and revising that version as you learn more about the domain. Michael Feathers captured this original reading of Cunningham in a line that carries more weight than it first appears to:
“Technical Debt was the accumulated distance between your understanding of the domain and the understanding that the system reflects.”
The accumulated distance between what you understand about the domain today and what the system reflects. That distance is unavoidable. Nobody fully understands a problem before they start building the solution, and the more you build, the more you learn about what you should have built differently. Debt is born exactly in that gap, and it’s born even when everything was done carefully.
Not all debt is a mistake
If debt is inevitable, the useful question isn’t “is there debt?” There almost always is. The question worth asking is different: was this debt taken on with judgment, or out of carelessness?
Steve McConnell, back in 2007, was one of the first to try to separate the two. For him, there’s intentional debt — a conscious decision to optimize for the present at the expense of the future — and unintentional debt, the result of sloppy work, often not even noticed as it happens. And he doesn’t treat the two as the same thing: in his whitepaper on the topic, he argues that some technical debt serves a real business purpose, while the rest is simply counterproductive, with no real payoff behind it.
Martin Fowler, another well-known figure in the field and the author of Refactoring, took McConnell’s split and expanded it into a quadrant that crosses two axes at once: whether the debt was taken on deliberately or inadvertently, and whether it was reckless or prudent.
Four possible combinations:
- Deliberate and prudent: the decision to take on debt was conscious, and the cost of paying it off later was weighed against the gain of moving now.
- Deliberate and reckless: “we don’t have time to do it right” — knew it was the wrong way, did it anyway.
- Inadvertent and prudent: only noticed afterward, in a team that learned something new about the problem along the way — it’s the natural price of getting better.
- Inadvertent and reckless: debt taken on out of sheer ignorance of good practice, without even realizing debt was being incurred.
The first cell tends to be the most misunderstood of the four, precisely because it doesn’t look like a mistake at all. Deliberate, prudent debt isn’t an engineering failure, but an engineering decision, following the same logic as any trade-off involving money: weighing the cost now against the cost later, and choosing with eyes open, knowing exactly what’s being postponed and why. Or a technology trade-off: keeping a monolith at the start of a new product, even knowing microservices would make scaling easier down the line, because splitting things up too early, before you understand where the domain’s real boundaries sit, adds complexity the team doesn’t need to pay for yet. Fowler himself defends this as a reasonable starting point for most projects, not as an irresponsible shortcut.
The part you can’t see
Joel Spolsky, the creator of Trello, compared software to an iceberg long before “technical debt” became common jargon: the part visible above the waterline — the interface, the functionality the end user sees — is small next to what holds it up underneath. Others later borrowed the metaphor specifically to talk about technical debt: what’s above the waterline is what shipped; what’s submerged is everything invisible from the outside: coupling, documentation that doesn’t exist, missing tests, a decision that got postponed and never revisited.

Outsiders only see the tip, and judge the whole system by it.
It’s not a very precise model, and it doesn’t need to be. It’s just a reminder of something simple: debt rarely shows up on the surface. It builds up wherever nobody’s looking, until the day someone needs to touch that exact spot, and finds out how much was actually hiding underneath.
A real case: from tennis challenges to school management
A while back, I was building a project that started out as a microservices course. Afterward, on my own initiative, I decided to keep going with it, expanded the scope, and made some changes, essentially a pivot. The project was a tennis challenge system: one player could challenge another, and the challenged player could accept, decline, or, after accepting, cancel the challenge. At some point I figured it’d be a good idea to expand it into a tennis school management system, where this player-challenge feature (now scoped to players within the same school) would just be one piece inside a bigger ecosystem. The app gained a ranking system between players, where players at each school (multi-tenant, each school had its own students and coaches) could challenge one another (the challenge status options also grew, like proposing a new date for a match), and their scores would go up or down based on match results and overall performance at the school. Every player started with a default score, in category E at 200 points, and could climb all the way up to GOLD (above category A). The ranking and scoring system was meant to keep students motivated and encourage some healthy competition between them.
Below are a few code samples that help make sense of the system as a whole.
// apps/api/src/modules/students/entities/category-info.enum.ts
export enum CategoryInfo {
CATEGORY_GOLD = 'Category Gold - The students with more than 12,000 points but not more than 30,000 points',
CATEGORY_A = 'Category A (World Elite) → 8,001 - 12,000 points',
CATEGORY_B = 'Category B (High-Level Professionals) → 4,001 - 8,000 points',
CATEGORY_C = 'Category C (Advanced Competitors) → 1,001 - 4,000 points',
CATEGORY_D = 'Category D (Growing Intermediate Students) → 501 - 1,000 points',
CATEGORY_E = 'Category E (Beginners and New Students) → 200 - 500 points'
}
// apps/api/src/modules/challenges/entities/challenge-status.enum.ts
export enum ChallengeStatus {
FINISHED = 'FINISHED',
PENDING = 'PENDING',
ACCEPTED = 'ACCEPTED',
DECLINED = 'DECLINED',
CANCELED = 'CANCELED'
}
// apps/api/src/modules/schools/domain/entities/school-status.enum.ts
export enum SchoolStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
TRIAL = 'trial',
SUSPENDED = 'suspended',
CANCELED = 'canceled',
PENDING_APPROVAL = 'pending_approval',
DELETED = 'deleted'
}
/**
* active -> school account is fully active and operational
* inactive -> created but not yet activated (e.g. email not verified or setup incomplete)
* trial -> in trial period, with temporary access and feature limitations
* suspended -> access temporarily blocked (e.g. billing issues or policy violation)
* cancelled -> account closed by the school or system administrator
* pending_approval -> awaiting internal review or approval before activation
* deleted -> soft-deleted tenant, data retained for audit or recovery
*/
So where am I going with all this? Here’s the important part: the course project started out on MongoDB, a non-relational database. That was fine at first, since I was learning about microservices at the time, and anything related to technical debt was far from my mind. The problem was that, even after changing the project’s direction and realizing it would need a relational database, I decided to stick with the database I’d been using since the start of the course. After all, the course project had no tests, didn’t use the Repository pattern, and agentic development wasn’t nearly as capable back then as it is today. Anyway… as the project moved forward, it became more and more obvious: a relational database was necessary.
Looking back now, with a clearer technical head, I can list exactly what made Mongo a poor fit for this specific domain. The system is multi-tenant, meaning each school has its own students, coaches, and challenges, and that kind of tenant isolation comes naturally in a relational database, where a simple foreign key already guarantees a student can never end up linked to the wrong school. On top of that, the domain is relationship-heavy: a student connects to a school, to a coach, to a ranking category, to challenges, and each challenge, in turn, connects to two players at once. The ChallengeStatus shown above isn’t just a list of standalone states: each transition, like from PENDING to ACCEPTED through to the challenge’s resolution, needs to update both players’ scores in a single atomic operation. I actually implemented this using MongoDB’s multi-document ACID transactions, a feature the database has only supported since version 4.0, but even with atomicity solved, modeling the relationships was still a lot of work: every query that joined student, school, and challenge data required manually referencing across documents, with no native referential integrity, and the logic for validating those references ended up scattered across the application instead of being enforced by the database itself. Solving atomicity didn’t solve the underlying problem, which was modeling a heavily relational domain in a database that wasn’t built for it. If one of the two score updates went through and the other failed, the ranking would end up inconsistent, with one player gaining points without the other losing any. That’s exactly the kind of guarantee a relational database’s transactions handle natively, and that a document database like Mongo requires you to simulate with a lot more effort and a lot less safety.
Long story short… I finally migrated to a relational database, PostgreSQL. The migration took some work, since a lot had already been built, but with an AI agent’s help it was done safely, following logical, measurable, testable steps (I created an “epic” in GitHub Projects with the necessary issues). As for the project’s current status, well, it’s been discontinued, though I might pick it back up down the line if it still seems worth it.
What AI changed — not the judgment, the cost
It’s worth separating two things that tend to get mixed up when this story gets told. One is whether the decision to postpone the migration kept making sense over time (and it did, for the same reasons as before). The other is how much it actually cost, in practice, to finally carry out that migration. That part changed.
That separation matters because it isolates exactly what AI changes: not whether a decision was right or wrong at the moment it was made, but how much it costs to act on it today. A migration that used to require manually rewriting every data access point, with no safety net and no repository pattern to isolate the change, can now be carried out by an agent capable of understanding the existing code, proposing the change, filling in what was missing, like the repository pattern, and, in the end, verifying the result in a fraction of the time it used to take.
That makes the whole process of assessing debt much easier, turning paying it off into something cheap and fast.
When debt spirals out of control
Not all postponed debt stays prudent forever, and that distinction is worth drawing out before wrapping up. Marco Tulio Valente, in Manutenção de Software, lists longer onboarding time for new developers as one of the most common symptoms of technical debt that’s stopped being managed. The more debt piles up without reassessment, the longer it takes someone new to understand the system before they can contribute safely.
This isn’t a hypothetical problem, and it’s not exclusive to small companies either. According to the book, Google itself runs a quarterly survey asking its engineering teams directly: “did your team knowingly take on technical debt in the last three months?” In other words, it treats technical debt as something that needs to be measured and tracked continuously, not solved once and forgotten.
Thinking it through, it’s unlikely that anyone, in a project that reaches a genuinely painful point, deliberately and prudently decided to let debt pile up that far. What usually happens is a whole sequence of reasonable decisions, each one making sense at the time it was made, with nobody stopping periodically to reassess whether they still made sense together. Over time, the accumulated debt stops being a manageable detail and starts defining, in practice, what the team can and can’t do.
The important point here is the need to keep asking that question over time: does this decision still hold up?
In practice, that usually comes down to three simple mechanisms, not sophisticated tooling. The first is repeating Google’s question: asking the team, at a fixed interval, whether any debt was knowingly taken on recently; it doesn’t wait for the problem to surface on its own, it creates space for it to be named. The second is keeping known debt recorded somewhere visible to the whole team, the same way you’d track a bug: debt that only exists in the head of whoever took it on disappears along with that person, whether through forgetting or through them leaving the team. The third is documenting the why behind a decision at the moment it’s made, for example, with a short ADR (Architecture Decision Record) capturing the context and the trade-off. That’s what makes it possible to revisit the decision months later with the same information that existed at the time, instead of reconstructing the logic from memory or, worse, concluding it never had any logic at all.
Closing
Kruchten, Nord, and Ozkaya, in Managing Technical Debt, argue for treating technical debt as just another input into development decisions, something that belongs on the same priority table as any other architectural choice, rather than a sin to confess once someone finally notices it.
The question that remains, then, isn’t “does this project have technical debt?” Almost every project does, to some degree. The question that actually matters isn’t just which cell of the quadrant each debt sits in, but whether that classification still holds today.