The nature of technical debt

What is ‘technical debt?’1

Technical debt is not a real debt. There is no loan. There is no creditor, no interest payments, and no repayment schedule. Treating it as “debt” distorts decision-making.

Another misleading viewpoint is to describe building software as creating an asset. Custom software has some properties of an asset, but think about the traditional lifecycle of a true capital asset like a building or a bulldozer. Over its lifetime, an asset moves through distinct phases:

%%{init: {'xychart': {'width': 900, 'height': 600}, 'themeVariables': {'xyChart': {'plotColorPalette': 'var(--figure-accent-border)'}}}}%% xychart-beta title "Assets Depreciate" x-axis "Year" [1, 2, 3, 4, 5, 6] y-axis "Value" 0 --> 100 line [90, 60, 40, 25, 15, 10]

Figure 1: A true asset has a purchase price, an initial value, depreciation, and finally a salvage value.

A custom application developed in-house is different. Accountants may well capitalize it on the balance sheet, so it carries a book value and depreciates on schedule like any other asset. But that book value is an accounting convention, not a market price. The numbers that matter tell a grimmer story:

%%{init: {'xychart': {'width': 900, 'height': 600}, 'themeVariables': {'xyChart': {'plotColorPalette': 'var(--figure-accent-border)'}}}}%% xychart-beta title "Custom Applications Are Sunk Costs" x-axis "Year" [1, 2, 3, 4, 5, 6] y-axis "Value" 0 --> 100 line [90, 5, 5, 5, 5, 0]

Figure 2: The same purchase price, but the value collapses instead of gliding down.

That gap is the point. A true asset is something you could sell to recover value; custom software mostly is not. Yet the idea of technical debt still reflects something real. An application with high technical debt carries a risk. You might have to change the software, and the more debt it carries, the more those changes cost.

Technical debt measures potential future expense.

The biggest risk is not just the expense. It’s the timing. You might have to pay it at a moment not of your choosing. A critical customer need suddenly requires a change to a brittle subsystem. A security vulnerability demands an immediate patch. At those moments, technical debt becomes very expensive very quickly.

Why not wait?

It’s tempting to defer updates. Libraries and frameworks receive new versions constantly. Updating carries risk. There might be compatibility issues, unexpected behavior, or bugs in the new version. It seems rational to wait for a more stable version, or until the pressure becomes acute. Suddenly, you don’t have a choice. Often, the forcing function is a vulnerability.

Here’s what happens in practice:

flowchart TD A["🔴 OS vulnerability"]:::accent --> B["App server<br/>doesn't support new OS"] B --> C["Application code<br/>incompatible with new server"] C --> D["Database version<br/>incompatible with new app"] D --> E["Three dependent systems<br/>affected by schema change"] E --> F["❌ Weeks of coordination<br/>and retesting"]:::accent

Figure 3: A single forced change cascades through the stack, touching integration points across the system.

You cannot guarantee that cascade will be smooth. The more time you’ve spent deferring, the further apart the component versions have drifted, and the less likely any combination of them has been tested together. Different libraries may require incompatible versions of their shared dependencies. Breaking API changes pile up. The teams you need to coordinate with have moved on to other projects.

The smaller the version increments, the better your chance of success. If you had been keeping the application server and application in sync all along, the forced upgrade might have been fully automated. Deferring creates an expense that compounds, not just the effort to catch up but the added uncertainty and coordination cost across your entire system.

Controlling technical debt in the little picture

Keeping those increments small starts with a working definition of “current.” It does not mean the sweeping upgrades that belong to the big picture, nor chasing every release the day it lands. It means not letting the components you depend on drift far behind, so that no single update is ever large enough to hurt. The harder question is how you tell whether you have drifted. For years the version number itself was supposed to answer that.

Knowing where you stand

Semantic versioning says that the three parts of major.minor.patch signal the size of a change, so a patch is a safe fix, a minor adds features, and only a major is allowed to break you. The catch is that the same scheme hands your schedule to the vendor. Pin yourself to “the last major” or “no more than one major behind,” and your cadence is set by whoever decides what counts as a major release.

Calendar versioning increasingly displaces semantic versioning, especially for applications. It abandons the promise entirely. The version is just a date. You can no longer read safety off the number, so you have to fall back on your own tests and metrics, but in exchange control returns to you. A vendor’s release cadence, monthly or quarterly, is the vendor’s business. How far behind you let your application drift is your own. That is why being current is best judged by age, not by counting the releases you are behind.

Knowing what you have

Judging anything by age first assumes you know what you actually have. The dependencies you chose deliberately are only the surface. Beneath them lie all the transitive dependencies they pulled in. A software bill of materials (SBOM) makes the full tree explicit. Generating one is now routine, and the better tools run as a service, so the data they check against stays current instead of going stale in a database you forgot to update. The same scan that ages your components also surfaces their license terms, flagging open source obligations that may not fit your project, along with the known vulnerabilities already filed against the versions you use.

Age measures how far you have fallen behind the latest target, but it only matters if the target is still moving. If it stops, the math changes. For commercial software, maintenance means a vendor still shipping fixes and publishing a roadmap, not one quietly winding the product down or treating it as a cash cow. For open source, it means a project with a few genuinely active committers, still merging changes and cutting releases, and not leaving real defects open for years. A repository whose last commit is two years old fails on every count. A component nobody maintains can never be made current, because no newer version is coming. That leaves three ways to respond:

Only the first reduces technical debt.

quadrantChart title Components by version age and project activity x-axis Abandoned --> Active y-axis Older --> Latest quadrant-1 Current and maintained quadrant-2 Migrate quadrant-3 Danger zone quadrant-4 Upgrade Betas:::edge: [0.93, 0.90] HTTP client: [0.86, 0.84] Crypto: [0.90, 0.70] JSON: [0.76, 0.76] Logging: [0.84, 0.60] Metrics: [0.66, 0.86] ORM: [0.72, 0.54] Web framework: [0.70, 0.20] Build plugin: [0.60, 0.64] XML parser: [0.18, 0.80] Auth shim: [0.74, 0.30] SOAP stack: [0.26, 0.28] classDef edge color: #87909e, stroke-color: #1b2733, stroke-width: 1px, radius: 25

Figure 4: Each component placed by how recent its installed version is and how recently the project has released a new version. The upper-right quadrant, recent versions of still-active projects, is within acceptable bounds but beware of instability when using beta releases. This portfolio is mostly current and maintained, with a few abandoned libraries stranded to the left and some outdated libraries on the lower right.

Metrics

Avoiding churn

Artificial intelligence has sharply increased churn. New tools find vulnerabilities faster than humans, and many projects answer the ones AI reports by using AI to generate and ship a fresh version almost at once. The recent release of Mythos has accelerated the trend, turning the occasional patch into a near-constant stream. The strain shows even at the top. In May 2026 Linus Torvalds reported that the flood of AI-generated bug reports had made the kernel’s security mailing list “almost entirely unmanageable.”2 When updates arrive that fast, staying current becomes a question of routine rather than effort. Keeping up by hand is hopeless and will burn out maintainers.

The temptation is to treat every new release as urgent and apply it the instant it lands. Resist it. Torvalds has long insisted, correctly, that “security problems are just bugs.”3 The converse holds just as well. Bugs are bugs too. Not every patch justifies dropping everything. Most updates can wait for the next scheduled pass, batched and run through your tests together. Let automation merge the safe ones on its own. Reserve the interrupt-driven scramble for the rare fix that genuinely cannot wait. Chasing each patch the moment it appears is its own expense, paid in distraction and risk, for very little in return.

Making updates routine

Batching only works if applying a batch is cheap, and routine only works if changing a version is cheap. Both start with where versions are declared. Scatter them across every module and each update is a hunt; collect them in a single version catalog and it is one edit. The catalog is also the natural place to generate updates and to bundle security patches, so a fix lands everywhere at once rather than module by module.

All of this rests on the unit tests. Automation lets new versions ship the moment a build passes, so the test suite is the only thing standing between an upstream change and production. Where coverage is thin, an update can pass a green build and still carry a defect through. Solid tests are therefore a precondition, not an optional refinement, and their gaps set the real limit on how much you can safely automate.

Within that limit the automation is nearly free. Each build picks up the latest versions, runs them through the suite, and ships whatever passes with no pull request to open and no merge to approve. The update simply arrives.

That freedom comes from asking for no more precision than you need. Pinning a dependency to 1.2.3 freezes it at a point the next patch will immediately invalidate, while asking for 1.2 or later lets the build resolve the newest compatible version every time it runs. You lose nothing in reproducibility because the build records the exact versions it resolved. Each carries a precise manifest of what went into it even though the declaration stayed loose. If the build fails, the manifest turns diagnosis and repair into arithmetic: compare the failing build against the last one that passed, and the libraries that changed between them are your short list of suspects.

Automatic resolution only reaches as far as the constraints allow. When a fix lies outside them, the constraint itself must change, and that calls for human judgment. This is the job a tool like Dependabot is built for, raising a pull request to propose the new constraint. Sometimes that means lifting a floor or forcing a transitive dependency, sometimes excluding a release that is broken or compromised rather than admitting a new one.

Forced constraints are easy to lose track of, so they belong in one named place rather than scattered through the build. Collect every CVE pin into a single set, each labelled with the advisory it answers and applied uniformly. That set doubles as a record of which vulnerabilities you have addressed and why, and when upstream catches up the pins come out. Most build systems can express this. The example below shows one form.

In practice: a Gradle security-patches bundle

In a Gradle build, the pins live in the version catalog (gradle/libs.versions.toml). Each is a library entry named for the advisory it answers, gathered into one security-patches bundle:

[libraries]
patch-cve-2024-12345 = { module = "com.example:vulnerable-lib", version = "1.4.2" }
patch-cve-2025-67890 = { module = "org.sample:other-lib", version = "3.1.0" }

[bundles]
security-patches = ["patch-cve-2024-12345", "patch-cve-2025-67890"]

The bundle is applied as implementation constraints, so every module inherits the floors without depending on the libraries directly. Adding a patch updates both the [libraries] entry and the [bundles] list; removing one, after upstream catches up, is a single deletion.

Setting the threshold

Good enough is not zero debt. It is a threshold past which you judge an application too exposed to the unplanned, urgent expense that is the real danger of technical debt. You set the threshold as an age measured by the calendar rather than by how many releases you are behind. An abandoned library has already crossed it, because when a patch is finally needed, none will come.

Change still gets forced eventually by events outside your control, whether a vulnerability that has to be patched, an operating system that drops support for the version you run, a vendor that ends maintenance, or a neighboring system that changes how it expects to be called. Staying current does not prevent any of that. It just keeps forced changes small, a single step rather than a leap across years of drift. Frequent updates buy forced changes that stay routine.

Controlling technical debt in the big picture

Keeping every version current says nothing about whether the application is still the right one to run. One with every dependency up to date can be too old in its design, too entangled with its neighbors, too expensive for anyone to touch. Judging that means reading the bill of materials as part of the portfolio rather than just a list of versions.

The same inventory carries each component’s license and each application’s vendors. Licensing and lock-in belong in the decision too, weighed alongside the debt even though staying current does nothing to pay them down. A central library too entrenched to replace crosses the threshold when it is too costly to update, or when an update requires unacceptable license terms.

Counting the cost

With the threshold set, look at what the application costs the team that owns it and at what it costs everyone else.

Is the application dragging itself down?

These are the costs the owning team feels directly. A high “tax” falls due every time a neighboring system updates and this one needs changes to keep up. Components age past the threshold you set. Web frameworks and authentication mechanisms are frequent offenders. The cost of routine maintenance creeps up release over release. Over time, it grows harder to hire or keep people who have the legacy skills the application demands. None of these waits for a forced change; each is a way the application quietly taxes its own team in the meantime.

Metrics

Is the application dragging others down?

These costs are easy to overlook, because the team that creates them is not the team that pays. The clearest is forcing the neighbors to keep speaking a legacy dialect, holding open batch file exchanges, SOAP endpoints, or support for browsers everyone else has long dropped. Another is unnecessary data synchronization, maintaining copies where a just-in-time call would do. A third is caching with no proven reason, or with one nobody revisits as machines get faster. Every neighbor that has to accommodate these habits is paying part of the application’s bill, which is why how often an interface is used says nothing about whether it should still exist.

Metrics

What to do about it

Once an application fails the test, only a few responses are useful, and what separates them is who keeps control of the timing. You can invest, modernizing or replatforming to pay the expense down deliberately while the schedule is still yours. You can contain, wrapping the application behind a facade and freezing it, accepting the debt but stopping it from spreading to the neighbors. Or you can retire it, sunsetting the application and migrating its users, the right call when neither investing nor containing earns its keep. The fourth option is the one no one chooses on purpose. Drift, making no decision at all, hands the timing to the next forced change, which is precisely the control this whole discipline exists to keep.

Governing the portfolio

Left alone, a team fixes only the expense it pays for itself. Someone has to look across applications and price the rest, the bounds no single team will set and the costs one application pushes onto the others.

That work divides into three jobs. The first sets the acceptable-age bound and enforces it everywhere, so staying up to date is portfolio policy and not each team’s taste. The second stops applications from exporting their costs, making each maintain its own facades and hold the bounded contexts that keep one system’s internals out of another’s. The third tells business partners the truth about what the portfolio’s state means for them, the integrations that will grow harder and the enhancements that will come slower because of debt carried elsewhere.

The point of all this

None of this makes the expense go away. Software still has to change, and every change still costs something. What the discipline buys you is control over the timing. The danger of technical debt was never the size of the bill, it was that the bill arrives when you least expect it, whether a vulnerability that must be patched today, a partner system that upgraded without asking, or a customer need that lands on the most brittle part of the code. Controlling technical debt in the little picture keeps each forced change small enough to absorb. Controlling it in the big picture keeps your application from becoming the reason someone else has to scramble. Do both, and you convert an unpredictable, externally triggered expense into a routine one you schedule on your own terms. That is the whole point: not to pay less, but to choose when you pay.


  1. This article is based on a presentation I gave to the TBM Evolution Group 3rd Annual Enterprise Architecture for Financial Institutions in NYC on June 9, 2022.↩︎

  2. Linus Torvalds, “Linux 7.1-rc4,” linux-kernel mailing list, 17 May 2026, https://lkml.org/lkml/2026/5/17/896.↩︎

  3. Linus Torvalds, “Re: [GIT PULL] usercopy whitelisting for v4.15-rc1,” linux-kernel mailing list, 17 November 2017, http://lkml.iu.edu/hypermail/linux/kernel/1711.2/01701.html.↩︎