Refactoring Legacy Java to Kotlin With an AI Pair — Safely
The risk in a Java→Kotlin migration isn't syntax, it's the nullability contract you invent while cleaning up the conversion. How to use AI for the mechanical work while keeping every nullability decision evidence-based and human-owned.
Hessam Rastegari
Senior Android Developer · 12 years shipping Android
TL;DR — Automated conversion handles syntax. The danger is what happens next: Java's platform
types become String?, and engineers "clean up" the resulting !! by guessing what's really
nullable, across hundreds of call sites. Use AI for the mechanical parts — dependency ordering,
call-site enumeration, behavioural-diff review — and keep every nullability decision backed by
evidence you actually read.
Why the syntax isn't the risk
IntelliJ's converter is good. It produces compiling Kotlin in seconds, and for straightforward classes the output is close to what you'd write. That's the easy 80%.
The risk lives in platform types. Java has no nullability in its type system, so an unannotated
field arrives in Kotlin as String! — "could be either, compiler won't check". The converter
conservatively renders these as nullable, and you're left with:
// After conversion — technically correct, unpleasant to read
val name: String? = user.name
textView.text = name?.uppercase() ?: ""
if (user.address!!.city != null) { /* ... */ }
The natural next step is to "tidy this up" by declaring what's actually non-null. That step is where
production breaks — because you're reconstructing an implicit contract from memory, and a !! in the
wrong place is an NPE that the Kotlin compiler now guarantees you won't be warned about.
Step 1: annotate the Java first
The highest-value move happens before any conversion. Add nullability annotations to the Java source, so the conversion carries real information instead of a coin flip:
public class User {
@NonNull private final String id;
@Nullable private final Address address;
}
This is tedious, mechanical, and well within what an AI pair does reliably — provided you ask it to justify each one:
For each field in this class, list every assignment site and constructor path. Then propose
@Nullableor@NonNull, and cite the assignments that justify it. Mark any field where the evidence is incomplete.
You are reviewing a table of evidence, not accepting a verdict. Fields marked incomplete are the ones you investigate yourself — and in my experience that's where the genuine surprises are.
Step 2: migrate leaves, not the god class
The instinct is to start with the most important class. That's backwards: it has the most inbound dependencies, so its converted API churns every call site at once.
Order by fewest dependencies first. Building that order is mechanical graph work from the imports — exactly what to hand off:
From these files, build a dependency graph based on imports and type references. Give me a migration order from leaves inward, and flag any cycles.
Cycles are worth knowing about early. They usually mean two classes need to move in one commit, which is a scheduling fact, not a code fact.
Step 3: ask what behaviour changed, not just what compiles
The conversions that bite are semantic, not syntactic:
equals/hashCode— converting a class to adata classchanges equality from identity to structural. If instances were used as map keys or compared with==, behaviour changes silently.- Overloads → default arguments — usually fine, but changes which method Java callers bind to, and reflection- or DI-based construction can stop resolving.
IntegervsInt— boxing differences mean==on boxed values in Java (identity) becomes structural in Kotlin. Occasionally that fixes a latent bug; occasionally it changes a cache hit into a miss.- Static initialisation order —
companion objectinitialises differently from Java statics.
A prompt that earns its place:
Compare the original Java and this Kotlin conversion. List only behavioural differences — cases where the same inputs produce a different result or a different side effect. Ignore style. For each, give the concrete call that would behave differently.
Step 4: let the tests carry the risk
Migration is one of the rare times a low-value test becomes a high-value test. A characterisation test doesn't need to be elegant — it needs to pin current behaviour before you touch anything:
@Test fun `formats an empty address the way it always has`() {
assertEquals("", UserFormatter().addressLine(User(id = "1", address = null)))
}
Ask AI to generate these from the Java, before conversion. It's genuinely good at enumerating edge cases in an existing implementation, and the tests are throwaway-cheap. If a test fails after conversion, you've found real drift.
The rule
AI writes the mechanical parts. You own every nullability decision.
A !! that an AI wrote and you approved is still your NPE at 3am, with your name in the blame. The
workflow above is designed so the model does the work that's tedious and verifiable, and you do the
work that requires knowing what the system is actually supposed to guarantee.
That division — mechanical to the machine, contractual to the human — is the one I'd apply to any AI-assisted refactor, not just this one.