Accidental Debt
Debt nobody chose: an unclear domain, requirements that moved, a rushed design, missing tests, people who left. It is the larger half, and it is not a moral failure.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Nobody took a shortcut here and the code is still expensive to change. Where did that come from?
A three-year-old subscription system needs to support annual plans. The estimate is six weeks, the code was written carefully by good engineers, and nobody can point at a decision that was wrong at the time.
The previous team made a mess. The way out is a clean rewrite by people who now understand the domain properly.
The previous team did not have the understanding you have; they earned it for you. The code is a record of what was knowable then, and a rewrite discards the record while keeping the requirement to reproduce its behaviour (The Risk in a Rewrite).
- The previous team did not have the understanding you have; they earned it for you. The code is a record of what was knowable then, and a rewrite discards the record while keeping the requirement to reproduce its behaviour (The Risk in a Rewrite).
- "We understand the domain properly now" is the same sentence the original team said three years ago, and it will be said again about your rewrite (Requirements Are a Snapshot).
- The expensive part is not the code, it is the undocumented behaviour: three years of edge cases that customers now depend on and nobody has written down (Characterization Tests).
- Framing it as someone's fault makes it politically impossible to talk about accurately, so the actual causes — a moving requirement, an unclear domain, a departure — never get addressed and reproduce themselves in the new system.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Two of the three original engineers have left; the third remembers the intent of about half of it.
- The billing rules changed twice during the original build and the model reflects the first version with two layers of patches.
- There are tests, and they assert the current behaviour of the implementation rather than the rules the business believes in (Refactoring Without Tests).
- Existing subscriptions must keep billing exactly as they do, whatever is discovered about why they bill that way.
- Any correction to a rule must be traceable, because the outputs have been reported to finance for three years.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The team owns recovering the behaviour, in tests, before changing anything. That is the asset the departed engineers did not leave behind (Characterization Tests).
- Whoever notices that the model no longer matches the domain owns saying so, at the moment they notice, when it is still a small correction (The Refactoring Loop).
- The organisation owns the causes it can control: turnover, review, and whether design gets revisited when requirements move (Knowledge Sharing).
- Draw a boundary around the part you actually need to change and characterize only that. Recovering behaviour for the whole system is a project; recovering it for annual plans is a week (Seams).
- Separate "the model is wrong" from "the model is old". A model that was right for monthly billing and is now stretched over annual is a modelling problem; one that was always confused is a different one (Choosing the Model).
- Keep the correction inside a boundary that can be released independently, so the six weeks becomes six one-week changes (Incremental Migration).
Five causes, and what each actually needs
Accidental debt is not one thing. The five causes below produce superficially similar code and need quite different responses, and choosing the wrong response is why "we cleaned up the legacy module" so often changes nothing.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| The domain was unclear when the code was written | A model with a concept missing, and every feature since built as a workaround | The design encodes an early, partial understanding — the original meaning of the metaphor | Name the missing concept and migrate to it incrementally. This is the case where refactoring genuinely pays (Domain Modeling). |
| Requirements moved during the build | Two layers of patches over a design that fit the first version | The design was right for a requirement that no longer exists | Redesign against the current requirement, not the union of all past ones (Requirements Are a Snapshot). |
| Rushed design under deadline | Structure that nobody would defend and nobody chose | No time was given to the design step; this is deliberate debt that nobody wrote down | Treat it as debt with an owner and a trigger, retroactively (Deliberate Debt). |
| Missing tests | Every change is slow because nothing can be verified cheaply | Behaviour is unprotected, so change is risky regardless of structure | Characterize before restructuring. Structure work on unprotected code is the highest-risk activity available (Refactoring Without Tests). |
| Team turnover | Code that was understood is now merely present | Knowledge left without changing a line of code | Recover it into tests and vocabulary rather than into a document nobody will open (Bus Factor). |
| A dependency changed underneath you | Workarounds for behaviour that used to be correct | The environment moved; the code did not | A maintenance decision with a revisit trigger, not a refactor (Dependency Management). |
The gap between the code and the domain
The mechanism is worth drawing, because it explains why accidental debt appears in careful teams and why it is front-loaded rather than a sign of decay. Understanding of the domain improves over time. The code encodes the understanding at the moment each part was written. The gap between the two is the debt, and it widens by default — every requirement teaches you something the existing model does not express.
There are exactly two ways to close the gap: correct the model when you learn, which is small and continuous, or patch around it, which is smaller now and compounds. The second is the default because it is what the deadline in front of you rewards.
- The gap opens without anyone doing anything wrong — that is what makes it accidental (Requirements Are a Snapshot).
- Correcting the model is cheapest at the moment you learn, and gets more expensive monotonically after that (The Cost of Change).
- A departure widens the gap without any code changing, which is the cause teams most consistently fail to plan for (Bus Factor).
- Patching is the right answer sometimes: when the area is stable, or the feature is a pilot, or you do not yet trust the new understanding (When Design Does Not Pay).
Two responses to the same discovery
When you discover the model is missing a concept, there is a version of the response that pays and a version that looks almost identical and does not. The difference is whether the new concept replaces the old representation or joins it.
class Subscription {
monthlyPriceCents: number // original
billingPeriod?: BillingPeriod // new, optional
annualDiscountPct?: number // added for annual
}
// Two representations, both live. Every reader
// must know which wins, and the answer differs
// by code path. Nothing was removed.class Subscription {
price: Money
period: BillingPeriod // Monthly | Annual | Custom(days)
}
// Old field written by a backfill, read by nothing.
// Removed in the following release once the
// dual-read window closed.The first version is the cheaper change today and leaves the system with two notions of what a subscription costs, which is strictly worse than the single confused notion it started with — the next engineer now has to reconstruct a precedence rule that exists only in code paths. The second costs a migration and a dual-read window and ends with one representation, which is the only state in which the discovery has actually been banked (Incremental Migration).
How to build it
Most important first.
- Start by recovering knowledge rather than by restructuring. Tests that state what the system does today are the prerequisite for everything else (Characterization Tests).
- Find where the model and the domain diverged. Usually there is one concept — here, "billing period" — that the original design did not have and that every patch has been working around (Domain Modeling).
- Introduce the missing concept in one place and migrate to it incrementally, keeping both representations alive until the last caller moves (Expand and Contract).
- Write down what you learn as you learn it, in the code's own vocabulary, because the next occurrence of this problem is a departure away (Ubiquitous Language).
- Fix the cause you can control. If the model drifted because nobody revisits design when requirements change, that is a process change worth more than the refactor (Revisit Triggers).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: annual plans cost six weeks, most of it discovery — reading code to reconstruct rules, testing hypotheses against production data, and finding the three places a monthly assumption is hard-coded as a constant.
- Before, the recurring part: every subscription change pays that discovery cost again, because nothing from the last discovery was written down anywhere durable.
- After recovering behaviour and introducing a billing-period concept: annual plans cost about a week, and the next billing variant — quarterly, custom terms — costs days, because the concept now exists and the tests state the rules rather than the implementation.
- What stays expensive: three years of edge-case behaviour is now captured in tests, which means it is also now frozen. Some of those edge cases are bugs, and the tests will defend them (The Legacy Change Loop).
- Recovering behaviour is expensive and produces nothing a customer can see, so it is always the hardest work to fund.
- Correcting a model mid-life is a wide change with real regression risk, and doing it under a feature deadline is how a careful team ships an outage.
- Writing down understanding as you go is a tax on every change, and most of it will never be read; the value is concentrated in the small fraction that is read after someone leaves.
What can go wrong
- The team treats accidental debt as incompetence, so the honest conversation about causes does not happen and the causes persist (Tone, Disagreement and Receiving Review).
- Characterization tests capture bugs as requirements, and the bugs become permanent because now a test asserts them.
- The missing concept is introduced beside the old model and both survive, so the system has two notions of a billing period and they disagree at the boundaries.
- The mitigation fails: "fix the cause" is often not in engineering's gift. Turnover and shifting requirements are business realities, and a team that treats every departure as a process defect will burn out trying to prevent the unpreventable.
- Recovering behaviour depends on being able to run the system in a controlled way, which is often the first thing missing (A Deterministic Core).
- Correcting the model creates a dependency on the new concept from everything that used to work around its absence — which is the point, and it is a wide change.
- "Accidental debt means someone was careless." The largest source is learning: the code encodes the understanding available when it was written, and understanding improves. That is the original meaning of the metaphor, not a failure of it (What Technical Debt Actually Is).
- "Better up-front design would have prevented it." Some of it, and less than people think. Requirements moved twice during the build; no amount of up-front design absorbs a domain that changes while you are modelling it (Design for the Known, Name What You Assumed).
- "The fix is documentation." Documentation of intent helps and decays. Executable characterization of behaviour does not decay, because it fails when it is wrong (Documentation Decay).
- "Rewrite it now that we understand." You understand the domain, not the three years of behaviour customers depend on, and the second one is what the rewrite has to reproduce (Refactor or Rewrite).
- primitive-obsession
- divergent-change
- duplicate-knowledge
Testing it, and how it ages
- Characterization tests first, at the outermost boundary you can afford, capturing behaviour rather than intent (Characterization Tests).
- As each rule is recovered, promote its test from "this is what it does" to "this is what the business says it should do", and record which of the two it is.
- Test the new concept directly and exhaustively, because it is now the thing every future variant depends on.
- Accidental debt accumulates fastest where the domain is least understood, which is exactly where the first version of any system is written. It is therefore front-loaded and not a sign of decline.
- It compounds through departures: each person who leaves converts some understood code into unexplained code, without a single line changing (Bus Factor).
- A team that corrects the model each time it learns something keeps accidental debt roughly flat; one that patches around it sees the patches become the design (The Refactoring Loop).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALEvery long-lived codebase accumulates it, in every language; what differs is the recovery mechanism, since a statically typed codebase surrenders more of its structure to a reader than a dynamically typed one does.
- DOMAIN-SPECIFICIn a domain with a stable, externally specified model — a protocol implementation, a chess engine, a compiler front end — accidental debt from domain misunderstanding is small, because the domain does not move. In billing, insurance, logistics or healthcare it dominates, because the rules are large, contradictory and revised by people outside the team.
- CONTESTEDA serious opposing view holds that most so-called accidental debt is really deferred design: the information needed was available, someone did not go and get it, and calling it accidental removes the incentive to talk to the domain expert next time. There is truth in that — a fair amount of "the domain was unclear" is "we did not ask" — and the distinction matters because the two have different remedies. The counter is that in genuinely evolving domains the information does not exist yet at any price.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — characterization coverage is the asset that makes accidental debt recoverable, and how much of it is enough is a confidence question that domain owns.