When a developer marks a ticket as “resolved” and closes it, something genuinely changed in the code. But the underlying complexity that produced the bug didn’t go anywhere. It moved. This is not a pessimistic take on software quality. It’s a structural property of how complex systems behave, and once you see it, you can’t unsee it.
The Conservation of Complexity
There’s a principle in software design, articulated clearly by Larry Tesler in the context of user interfaces, that complexity cannot be eliminated from a system, only moved. Tesler was talking about the trade-off between making software simple for users versus burdening the developers who build it. But the principle runs deeper than UX decisions.
Every software system has an irreducible complexity floor. Some problems are genuinely hard, and the difficulty has to live somewhere. When you fix a bug, you’re rearranging where that difficulty sits. The most common outcome is that you move it from one place in the codebase to another, from the code to the configuration, from your system to your users, or from the present to some future version of the problem.
This isn’t a failure of engineering skill. It’s what fixing things actually looks like in a system with real constraints.
Why Patches Create New Attack Surfaces
The security world has documented this phenomenon more rigorously than most software disciplines. When a vulnerability is patched, the fix itself frequently introduces new surface area. The HeartBleed bug in OpenSSL is a useful reference point: the fix was straightforward in isolation, but auditing the surrounding code that the bug had obscured took far longer, because the patch drew attention to adjacent assumptions that had never been pressure-tested.
This pattern shows up repeatedly in CVE histories. A buffer overflow gets fixed by adding bounds checking. The bounds checking introduces a new conditional branch. That branch has its own edge cases. The edge cases sit quietly until someone finds them. This isn’t a failure of the developers who wrote the original patch. It’s what happens when you insert new logic into a system that was built around different assumptions.
The complexity relocated. It didn’t leave.
The Debt That Compounds in Silence
Technical debt is a concept most developers understand intuitively, but it’s worth being precise about what it means in this context. When you fix a bug quickly by adding a special case, a flag, or a workaround, you’ve solved the immediate problem. You’ve also made the codebase slightly harder to reason about. The next person who reads that code has to carry an extra piece of context in their head.
Multiply that by a few hundred quick fixes over two years and you have a system where the bugs aren’t discrete problems anymore. They’re emergent properties of accumulated workarounds that each made sense individually but collectively create behavior nobody fully understands.
This is why the number of bugs in a mature codebase often doesn’t decrease linearly over time, even with a disciplined engineering team. New bugs emerge from the interaction of old fixes. The fixes were real. The improvement was real. But the complexity that produced the original bugs found new configurations to inhabit.
The practical implication for your work: when you’re reviewing a patch, don’t just ask whether it fixes the reported behavior. Ask where the complexity went. If you can’t answer that, the fix probably isn’t done.
How Abstraction Layers Hide the Bill
One of the most common relocation paths for bug complexity runs through abstraction. When a database query is slow or incorrect, you might wrap it in a caching layer. The query problem is gone. Now you have cache invalidation logic that needs to be correct, and cache invalidation is famously one of the two hard problems in computer science (the other being naming things, and, some would argue, off-by-one errors).
Abstraction is genuinely valuable. That’s not the point. The point is that abstracting a problem away and solving it are different things. When you use a framework that handles authentication for you, you’ve moved the complexity of authentication into the framework’s logic and your configuration of it. That complexity will express itself again the moment your use case drifts from what the framework anticipated.
This is one of the structural reasons why third-party dependencies that look like solutions turn out to be bug generators in disguise. You imported someone else’s answers to their version of your problem. The gaps between their version and yours are where the next bugs live.
The Social Dimension Nobody Talks About
Bug relocation isn’t just a technical phenomenon. It has a human dimension that’s worth being direct about.
When a customer-reported bug is “fixed” by changing the documentation to say the behavior is actually expected, the complexity moved from engineering to the customer. When a race condition is resolved by adding a retry loop, the complexity moved from the synchronization logic to the operations team who now has to monitor retry rates and understand what elevated retries actually mean. When a memory leak is addressed by adding more RAM to the server, the complexity moved from the code to the infrastructure budget.
None of these moves is inherently wrong. Sometimes they’re the correct trade-off. But they should be made consciously. The most dysfunctional version of this pattern is when the social move happens without anyone acknowledging it, when the ticket gets closed and the complexity has quietly been transferred to someone who doesn’t know they received it.
If you manage engineers, or you are one, this is worth building explicit habits around. Who inherited the complexity from this fix? Do they know?
What Rewrites Actually Accomplish
Every few years, an engineering team reaches the point where the accumulated relocations have made the system so hard to reason about that a rewrite starts to seem like the answer. Sometimes a rewrite is the right call. But it’s worth understanding what you’re actually doing when you rewrite.
You’re not eliminating the underlying complexity of the problem. You’re starting the relocation cycle fresh, with the benefit of knowing where complexity accumulated last time. That knowledge is genuinely valuable. The new system will accumulate complexity in different places, hopefully places that are more visible or more manageable, but it will accumulate it.
Joel Spolsky wrote about this in 2000 in his famous post on why you shouldn’t rewrite from scratch, arguing that the bugs in existing software often encode hard-won knowledge about edge cases that took years to discover. That framing holds up. The relocated complexity in your legacy system represents a map of where reality diverges from the original design. Throwing that away means rediscovering those divergences the hard way.
This doesn’t mean never rewrite. It means going in with clear eyes about what you’re getting and what you’re giving up.
How to Work With This, Not Against It
None of what’s described here is a reason for fatalism. It’s a reason for developing better instincts.
First, when you fix a bug, make the relocation explicit in your commit message or PR description. “Fixing X by moving the validation logic to the input layer. This means Y now needs to handle malformed input” is a far better record than “fixed bug #4412.” Future you, and your teammates, will thank you.
Second, treat tests not just as a way to verify the fix, but as a way to mark where the complexity currently lives. A test that covers the edge case of a fix is a flag that says “complexity settled here.” When the system evolves and that test starts failing unexpectedly, you know where to look.
Third, when you’re doing code review, ask the relocation question explicitly: where did the complexity go? If the answer is “to the user” or “to ops” or “to configuration,” that might be fine, but it should be a deliberate decision that the right people have agreed to, not an accidental one.
Finally, build in time for what some teams call “complexity audits,” periodic reviews not of individual bugs but of where the hard parts of the system are currently living. This is different from a standard code review or a technical debt backlog. You’re asking a systemic question: has complexity been accumulating in places that are fragile, invisible, or poorly owned?
What This Means
Bugs are symptoms. The underlying complexity that produces them is the actual substance of the problem, and that substance is conserved across your fixes. Every patch, workaround, and abstraction moves it; sometimes to a better place, sometimes to a worse one, almost never out of the system entirely.
This matters for how you review code, how you write postmortems, and how you talk to stakeholders about what “fixed” means. A ticket being closed is not evidence that the problem is gone. It’s evidence that the problem moved. Knowing where it went, and whether that’s an acceptable location, is the part of software engineering that doesn’t show up in the commit log but determines whether your system gets easier or harder to work in over time.