Migrating a Module to KMP With AI Doing the Grunt Work
A KMP migration is mechanical in volume and subtle in a handful of decisions. The workflow that separates the two — compiler-generated task list, AI-generated categories, human-made policy, AI-applied volume — and the three things the model reliably got wrong.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — A module migration to Kotlin Multiplatform is 90% mechanical edits and 10% decisions, and
every bug lives in the 10%. So separate them. Let the compiler produce the task list (move the source set
to commonMain, read the 400 errors). Let the model categorise those errors into nine root causes. Make
the nine policy decisions yourself. Then hand the model volume, one category per commit, with three
worked examples. Keep JVM tests green the entire way. Expect it to get kotlinx-datetime semantics wrong,
to invent expect/actual where a multiplatform library exists, and to hallucinate your Gradle wiring.
Why this task suits a model unusually well
Two properties, and they're the same two that make any AI task work.
The work is voluminous and mechanical. 180 java.time call sites is not 180 interesting problems; it's
one problem and 179 instances of typing.
Verification is cheap and objective. It compiles for iosArm64 or it doesn't. The JVM tests pass or they
don't. There's no "is this good code?" judgement call in the loop — which is exactly the condition that makes
generated code safe to accept quickly.
Contrast with a refactor of business logic, where the model can produce something that compiles, passes, and is wrong. Here, most wrongness is caught by a compiler you were going to run anyway.
Most of it. There's one category where that isn't true, and it's below.
Step 1: the compiler writes the task list
Do not ask a model what needs changing. It will miss things and invent others, and you'll have no way to know which.
Move the module's sources and let the build tell you:
src/main/kotlin → src/commonMain/kotlin
kotlin {
androidTarget()
iosArm64(); iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
}
}
}
> Task :data:pricing:compileKotlinIosArm64 FAILED
e: Unresolved reference: java
… 400 errors
Thirty seconds, and you have a task list that is complete, ground-truth and free. That property — exhaustive and machine-generated — is what makes the rest of this safe.
Step 2: the model categorises
400 errors is not 400 problems. Paste them:
Group these compiler errors by root cause.
For each: the JVM API involved, how many
occurrences, and how many distinct files.
Table only. Don't propose fixes yet.
What came back for :data:pricing:
| Root cause | Errors | Files |
|---|---|---|
java.time (Instant, LocalDate, Duration) |
180 | 34 |
android.util.Log |
60 | 41 |
java.util.UUID |
40 | 18 |
java.text.SimpleDateFormat / NumberFormat |
38 | 12 |
java.io.File |
24 | 6 |
BigDecimal |
22 | 9 |
@JvmStatic / @JvmOverloads |
18 | 15 |
Context-dependent resource lookup |
12 | 4 |
Misc (Charset, Regex flags) |
6 | 5 |
This is the step that turns an intimidating job into a plannable one. Nine decisions, not four hundred. It's also a task models are genuinely reliable at — grouping and counting text — with no opportunity to be subtly wrong about your product.
Step 3: you make the nine decisions
This is the part that is not delegable, and the reason is simple: each choice is a long-lived maintenance commitment, and the model has no stake in maintaining it.
What I chose, and why:
android.util.Log→ an injectedLoggerinterface. Notexpect/actual. The module shouldn't own a logging implementation at all; it should take one. The migration was an excuse to fix a dependency that was wrong before it was ever multiplatform.java.io.File→ Okio. A model happily writes you anexpect class FileSystem. Mine would be worse than Okio's in every respect and I'd own it forever.UUID→kotlin.uuid.Uuid. Common, no extra dependency.BigDecimal→ aMoneyvalue class overLongminor units. Which is what it should always have been, and is a genuine behaviour change requiring real thought about rounding. Not a mechanical edit — I did this one by hand.java.time→kotlinx-datetime. The big one, and the dangerous one.SimpleDateFormat→ explicit formatting, locale passed in rather than implicit.@JvmStatic/@JvmOverloads→ delete, add explicit overloads where a Swift caller needs them.Contextresource lookup → move out of the module. Strings belong in the presentation layer; this was a layering violation that the migration exposed. Several of these turn up in any migration, and they're a real side benefit.- Misc → case by case.
Notice how many of those are "the module was already wrong". A KMP migration is an unusually good audit of your layering, because the compiler enforces what your architecture doc merely requested.
Step 4: hand it volume, one category per commit
Now the model earns its place. The prompt shape that works:
Category: android.util.Log → the Logger interface.
Here are three files already converted: [paste].
Match them exactly.
Apply to these 41 files. Constraints:
- No behaviour changes beyond the logging call.
- Don't touch imports you don't need to.
- If a call site doesn't fit the pattern, list it
and skip it — don't improvise.
Output: unified diff.
Four things doing work:
- Three worked examples, not a description. The examples define the pattern precisely; adjectives don't.
- One category at a time. "Migrate the module" produces a diff nobody can review and that fails in six unrelated ways at once.
- An explicit escape hatch — list it and skip it. This is the highest-value line in the prompt. Without it the model improvises on the awkward 5%, and those improvisations are hidden inside a 41-file diff. With it, you get a short list of exactly the cases that need a human, which is precisely the output you want.
- One commit per category, so
git bisectstill means something when something breaks in week three.
Step 5: JVM tests stay green throughout
Move the existing tests to commonTest first, and run them on JVM after every single category:
./gradlew :data:pricing:jvmTest # after EVERY category
./gradlew :data:pricing:compileKotlinIosArm64 # last
You don't get to look at iOS until the thing you already had still works. A green JVM suite is a much stronger signal than a successful iOS compile, and it's ten times faster.
If your module doesn't have decent tests, that's the actual first task, and no amount of AI changes it. Migrating untested code is how a rounding error reaches production nine months later with no plausible suspect.
What it got wrong
Being specific, because the failure modes are consistent:
1. kotlinx-datetime is not java.time with different imports. The model translated it as if it were,
and produced code that compiled and was quietly wrong about timezones.
// java.time — an instant, then implicitly local
LocalDate.now()
// kotlinx-datetime — the timezone is mandatory,
// and that's the point
Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault())
.date
The JVM API let you be vague; the multiplatform one makes you say which timezone you meant. Wherever the original was vague, the model picked a plausible answer, and plausible is not the same as the one your business rules need — especially for billing periods and "day" boundaries.
Every date conversion got read by a human. It was the slowest category by a wide margin, and correctly so. This is the class of error the compiler cannot catch, which makes it the one place the cheap-verification argument breaks down.
2. It reached for expect/actual when a common solution existed. Twice, unprompted, it offered a
platform-split file API. A model doesn't feel the weight of a maintenance burden. Default position:
expect/actual is a last resort, and every one you add is a small permanent tax.
3. Gradle wiring was confidently invented. Plausible source-set names, plausible plugin blocks, occasionally two versions of the same convention mixed together. It has never seen your build. Write the Gradle by hand or copy it from a module that already works.
The honest accounting
- Mechanical edits: ~4 days → ~5 hours.
- The nine decisions: the same time they always take. Maybe an hour longer, because having them enumerated in a table made me think about each one properly instead of deciding in passing.
- Date semantics review: a full day, unchanged, and unchangeable.
- Total: a two-week job became about three days.
That's a real win and it is not a 10x. The mechanical half compressed enormously; the part that required knowing what the business means by "a day" compressed not at all. Any 10x claim about a migration is someone who didn't check the timezone code.
The rule
Give the model the volume. Keep the policy. Compiler produces the task list, model produces the categories, you produce the nine decisions, model applies them one category per commit with an explicit "skip and tell me" clause, JVM tests green throughout. The 10% you kept is where the bugs would have been — which is the whole reason you kept it.