The Pragmatic Migration: Moving from RxJava & XML to Coroutines & Compose
Spearheading a large-scale architectural migration from legacy XML and RxJava to Jetpack Compose and Kotlin Coroutines is a massive undertaking.
Technically, the challenges are complex. But as a Team Lead, you quickly realize the friction is rarely just about the code — it is organizational. How do you modernize a massive, deeply entrenched codebase when the business still demands new features and bug fixes every single sprint?
The answer is never a “feature freeze.” And unless you have unlimited time and budget, a complete rewrite from scratch is a fantasy.
The only sustainable path forward is incremental, strategic replacement. Here is the blueprint for executing a seamless, live migration without missing a release cycle.
1. Tidy First, Feature Second
When migrating an app at scale, you have to shift the team’s mindset from “patching” to “preparing.” This is heavily influenced by Kent Beck’s Tidy First? philosophy: change the structure of the code to make the behavior change easier, then change the behavior.
Instead of migrating an entire module at once, empower your engineers to tidy up the specific area they are working in just before adding a new feature.
If a developer is tasked with adding a new validation warning to an old XML screen, they shouldn’t just jam another TextView into the messy ConstraintLayout. They should isolate the section, wrap it in a ComposeView, and build the new component using modern standards.
Leave the screen slightly more modernized than you found it. Over dozens of sprints, these micro-migrations compound.
2. The RxJava to Coroutines Quarantine Zone
You cannot swap out a deeply entrenched RxJava reactive layer overnight. The data layer is the nervous system of your app; ripping it out all at once guarantees critical regressions.
The secret to a smooth transition is establishing a strict “Quarantine Zone” using the kotlinx-coroutines-rx2 (or rx3) interoperability library.
Define a clear coding standard for the team: All new ViewModel and Domain logic must be written strictly using Coroutines and Flow. When this new modern code needs to interact with your legacy RxJava repositories, you convert the streams right at the boundary.
Kotlin
// Legacy Repository (Do not rewrite yet)
interface UserRepository {
fun getUserProfile(id: String): Single<UserProfile>
fun observeUserState(): Observable<UserState>
}// Modern ViewModel (Using Interoperability)
class ProfileViewModel(
private val userRepository: UserRepository
) : ViewModel() { // Bridging the Single to a suspend function
suspend fun loadProfile(id: String) {
try {
val profile = userRepository.getUserProfile(id).await()
// Handle profile data
} catch (e: Exception) {
// Handle error
}
} // Bridging the Observable to a Flow
val userState: Flow<UserState> = userRepository.observeUserState().asFlow()
}
This approach allows you to confidently build modern UI and domain layers on top of legacy data layers. Once the top layers are migrated, you can slowly rewrite the repositories from RxJava to Coroutines backward, without breaking the UI.
3. The UI Strangler Fig Pattern (Top-Down vs. Bottom-Up)
Migrating XML to Compose requires a dual approach, often referred to as the Strangler Fig pattern. You define clear standards for when the team should use a Bottom-Up versus a Top-Down approach.
Get Lucky Raj Gurjar’s stories in your inbox
Join Medium for free to get updates from this writer.
Bottom-Up (Compose in XML):
Use this when you are adding new features to an existing, complex XML screen that is too risky to rewrite entirely.
XML
<LinearLayout>
<TextView android:text="Legacy Header" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/new_feature_compose_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>Top-Down (XML in Compose):
Use this when you are building a brand new screen in Compose, but you need to reuse a highly complex, custom legacy XML view (like a custom video player or a complex chart) that hasn’t been rewritten yet.
Kotlin
@Composable
fun ModernScreen() {
Column {
Text("Modern Header in Compose")
// Hosting a legacy custom view inside Compose
AndroidView(
factory = { context ->
LegacyCustomChartView(context).apply {
// setup
}
},
update = { view ->
// update data
}
)
}
}4. Apply the “Rule of Least Power” to Guard the Architecture
A migration falls apart when the team isn’t aligned. You can’t just tell developers, “Go use Compose and Flow now,” because everyone will implement it differently. The “messy middle” of a migration is where architecture tends to degrade fastest.
To prevent this, strictly enforce the Rule of Least Power. This software engineering principle states that you should choose the least powerful language or tool suitable for a given purpose.
In the context of this migration:
- Don’t use a
SharedFlowif a simpleStateFlowwill do. - Don’t launch a new Coroutine if a simple suspend function map is enough.
- Don’t build a complex custom Compose layout if a standard
ColumnorRowachieves the design.
Write a clear roadmap detailing exactly how state, dispatchers, and testing should be handled. When the guardrails are rigid and the tools are kept as simple as possible, code reviews become faster, and the architecture evolves predictably.
Final Thoughts
Architectural migrations are marathons, not sprints. By adopting a “tidy first” mindset, leveraging interoperability boundaries, and applying the Rule of Least Power to your coding standards, you can completely transform a legacy codebase while keeping your product managers happy and your release cycles intact.









