Workflow Architecture

Java to Kotlin: The Converter Does Syntax, the Agent Does Semantics

David Guzenburg/ / 9 min read

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.

KotlinJavamigrationnullability

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.

Deserialisation defeats constructor analysis

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.

TransformationSafe once nullability is settled
Class with only data → data classGives equals, hashCode, copy for free
var never reassigned → valThe compiler proves it
Getter-only method → propertyReads better; check Java callers first
Abstract class + fixed subclasses → sealedEnables exhaustive when
Static utility class → top-level functionsAdd @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 issueSymptomGuard
Java passes null to a Kotlin non-null parameterNullPointerException at the boundary, not at the callerKeep Java callers annotated; test the boundary
Platform types (String!)Kotlin cannot tell; no warningAnnotate the Java side, or assert at entry
Kotlin default argumentsInvisible from Java@JvmOverloads where Java calls it
Kotlin properties from JavaAccessor naming differs@JvmField or @get:JvmName
Checked exceptionsKotlin 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

Takeaway

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.

Keep reading
Workflow Architecture

Designing Migration Instructions: Modernising Without Changing Behaviour

Why framework migrations go wrong with agents, splitting mechanical from semantic work, the instructions that preserve behaviour, and batching so review stays possible.

Workflow Architecture

REST to GraphQL: Design the Schema Yourself, Delegate the Resolvers

Why an agent-generated GraphQL schema ends up shaped like your endpoint list, the N+1 pattern it will write by default, and the field-level auth check the migration tends to lose.

Codex vs Claude

Fleet-Wide Maintenance: Generating Twenty Pull Requests Is the Easy Part

Batch updates across many repositories are where agents change what a small team can attempt. The constraint is merging, not generating, and the same change is usually three different changes. Surveying, piloting, and measuring repositories migrated.

Workflow Architecture

A DSL Fine-Tuning Pipeline: Validator First, Training Script Second

The QLoRA pipeline for a proprietary DSL once you have established the case: compiler-backed dataset validation, AST mutation for expansion, error-correction pairs, and evaluating on parse rate rather than loss.

← Layering Agent Instructions: Personal, Project and Path-Scoped Rules  ·  Finding Technical Debt: Parse for Candidates, Then Judge Them →

All workflow architecture articles  ·  Every article