Java to Kotlin: The Converter Does Syntax, the Agent Does Semantics
Run the converter and you get Kotlin that compiles and reads like Java. The interesting work is deciding what the original meant in the places Java could not say it.
Translation is not the hard part
Java to Kotlin looks like the ideal automated migration: same JVM, same libraries, official conversion tooling, and a mechanical mapping for most syntax. Run the converter, get Kotlin, done.
What you get is Kotlin that compiles and reads like Java. Every field nullable, every class final-by-accident or open-by-habit, no data classes, no scope functions, and null handling that translated Java's absence of it into Kotlin's most defensive form. It is correct and nobody wants to maintain it.
The interesting work is not translation. It is deciding what the Java code meant in the places where Java could not say.
Nullability is where the semantics live
A Java field is nullable unless annotated, and most Java is not annotated. The converter has to choose, and it chooses defensively.
class Customer(
val id: String?, // never actually null
val email: String?, // never actually null
val phone: String?, // genuinely optional
val deletedAt: Instant?, // genuinely optional
) {
fun displayName(): String? =
email?.substringBefore("@")?.replaceFirstChar { it.uppercase() }
}
data class Customer(
val id: String,
val email: String,
val phone: String? = null,
val deletedAt: Instant? = null,
) {
val displayName: String
get() = email.substringBefore("@").replaceFirstChar { it.uppercase() }
}
The second version is the point of migrating. Two fields moved from "possibly null" to "never null", which propagates through every caller — no safe calls, no elvis operators, no defensive branches for a case that cannot happen.
Deciding which fields are genuinely nullable is judgement informed by evidence, and it is exactly what an agent can do well when given the evidence.
Give it the evidence, not the guess
Here is a converted Kotlin class with every field nullable, plus:
- the original Java source
- every construction site (from the symbol index)
- the database schema for the backing table
- existing @Nullable / @NotNull annotations, if any
For EACH field, decide: genuinely nullable, or nullable only
because Java could not express otherwise?
Evidence to weigh, in this order:
1. Database NOT NULL constraints — strongest signal
2. Every construction site passes a non-null value
3. No null check exists at any read site
4. Jackson/JPA annotations implying required
Output a table: field, decision, evidence, confidence.
Mark anything you are unsure about as UNCERTAIN. Change no code.
The ordering matters. A database NOT NULL constraint is nearly
conclusive; "no construction site passes null" is strong but incomplete,
because reflection and deserialisation bypass constructors. Asking for evidence
per decision lets you check the reasoning rather than the conclusion.
A field that is non-null at every construction site can still be null when Jackson populates it from JSON missing that key. If the class crosses a serialisation boundary, the constructor evidence is weaker than it looks — check the schema or the API contract instead.
The staged sequence
1. CONVERT the tool, not the agent. Mechanical, deterministic.
2. COMPILE it must build and tests must pass before anything else.
Commit here — this is your rollback point.
3. NULLABILITY one class at a time, evidence table first, then apply.
Compile + test after each. Commit.
4. IDIOM data classes, val over var, scope functions, sealed
hierarchies. Behaviour-preserving only.
5. VERIFY full suite plus the interop check below.
Committing after step two is what makes the rest tractable. If step three goes wrong on a class, you revert one commit rather than unpicking a translation and a redesign that arrived together.
Idiom, after nullability, never before
The ordering matters and is easy to get backwards. Applying idiomatic Kotlin before resolving nullability produces scope functions and elvis chains wrapped around uncertainty you have not removed yet — and then the nullability pass has to unpick them.
| Transformation | Safe once nullability is settled |
|---|---|
Class with only data → data class | Gives equals, hashCode, copy for free |
var never reassigned → val | The compiler proves it |
| Getter-only method → property | Reads better; check Java callers first |
Abstract class + fixed subclasses → sealed | Enables exhaustive when |
| Static utility class → top-level functions | Add @JvmName if Java calls it |
The fourth is the one worth prioritising, because a sealed hierarchy plus an
exhaustive when is a guarantee the Java original could not express
at all — the compiler now fails when someone adds a subclass and forgets
a branch.
Interop is where the mixed codebase bites
During migration, Java and Kotlin call each other, and the boundary has sharp edges that neither compiler fully guards.
| Boundary issue | Symptom | Guard |
|---|---|---|
| Java passes null to a Kotlin non-null parameter | NullPointerException at the boundary, not at the caller | Keep Java callers annotated; test the boundary |
Platform types (String!) | Kotlin cannot tell; no warning | Annotate the Java side, or assert at entry |
| Kotlin default arguments | Invisible from Java | @JvmOverloads where Java calls it |
| Kotlin properties from Java | Accessor naming differs | @JvmField or @get:JvmName |
| Checked exceptions | Kotlin does not declare them; Java callers cannot catch | @Throws on anything Java calls |
The !! operator deserves its own note. Mechanical conversion
produces them liberally, and every one is a nullability question that was
deferred rather than answered. A codebase that finishes migration with two
hundred of them has translated Java's null problems into Kotlin syntax without
solving any of them.
Row one produces the most confusing production failures, because the stack trace points at Kotlin code that looks correct. The parameter was declared non-null and Java handed it null anyway — nothing in either compiler prevented it.
What to verify before merging each batch
- Behaviour unchanged. Tests pass with zero test-file edits. Same rule as any migration: if a test needed changing, behaviour changed.
- No
!!operators. Every one is a nullability decision that was dodged. Grep for them and treat each as a review item. - Public API compatible if anything external depends on it — a binary-compatibility check catches accidental signature changes.
- No
UNCERTAINfields resolved silently. Anything the evidence table flagged uncertain should have a human decision attached, not a quiet choice.
Let the tool do the syntax and the agent do the semantics — the value is in
recovering nullability intent that Java could not express, and that needs
evidence from the database, the call sites and the annotations. Convert,
commit, then decide nullability one class at a time. Watch the interop
boundary, and treat every !! as a decision someone avoided.