Software Architecture · Technical Debt
Sequencing Repayment Under a Fixed Capacity
With more debt than capacity, the order of repayment determines the total benefit. Ranking by interest eliminated per week of work, subject to deadlines and prerequisites.
Every organisation that recognizes its technical debt eventually faces the same allocation problem: more candidate repayments than capacity to perform them. Treating the choice as a matter of taste produces the familiar pattern of refactoring whatever the last person found annoying, which is uncorrelated with where the cost is actually being paid. Treating it as a scheduling problem produces a defensible order, and the order matters more than the total amount of effort spent, because the payoff of a repayment is the interest it stops.
The scheduling view has three inputs: the interest each candidate currently charges, the principal each requires, and the constraints that make some orderings impossible or some candidates worthless. The first two come from the analysis described in Measuring Debt Without Inventing Numbers, with all its uncertainty. The third is where most of the intellectual work sits, and it is routinely skipped.
Ranking by interest eliminated per week
If candidates were independent and divisible, the optimal allocation would be greedy by rate of return: compute interest saved per unit of principal for each, and fund them in descending order until capacity is exhausted. This is the fractional knapsack problem, and greedy is exactly optimal for it. The intuition is that a repayment with a high ratio converts scarce capacity into eliminated recurring cost faster than one with a low ratio, and since capacity is the binding constraint, the conversion rate is what to maximize.
The model is worth using even though its assumptions fail, because it forces the two quantities to be named. A candidate with low principal and moderate interest can outrank a large, high-interest rewrite. Consolidating six copies of a validation rule might take a week and remove a recurring charge on every change to that rule; replacing an ORM might take a quarter and remove a recurring charge that is spread thinly across a codebase. The ratio decides, and the ratio is not obvious from the size of either.
When candidates are indivisible — and they are, since a half-finished refactoring is usually worse than none — the problem becomes 0/1 knapsack, which has no efficient exact solution in general. The practical consequence is not that greedy is wrong but that it is an approximation, and it can be beaten by combinations. Two medium candidates may fit where one large one does not, even if the large one has the better ratio. Any ranking should therefore be checked against a packing pass: fill the remaining capacity with the best-fitting small items rather than leaving it idle.
Prerequisites make the order partial
Refactorings have dependencies. Extracting a module is usually a prerequisite for replacing the implementation behind it; adding tests is a prerequisite for changing behaviour safely; fixing a data shape is a prerequisite for changing the code that reads it. A ranking that ignores the dependency graph will schedule work that cannot start, and the schedule will be re-planned from scratch each time, which is how repayment programmes lose credibility.
The mechanics of scheduling with prerequisites are standard: take the transitive reduction of the dependency graph, then apply the ratio ranking only to candidates whose prerequisites are complete. This produces a topological order with a ratio-based tiebreak. The practical effect is that unglamorous preparation work — characterization tests, a seam, an interface extraction — rises in the queue, because it unlocks the items with the high ratios. That work is also the work most likely to be dropped when a deadline arrives, which is why an explicit graph is more useful than a ranked list.
import heapq
def schedule(items, capacity):
"""items: {name: (principal, interest, {prereqs})}. Ratio-greedy over ready work."""
done, spent, order = set(), 0, []
ready = [(-(i / p), n) for n, (p, i, pre) in items.items() if not pre]
heapq.heapify(ready)
pending = {n: v for n, v in items.items() if v[2]}
while ready and spent < capacity:
_, name = heapq.heappop(ready)
principal, _, _ = items[name]
if spent + principal > capacity:
continue
spent += principal
done.add(name)
order.append(name)
for n, (p, i, pre) in list(pending.items()):
if pre <= done:
heapq.heappush(ready, (-(i / p), n))
del pending[n]
return order, spent, capacity - spent
The greedy loop is deliberately naive about deadlines and about packing; it exists to show that the two quantities plus the graph are enough to produce an order, and that the leftover capacity at the end is a visible quantity rather than an accident.
Deadlines that override the ratio
Some obligations are not optional and not movable, and they take precedence over any ratio. A dependency with a published end-of-support date, a protocol deprecation with a counterparty’s deadline, or a security weakness with a known exploitation path has a hard date; missing it converts the interest into a loss. These belong in the schedule as fixed points, with the ratio-ranked work filling the space around them.
The distinction between a deadline and an urgency claim matters. “The vendor stops supporting this version in fourteen months” is a deadline, because the consequence attaches to a date. “This code is terrible and someone should fix it” is a preference, and its ratio may still justify the work, but not by virtue of urgency. Teams that treat preferences as deadlines end up with a schedule made entirely of overrides, which is another way of having no schedule.
Write-offs are the other category that overrides the ratio in the opposite direction. A module scheduled for replacement within the planning horizon should not be refactored, regardless of its interest, because the principal will be discarded along with the module. The check is cheap and is skipped surprisingly often: before ranking a candidate, establish whether the component has a planned end of life. The exception is the small repairs that keep the doomed module operable until its replacement lands, which are maintenance rather than repayment.
Why dedicated refactoring periods underperform
The instinct to concentrate repayment into a periodic block — a “hardening sprint”, a percentage of each release cycle — has an appealing simplicity and a predictable failure mode. A block concentrates structural change into a period during which feature work continues in parallel, so the structural branch and the feature branch diverge, and merging them costs roughly the same as the refactoring. The cost lands at the end of the block, when the merge conflicts are resolved under time pressure, and it is paid in defects.
The alternative is to spend the same capacity in changes that are small enough to merge continuously, each of which is behaviour-preserving and independently reviewable. The discipline that makes this workable is separating structural edits from behavioural edits in the same area, so that a reviewer can see that one change moves code and the next changes what it does. A change that both restructures and alters behaviour has no reviewable invariant, and its test results cannot distinguish “the refactoring is wrong” from “the new behaviour is wrong”.
Small continuous repayment has a second advantage that matters for scheduling: it can be attached to work that is already happening. When a change must touch a module anyway, the marginal cost of improving the module’s shape is the difference between editing it now and editing it again later, and that difference is smaller than the standalone cost. This is the only defensible version of the opportunistic-refactoring rule: the scope is bounded by the change already in hand, and anything beyond it is a separate, scheduled item.
Allocation that tracks exposure
A fixed percentage of capacity for debt work is a simple policy and a poor one, because interest is not uniform in time. When a subsystem is about to absorb a large feature, the interest on its debt is about to be charged heavily, and repayment there has a higher ratio than repayment in a subsystem that will be quiet. The allocation should follow the roadmap rather than a constant fraction.
This produces a scheduling rule with a forward-looking element: for each planned piece of feature work, estimate the interest that the affected modules will charge during it, and consider repaying the highest-ratio items in those modules before the feature starts. The repayment then also reduces the feature’s cost, which is a benefit the standalone ratio calculation does not capture. The estimate is rough, and it does not need to be precise — the ranking only has to be right at the top of the list.
The counter-argument is that this keeps attention on whatever is currently being built, leaving the untouched parts of the system to rot. That is correct and is the point: untouched parts are not charging interest, as argued in Principal, Interest, and Upkeep in the Technical Debt Model. They become relevant when they are about to be touched.
Time as an input, not as a multiplier
One modelling error is worth naming because it distorts every ranking: treating interest as growing with age. A design flaw does not become more expensive because it is old; it becomes more expensive because more code depends on it, and dependency count grows through use, not through time. A module with no dependents and no edits has the same interest after five years as after five days.
The corollary is that a repayment deferred is not necessarily a repayment made more expensive. Deferring a repayment in a stable module is often free. Deferring one in a module that is about to be extended by three teams is not, because the extension will create the dependents that raise the principal. The scheduling question is therefore less “how old is this debt” and more “what is about to be built on top of it”, which is a question about the roadmap and can be answered before the interest is incurred.
Where time does enter is through the verification cost of the repayment itself. A codebase that has accumulated untested behaviour cannot be restructured safely, so the first repayment in such a codebase is usually the tests that make the rest possible. That ordering is an instance of the prerequisite rule, and it is also the reason repayment programmes that start with the most visible structural change tend to stall: the change cannot be verified, so it is either abandoned or shipped with a defect, and both outcomes consume the credibility the programme needed.
References
- Tidy First? A Personal Exercise in Empirical Software Design — Kent Beck, O'Reilly, 2023
- Managing Technical Debt: Reducing Friction in Software Development — Kruchten, Nord, and Ozkaya, SEI Series, Addison-Wesley, 2019
- Software Engineering Economics — Barry Boehm, Prentice Hall, 1981
- Refactoring: Improving the Design of Existing Code — Martin Fowler, 2nd edition, Addison-Wesley, 2018