Why Most Software Systems Become Hard to Maintain
Complexity doesn't arrive all at once. It accumulates one reasonable decision at a time — and by the time it's a problem, no single change caused it.
Eng Abdalla
Updated Aug 29, 2026
Complexity doesn't arrive all at once. It accumulates one reasonable decision at a time — and by the time it's a problem, no single change caused it.
Eng Abdalla
Updated Aug 29, 2026
No comments yet — be the first to weigh in.
Every system I've inherited was, at some point, clean. Someone made deliberate, defensible choices. And every one of them became hard to work in anyway. That contradiction is worth sitting with, because the usual explanation — "bad engineers" or "no time for quality" — doesn't hold up. The teams I've watched build unmaintainable systems were competent and careful. They just optimized for the wrong thing, one decision at a time.
Nobody wakes up and decides to build an unmaintainable system. It happens through a sequence of locally reasonable choices that are each, individually, fine.
The pattern
A new requirement doesn't fit the existing model, so you add a special case. Then another. Each one is a few lines. None of them, alone, would make a reviewer blink.
The problem is that complexity compounds. A codebase with ten special cases isn't twice as hard to reason about as one with five — it's much worse than that, because the special cases start interacting with each other in ways nobody designed for.
The usual fix people reach for is more abstraction: a plugin system, a
generic handler interface, a config-driven rules engine. Sometimes that's
the right call. Often it isn't.
Premature abstraction
Earned abstraction
A rule I've come to trust: if you can't point to two real call sites that need the abstraction, you're not abstracting — you're guessing.
None of this is about avoiding complexity entirely. Real problems are complex. The systems that stay maintainable aren't the ones that avoided complexity — they're the ones that kept it localized.
// A boundary that contains complexity instead of leaking it
export async function chargeCustomer(input: ChargeInput): Promise<ChargeResult> {
const customer = await getCustomer(input.customerId);
const plan = await getPlan(customer.planId);
return billingProvider.charge(customer, plan, input.amount);
}The function above hides a lot — retries, provider-specific quirks, currency handling — but none of that complexity has to leak into the fifteen call sites that just want to charge a customer. That's the actual goal: not less complexity, but complexity that stays where you put it.
A useful test
Before adding a layer, ask: "if I delete this in six months, how many files change?" If the answer is "just this one," it's a good boundary.
The final standard I hold designs to isn't "does this look clean today" — it's "would I want to be the person who has to change this in five years, without me around to explain it." That question rules out a lot of clever code, and it rules in a lot of boring, obvious code. Boring, obvious code is underrated. It's the reason some systems age well and others don't.