6 min read

The Strangler Pattern on a 400k-Line Android App

Big-bang rewrites fail predictably: two codebases, a slipping switch-over date, and a v2 that never reaches feature parity. The strangler pattern replaces a large app incrementally behind flags — always one flip from working, always shipping value.

AndroidRefactoringLegacy CodeArchitectureEngineering Leadership
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Don't rewrite; strangle. Put an interface across a seam, build the new implementation behind it, route between them with a runtime flag, roll out gradually, then delete the old path. You're never more than one flag flip from a working app, every increment ships, and you never bet the company on a switch-over date. The hard part isn't technical — it's the discipline to keep deleting.

Why the big rewrite fails, predictably

The pitch is always the same: the old code is unmaintainable, so we'll build v2 alongside it and switch over when it's ready.

What actually happens:

  • Two codebases to maintain. The business still needs changes in v1 today, so you build every feature twice or freeze v1 and accumulate debt elsewhere.
  • Parity is asymptotic. v2 reaches 60% quickly, then discovers the five years of edge cases encoded in v1 — the fix for that one carrier, the workaround for that OEM's keyboard. Nobody documented them because they were bug fixes, not features.
  • The date slips, and slipping is unrecoverable. Every month of delay is a month of paying for two systems with no return.
  • Nobody can ship value in the meantime. That's what kills the project politically, long before it fails technically.

I've watched this from the inside more than once. The failure isn't engineering competence — it's the structure of the bet.

The strangler alternative

Named after the strangler fig, which grows around a host tree until the host is gone and the fig stands on its own. New code grows around old code until the old code is unreferenced and can be deleted.

The crucial property: the app works at every single point in the process.

Step 1: draw the seam

Pick a boundary you can put an interface across. On Android the natural seams, in order of ease:

  1. A screen. Self-contained, has a clear entry point, easy to route.
  2. A feature. A group of screens with one entry.
  3. A cross-cutting layer. Networking, storage — harder, because everything depends on it.

Start with screens, always. "We'll migrate the data layer first" sounds architecturally principled and means nothing ships for three months.

Choose a screen that is high traffic but low risk — heavily used enough that a 1% rollout gives you real signal quickly, but not checkout.

Step 2: an interface across the seam

interface SearchEntryPoint {
    fun open(context: Context, query: String)
}

class LegacySearchEntryPoint @Inject constructor() : SearchEntryPoint { /* starts the Fragment */ }

class ComposeSearchEntryPoint @Inject constructor() : SearchEntryPoint { /* new screen */ }

The interface is deliberately narrow — how you enter, what you pass in. Resist making it a shared abstraction over both implementations' internals; that couples them and defeats the purpose.

Step 3: route at runtime

@Provides
fun searchEntryPoint(
    flags: FeatureFlags,
    legacy: Provider<LegacySearchEntryPoint>,
    modern: Provider<ComposeSearchEntryPoint>,
): SearchEntryPoint = if (flags.isEnabled(NEW_SEARCH)) modern.get() else legacy.get()

Runtime, not build-time. A build-time flag means a rollback requires a release, which on Android is days. A runtime flag means a rollback is a dashboard toggle, and that difference is what makes gradual rollout safe enough to do casually.

Step 4: roll out gradually and watch the right metrics

1%, then 10%, then 50%, then everyone. At each stage, compare between the two cohorts:

  • Crash-free rate — the obvious one.
  • The screen's key business metric — search-to-result-tap, add-to-cart, playback starts. A rewrite that's stable but converts 8% worse is a failure, and you will not notice from crash data.
  • ANRs and startup — a new screen pulling in a new dependency graph can regress startup for everyone.

The metric comparison is the part teams skip, and it's the entire justification for gradual rollout. If you're not comparing cohorts, you're just shipping slowly.

Step 5: delete the old path — actually delete it

Once the new path is at 100% and has been stable for a couple of releases:

  • Remove the flag.
  • Delete the legacy implementation.
  • Delete its tests, its resources, its now-unused dependencies.

This step gets skipped, and skipping it is how you end up with a codebase containing four generations of everything and a flag system nobody can reason about. A strangler migration that never deletes is just a codebase that grew.

Put the deletion in the plan as its own ticket, with a date. Otherwise the team moves to the next feature and the old code becomes permanent.

The honest cost

For the duration, your codebase is worse: two implementations, a flag, a branch in the DI graph, and two paths to test. Engineers will find this uncomfortable and say so.

That's the price of never being broken. The rewrite's codebase looks cleaner throughout — right up until the switch-over that doesn't happen.

What makes it work

You're never more than one flag flip from a working app. Every increment ships and earns. There's no date the business is waiting on. And if the new approach turns out to be wrong — which happens — you find out at 1% of users, not eighteen months and one big-bang launch later.

That's not a technical advantage. It's a risk structure, and it's why this is the only approach I've seen finish on a genuinely large app.

Keep reading