7 min read

De-risking a Big Rewrite Nobody Asked For

The engineer proposing a rewrite is closest to the pain, so a flat no costs you the person. Convert the request into a one-page case with numbers, apply the three tests that actually justify a rewrite, and if it proceeds, use exit conditions and shipped value to keep it stoppable.

AndroidLegacy CodeLeadershipArchitectureEngineering Process
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Don't refuse the rewrite; ask for a one-page case with numbers. Half the time the author discovers it's a three-week refactor. A rewrite is justified by exactly three things: the ground is disappearing (EOL, forced API level), the cost of change is measurably rising, or it blocks a capability you can name. If it proceeds: strangler rather than a parallel build with a cutover date, an exit condition agreed before starting, user-visible value shipped every sprint, and one owner. A rewrite cancelled at 70% is the worst outcome available — two systems, permanently.

Start by not saying no

Someone senior says "we should rewrite the sync layer." No product manager has asked for it. There's no ticket, no deadline, no user-facing outcome.

Refusing flatly is the obvious move and it's expensive. The person proposing it is usually the one who touches that code most, and their frustration is data — the earliest signal you have that the cost of change is rising somewhere. Say no and you don't remove the problem, you remove the reporting of it.

So: convert the proposal into a document.

"Write me the one-pager. What does it cost us today, in numbers? What can't we do because of it? What's the smallest version that makes next quarter easier?"

This is not a stalling tactic and it shouldn't be used as one. It's a genuine request, and roughly half the time the answer that comes back is "actually, if we extract the retry logic and delete the two dead adapters, the rest is fine." Three weeks instead of two quarters, proposed by the person who'll do it. That's the best outcome on the table and it cost you one conversation.

The three tests

Only three things justify a rewrite. Everything else justifies a refactor.

1. The ground is disappearing. A dependency is end-of-life, a mandatory targetSdk bump breaks it, a vendor SDK is gone, an API level removed the thing it's built on. This is the easy case, because the deadline was set by someone outside the argument.

2. The cost of change is measurably rising. This is the one people feel and don't measure, and the measurement usually exists already:

  • Median lead time for a change touching that module, quarter over quarter.
  • Share of production incidents originating there.
  • Number of engineers who have merged a change to it in the last six months. (One is a bus factor. Zero is a museum.)
  • Time for a new joiner to make their first safe change in it.

A graph of any of these going the wrong way for three quarters is an argument. "It's a mess" is not — and forcing the translation is itself useful, because a cost you can't measure is a cost you can't prove got better afterwards either.

3. It blocks a named capability. Not "it would be cleaner." "We cannot ship offline mode without this" — specific, and someone outside engineering cares about the outcome.

What never justifies it

It's unfamiliar. It's not the framework you'd pick today. It uses RxJava and the team knows Flow. Someone who left wrote it. It has no tests but also no bugs.

That last one deserves a moment: old code that isn't changing and isn't crashing is doing its job. Ugly and stable is a completely acceptable state for software to be in, and the fact that reading it is unpleasant is not a business cost.

If it proceeds: five rules

1. Strangler, not a parallel build. Route traffic feature by feature through a new implementation behind the same interface, with the old one still running. The reason isn't purity. A parallel rewrite with a cutover date doesn't fail at the cutover — it fails in month five, when the old system has gained four features the new one hasn't, and the gap is widening faster than it closes.

2. Agree the exit condition before you start. Written down, while everyone is still optimistic and therefore honest:

If the checkout flow isn't running on the new sync layer by week 6, we stop, keep both, and write up what we learned.

Nobody can define a stopping point at week 9. Sunk cost has a grip by then and every conversation becomes "we're nearly there" — the sentence that precedes another quarter. Decide it while it's cheap.

3. Ship user-visible value from inside the rewrite, every sprint. A rewrite with no external output is invisible to everyone funding it, and the first time priorities shift it gets paused. Paused at 70% is the worst possible state: two systems, twice the surface, no owner enthusiastic about either, and every future change costing double. The failure mode of rewrites isn't going badly — it's stopping halfway.

If nothing user-visible can come out of the first six weeks, that's a strong signal the increment is too big.

4. Assume the old system keeps changing. It will. Product doesn't stop for you. If the plan requires a feature freeze, the plan requires something you cannot have, and the honest response is to shrink the scope until it survives a moving target.

5. One owner. Not a task force, not "everyone contributes 20%." Rewrites need someone who holds the whole map, notices when the increment is drifting, and can say "this part isn't worth doing." Distributed ownership of a rewrite produces a new system with the same inconsistencies as the old one, plus a migration.

Instrument both, from day one

The question "is the new one actually better?" must be answerable with data, not vibes:

// Same metrics, both paths, tagged by implementation
analytics.timing("sync.duration", elapsed, tags = mapOf("impl" to impl))

Latency, error rate, crash rate, cold-start contribution — side by side. Two things follow from this. You can prove the win, which is how you get support for the next increment. And you find out when the new implementation is worse, which happens more often than the retrospectives admit, and much earlier.

The sentence to keep

"What's the smallest version of this that makes next quarter easier?"

It converts a rewrite into a sequence of increments, and a sequence can be stopped after any element with the value already banked. That property — stoppable at every point — is what "de-risked" actually means. Everything else in this post is a consequence of it.

And sometimes the answer is no

Not every rising cost is worth paying to fix. If the module is unpleasant but stable, changes rarely, and the rewrite costs a quarter you'd rather spend on something users notice — say that, plainly, with the reasoning:

We're choosing to keep this cost. Here's what would change my mind: if lead time in that area goes above X, or if it blocks the offline work in Q3, we revisit.

A clear no with a named trigger is respectful and actionable. A slow no — "let's revisit next quarter", forever — burns the person's goodwill and their trust in the process, and they'll stop bringing you the early signals.

The rule

Rewrites don't fail because they're hard. They fail because they can't be stopped. Make it incremental, make it measurable, agree in advance when you'd walk away — and the question stops being "should we rewrite this?" and becomes "what's the next increment worth doing?"

Keep reading