5 min read

Using AI to Write the Migration You're Scared Of

The migration everyone agrees on and nobody starts isn't hard, it's large — and largeness is its own risk. AI makes the two expensive parts cheap: taking inventory and repeating your decision at volume. The four-step workflow and the two rules that keep it safe.

AIRefactoringAndroidMigrationEngineering Workflow
HR

Hessam Rastegari

Senior Android Developer · 12 years shipping Android

TL;DR — Big mechanical migrations stall because nobody can size them, not because they're difficult. Use AI for the two costly parts: inventory (find and classify every occurrence, with counts) and repetition (apply a transformation you designed, at volume). Migrate one instance of each pattern by hand first, do the genuinely hard cases yourself, ship one pattern per PR, and write characterisation tests from the old code before changing anything.

Why these migrations stall

LiveData to Flow. Dagger to Hilt. Views to Compose. kapt to KSP. Every codebase has one, everyone agrees it should happen, and it doesn't — for two years, while the cost of not doing it compounds.

The reason isn't difficulty. Any individual conversion is a task a mid-level engineer does correctly in ten minutes. The reason is size, and specifically that nobody can answer "how long will this take?" without doing most of the work first. An unsizeable task can't be scheduled, so it never is.

That's the actual bottleneck, and it's the one AI removes.

Step 1: inventory before touching anything

The first prompt isn't "migrate this". It's:

Search this codebase for every LiveData usage. Group them by pattern:
 - simple observe in a Fragment/Activity
 - MediatorLiveData
 - Transformations.map / switchMap
 - observeForever
 - LiveData exposed from a Repository

For each group: a count, a representative example, and whether the
conversion is mechanical or needs a judgement call. Flag anything where
the semantics of Flow differ from LiveData.

You now have something you didn't have before: a number with structure. "200 files" is frightening and unschedulable. "180 trivial, 15 medium, 5 needing design decisions" is a two-sprint plan you can defend in planning.

That last flag matters. LiveData is lifecycle-aware and conflated; Flow is neither by default. A model asked explicitly to flag semantic differences will point at the observeForever calls and the places where losing conflation changes behaviour — the exact spots where a naive conversion introduces bugs.

Step 2: migrate one of each pattern by hand

This is the step you must not delegate. For each pattern group, do one conversion yourself and decide the target shape:

// Before
val user: LiveData<User> = repository.getUserLiveData(id)

// After — your decision: StateFlow, WhileSubscribed(5s), explicit initial value
val user: StateFlow<User?> = repository.observeUser(id)
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)

Those choices — StateFlow over SharedFlow, the timeout, the initial value — are architecture. They should be made once, deliberately, by a person who will live with them.

Step 3: hand the model your own example

Now the repetition, anchored to your decision:

Here's how I converted this ViewModel: [before/after]. Apply exactly the same transformation to these twelve files. Keep the same stateIn parameters. If a file doesn't fit the pattern, don't convert it — tell me why.

That last sentence is what makes this safe. Without it, files that don't fit get forced into the shape anyway, and you find out in code review at best.

This is where the leverage is. You've made one decision and applied it a hundred and eighty times, consistently — which is more consistency than a team of four doing it manually would achieve.

Step 4: do the hard ones yourself

The five flagged cases are hard for a specific reason: they encode a decision nobody wrote down. Why does this one use observeForever? Probably because something needed to observe outside a lifecycle, and there's a reason, and it's in someone's head.

That's exactly the situation where a model will produce something confident and plausible that silently drops the reason. Do these by hand, and while you're there, write down why — the comment is the real deliverable.

Two rules that keep it safe

One pattern per PR. Not one file, not the whole migration. A PR that converts all 180 simple observe calls is reviewable in a way that a PR mixing five patterns isn't, and it's revertable and bisectable as a unit.

Characterisation tests first, written from the old code before any conversion:

@Test fun `user emits null then the loaded user`() = runTest {
    val emissions = viewModel.user.take(2).toList()
    assertEquals(listOf(null, expectedUser), emissions)
}

These aren't elegant and they're throwaway. Their entire job is to fail if behaviour drifts, and generating them from existing code is another thing AI is genuinely good at.

The reframe

AI doesn't make the migration safe — your tests and your review do. It makes the migration small enough to start.

The inventory turns an unsizeable fear into a schedule. The repetition turns two sprints of tedium into two days of review. Both attack the reason the work never began, which is the only reason it's still on your backlog.

Keep reading