๐Ÿ›๏ธ Android Architecture and Clean Code

How to Refactor Legacy Android Applications

Legacy Android apps often have 3,000-line Activities, AsyncTasks, no tests and code nobody dares touch. A rewrite is tempting and usually a mistake. Here's a step-by-step approach that improves the code while the app keeps shipping.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Advanced
On this page
  1. Principles of safe refactoring
  2. A step-by-step playbook
  3. Characterization tests
  4. Extracting a ViewModel from a god Activity
  5. Creating seams
  6. Prioritisation examples
  7. Common mistakes
  8. Frequently asked questions
  9. Conclusion

"Let's rewrite it properly" is the most expensive sentence in software. While the rewrite is built, the old app still needs features and fixes, the rewrite chases a moving target, and years of hidden edge-case handling get lost. Incremental refactoring keeps delivering value and reduces risk with every step.

Principles of safe refactoring

  • Refactoring changes structure, not behaviour. Keep these in separate commits from feature changes.
  • Get a safety net first: tests, or at least reproducible manual checks and crash monitoring.
  • Small steps that can be released independently.
  • Prioritise by pain: refactor what you change often or what crashes most.
Strangler fig refactoring over time Release 1: mostly legacy code with a small new module. Release 2: more features moved to new architecture. Release 3: legacy shrinks to a small remnant. New code grows around and gradually replaces the old system while the app keeps shipping. Release Nlegacy N + 3new N + 8modern
The strangler fig pattern: new code grows around the old and replaces it piece by piece, with every release still shippable.

A step-by-step playbook

  1. Map the app. List screens, entry points, background work and third-party SDKs. Find the top crashes and the most-edited files in Git history.
  2. Update the build. Recent AGP, Gradle and Kotlin, a version catalog, R8 enabled. Many later steps depend on modern tooling.
  3. Add characterization tests around code you'll touch. They capture current behaviour, even if it's odd.
  4. Extract logic out of Activities into ViewModels and plain classes, without changing behaviour.
  5. Introduce repositories around scattered network and database code.
  6. Replace deprecated async (AsyncTask, raw threads, deep callback chains) with coroutines, one feature at a time.
  7. Write new code in Kotlin, and convert old files when you touch them.
  8. Build new screens in Compose, and migrate old screens when they're redesigned.

Characterization tests

Lock in existing behaviour before changing itKotlin
class LegacyPriceCalculatorTest {
    // We don't know if these results are "right"; we record what the app does today.
    @Test fun `legacy rounding for discounted items`() {
        val calc = LegacyPriceCalculator()
        assertEquals(84.0, calc.finalPrice(99.0, discountPercent = 15), 0.0001)   // floors to rupees: keep it
        assertEquals(0.0, calc.finalPrice(10.0, discountPercent = 120), 0.0001)   // clamps: keep it
    }
}

Extracting a ViewModel from a god Activity

Before (simplified)Kotlin
class OrdersActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_orders)
        object : AsyncTask<Void, Void, List<Order>>() {          // deprecated; leaks on rotation
            override fun doInBackground(vararg p: Void?) = Api.getOrders()
            override fun onPostExecute(result: List<Order>) { adapter.submitList(result) }
        }.execute()
    }
}
After: same behaviour, testable structureKotlin
class OrdersViewModel(private val repository: OrdersRepository) : ViewModel() {
    private val _orders = MutableStateFlow<List<Order>>(emptyList())
    val orders: StateFlow<List<Order>> = _orders.asStateFlow()
    init { viewModelScope.launch { _orders.value = repository.getOrders() } }
}

class OrdersActivity : AppCompatActivity() {
    private val viewModel: OrdersViewModel by viewModels { OrdersViewModelFactory(appContainer) }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_orders)              // UI unchanged
        lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.orders.collect(adapter::submitList) }
        }
    }
}

The XML and adapter stay the same, so the UI is untouched. Later, the screen can move to Compose with the same ViewModel.

Creating seams

Legacy code often calls static singletons (Api.getOrders()). Wrap them behind interfaces so new code can be tested:

Seam around a static APIKotlin
interface OrdersRepository { suspend fun getOrders(): List<Order> }

class LegacyOrdersRepository : OrdersRepository {
    override suspend fun getOrders() = withContext(Dispatchers.IO) { Api.getOrders() }   // existing call
}

Prioritisation examples

Top crash in checkout

Refactor checkout first: tests, extract a ViewModel, fix the crash, then continue modernising that area.

Feature work in profile

Combine it: modernise profile while building the requested feature, in separate commits.

Stable, untouched settings screen

Leave it. Old but working code that never changes doesn't need refactoring.

Common mistakes

  • Big-bang rewrites, or long-lived refactoring branches.
  • Mixing behaviour changes with refactoring in one PR.
  • "Fixing" odd legacy behaviour that users or other systems depend on, without confirming first.
  • Introducing every modern library at once (Hilt, Compose, Navigation, KMP) and overwhelming the team.

Frequently asked questions

When is a rewrite justified?

Rarely: when the platform or product has fundamentally changed, the codebase is small, or the old code genuinely can't be built and maintained. Even then, consider rewriting incrementally.

Can AI tools help?

Yes, for explaining unfamiliar code, drafting characterization tests and converting Java to Kotlin, with careful review. See Using AI to Understand Legacy Java Code.

How do I convince stakeholders?

Tie refactoring to measurable outcomes: crash rate, time to deliver features in an area, or onboarding time. Then show progress release by release.

Conclusion

Refactor legacy Android apps incrementally: build a safety net, extract ViewModels and repositories, replace deprecated async with coroutines, and adopt Kotlin and Compose as you touch code, all while shipping every release.

Next step: scale these practices across teams in Scaling an Android Codebase Across Multiple Teams.

Official references

๐Ÿ“ฑ Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps โ†’

Ready to build real Android applications?

Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp