โš™๏ธ Advanced Android Engineering and Emerging Technology

Migrating Legacy Android Apps to Modern Architecture

Legacy apps still earn money, so you can't stop and rewrite. The safest path is incremental: wrap, extract and replace piece by piece, shipping every step. Here's a migration plan that works on real codebases.

โœ๏ธ Yunok Tech ๐Ÿ“… Updated โฑ 3 min read ๐ŸŽฏ Advanced
On this page
  1. From where to where
  2. The strangler approach
  3. The migration sequence
  4. Example: wrapping a callback API
  5. Compose interop during migration
  6. Measure progress meaningfully
  7. Pitfalls
  8. Frequently asked questions
  9. Conclusion

Refactoring Legacy Apps covers safe, local refactoring techniques. This article is the bigger picture: a sequenced migration from a legacy stack to modern Android architecture, without a rewrite.

From where to where

LegacyModern target
JavaKotlin (new code first)
Logic in Activities/FragmentsViewModels + repositories (+ use cases where useful)
AsyncTask, callbacks, RxJavaCoroutines and Flow
Singletons, manual wiringHilt
XML layouts, findViewByIdCompose (screen by screen)
SQLiteOpenHelperRoom
SharedPreferencesDataStore
Single moduleModules by feature/layer when size justifies

The strangler approach

Strangler migration over time At first the legacy code handles nearly everything. Over successive releases, new modern components wrap and replace legacy parts feature by feature, until the legacy code is small and finally removed. LegacyRelease 1 LegacyModernRelease 4 LegacyModernRelease 10 ModernLater
Every release ships; the modern part grows as the legacy part shrinks.

The migration sequence

  1. Foundations: update Gradle, AGP and dependencies; enable Kotlin; add crash reporting and CI with tests. See CI/CD.
  2. Safety net: characterization tests around critical flows (login, checkout), plus a few UI smoke tests.
  3. Data layer first: introduce repositories that wrap existing data code, so screens stop talking to databases and network directly.
  4. ViewModels: move screen logic out of Activities/Fragments, starting with the most-changed screens.
  5. Async: replace AsyncTask/callbacks with coroutines at the repository boundary.
  6. DI: add Hilt and migrate singletons gradually.
  7. UI: new screens in Compose; migrate existing screens when they need significant changes.
  8. Storage: Room and DataStore migrations with data-migration tests.

Example: wrapping a callback API

Bridge legacy callbacks to coroutinesKotlin
// Legacy Java API: LegacyApi.fetchUser(id, Callback<User>)
class UserRepository @Inject constructor(private val legacy: LegacyApi) {

    suspend fun getUser(id: String): User = suspendCancellableCoroutine { cont ->
        val call = legacy.fetchUser(id, object : LegacyApi.Callback<User> {
            override fun onSuccess(user: User) { cont.resume(user) }
            override fun onError(t: Throwable) { cont.resumeWithException(t) }
        })
        cont.invokeOnCancellation { call.cancel() }   // propagate cancellation
    }
}
// Screens now call repository.getUser() from a ViewModel; LegacyApi can be replaced later.

Compose interop during migration

Compose inside an existing FragmentKotlin
class OrdersFragment : Fragment() {
    private val viewModel: OrdersViewModel by viewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, s: Bundle?) =
        ComposeView(requireContext()).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent { AppTheme { OrdersScreen(viewModel) } }
        }
}

Keep the Fragment and navigation for now, and replace the UI inside. Migrate navigation later. See XML to Compose with AI.

Measure progress meaningfully

  • Crash-free rate and ANR rate in migrated areas.
  • Lead time for changes in migrated vs legacy features.
  • Test coverage on critical flows.
  • Avoid vanity metrics like "% Kotlin files" as the main goal.

Pitfalls

Big-bang rewrite

Months without shipping, feature parity gaps, new bugs. Usually avoid.

Mixing refactors with features

Hard to review and roll back. Keep them separate.

Two patterns forever

Set a direction and a rule ("touched screens get migrated") so migration finishes.

Frequently asked questions

When is a rewrite justified?

Rarely: when the platform or product fundamentally changes, or the codebase is small. Even then, consider rewriting module by module.

Can AI speed up migration?

Yes, for explaining code, drafting conversions and tests. Verify carefully. See AI for Legacy Java.

Conclusion

Migrate incrementally: foundations and tests first, then repositories, ViewModels, coroutines, Hilt, Compose and modern storage, shipping every step and measuring outcomes that matter.

Next step: learn how senior engineers make these decisions in Engineering Trade-offs in Android.

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