Deep Engineering #39: Ron Veen on Java-to-Kotlin migration without the rewrite
How to modernize a large JVM codebase layer by layer, without freezing product velocity or introducing semantic risk
Building Production-Ready Agent Systems with MCP
A full-lifecycle workshop on designing, securing, evaluating, and optimizing agent systems that hold up in production.
Online: March 29 | 10:30 AM - 4:00 PM EST
✓ 2-for-1 deal: Bring a colleague, get 2 passes for the price of 1.
✍️ From the editor’s desk,
Welcome to the 39th issue of Deep Engineering!
In Issue #34 we spoke with José Dimas Luján Castillo and Ron Veen, authors of Kotlin for Java Developers, to unpack practical decision lenses for modernizing JVM systems. In the past week, JetBrains released Kotlin 2.3.20 with a simpler Maven setup and compiler-plugin improvements, and OpenAI added GPT-5.4 mini to Codex as a faster, lighter model for coding tasks. The language stack is evolving, and so are the AI tools teams now use to change production code at speed.
But how can a large Java codebase be migrated without turning modernization into a rewrite, a slowdown, or a source of subtle semantic risk? We are collaborating again with Ron Veen, whose article answers that by treating migration as a controlled, layer-by-layer transformation—one where Kotlin’s gains in null safety and expressiveness matter, but where the harder engineering work lies in framework compatibility, review discipline, and keeping humans accountable while AI accelerates the mechanics.
Ron Veen is a JVM veteran with over 20 years of experience across enterprise systems, from mainframes to modern microservices. Veen is an Oracle Certified Java Programmer and certified Sun Business Component Developer. As a Lead Developer at Team Rockstars IT and a regular international speaker, Veen brings both hands-on experience and architectural perspective.
Let’s get started
Mike Scientific Repo: Tons (> 1200) of FREE Math, AI, Machine Learning Books, All reviews (almost 600), presentations and more learning resources: https://github.com/merlihson/scientific-resources
Science and AI with Mike: Telegram channel - fresh deep learning paper reviews and free books: https://t.me/science_and_ai_with_mike_english/
From Java to Kotlin at Scale – with AI in the Loop
by Deepayan Bhattacharjee with Ron veen
Most large engineering organizations are sitting on a paradox.
Their core systems – often millions of lines of Java – are stable, battle-tested, and deeply valuable. But they’re also increasingly out of step with how modern teams want to write software: safer by default, more expressive, and easier to reason about. Kotlin promises exactly that. The problem is not why to migrate, it’s how to do it without freezing product velocity or introducing subtle, system-wide risk.
This is where Ron Veen’s migration story becomes relevant. Instead of treating Java-to-Kotlin as a one-shot rewrite or a tooling exercise, he approaches it as a controlled, layer-by-layer transformation – reconstructed on an OpenMRS codebase to mirror real-world enterprise constraints. The twist: AI is not just assisting but actively accelerating the migration – while humans remain accountable for every semantic decision.
What emerges is not just a migration playbook, but a shift in how we think about large-scale code evolution. The bottleneck is no longer writing code – it’s governing correctness at speed. And in that world, success depends less on conversion tools and more on how teams handle semantics, frameworks, and oversight under AI-assisted velocity.
To this end, today’s issue focuses on three action points for senior engineers and engineering leaders:
Kotlin migrations succeed when you treat them as a semantic change (types, nullability, and idioms), not a mechanical translation.
Framework constraints (Spring/Hibernate/JPA) are where migrations usually get stuck – unless you plan for Kotlin’s “final by default” stance and use the right compiler plugins.
AI can compress weeks of conversion work into days, but it shifts the hard work to governance: review strategy, testing cadence, and repeatable patterns.
1: Treat migration as a semantic refactor, not a syntax swap
One of the easiest mistakes in a Java-to-Kotlin migration is to treat it as a tooling problem. Run the IDE converter, clean up a few warnings, and move on. Veen’s work makes it clear why that approach fails: working Kotlin isn’t the goal – idiomatic Kotlin is. And the gap between the two is where most of the risk, and value, lies.
Veen’s core point is easy to miss if you’ve only used IDE “convert Java to Kotlin” tools: working Kotlin is not the goal – idiomatic Kotlin is. The difference shows up most sharply in nullability, because Kotlin forces you to say what can be null and what cannot.
That’s also why Kotlin’s Java interop rules matter for incremental migration. Kotlin can call existing Java “naturally,” but when Kotlin reads Java types, it must assume any reference might be null. Kotlin handles this by using platform types, which relax compile-time null checks and can still fail at runtime if your assumptions are wrong. For leaders, the practical implication is: your migration policy must include a nullability strategy (what becomes T, what becomes T?, and when you tighten types).
Veen’s entity conversion example shows why this is a worthwhile discipline:
Java Version
@Entity
@Table(name = "location_tag")
@Audited
@AttributeOverride(name = "name", column = @Column(name = "name", nullable = false, length = 50))
public class LocationTag extends BaseChangeableOpenmrsMetadata {
private static final long serialVersionUID = 7654L;
private Integer locationTagId;
public LocationTag() {
}
public LocationTag(Integer locationTagId) {
this.locationTagId = locationTagId;
}
public LocationTag(String name, String description) {
setName(name);
setDescription(description);
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "location_tag_id", nullable = false)
public Integer getLocationTagId() {
return this.locationTagId;
}
public void setLocationTagId(Integer locationTagId) {
this.locationTagId = locationTagId;
}
@Override
public Integer getId() {
return getLocationTagId();
}
@Override
public void setId(Integer id) {
setLocationTagId(id);
}
@Override
public String toString() {
return getName() != null ? getName() : "";
}
} Kotlin Version
@Audited
@Entity
@Table(name = "location_tag")
@AttributeOverride(name = "name", column = Column(name = "name", nullable = false, length = 50))
class LocationTag() : BaseChangeableOpenmrsMetadata() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "location_tag_id", nullable = false)
var locationTagId: Int? = null
constructor(locationTagId: Int?) : this() {
this.locationTagId = locationTagId
}
constructor(name: String?, description: String?) : this() {
this.name = name
this.description = description
}
override fun toString(): String = name ?: ""
override fun getId(): Int? = locationTagId
override fun setId(id: Int?) {
locationTagId = id
}
companion object {
private const val serialVersionUID = 7654L
}
} With this approach, boilerplate shrinks, but more importantly, intent becomes visible in the type system.
For senior reviewers, the takeaway is that “semantic migration” is reviewable. You can ask: Do the nullability markings reflect reality? Did we remove boilerplate without losing invariants? Are we leaning on Kotlin’s strengths (properties, expression bodies, Elvis operator) in ways that reduce bug surface?
2: Framework constraints are the hidden migration backlog
Most Java-to-Kotlin migration pain doesn’t come from language syntax. It comes from frameworks that depend on bytecode tricks: proxies, reflection, and runtime enhancement.
Two Kotlin compiler plugins exist precisely to reduce this friction:
The all-open plugin addresses Kotlin’s default of making classes and members final. Kotlin’s docs explicitly call out that this default is inconvenient for frameworks such as Spring AOP, which require classes to be
openso proxies can be created.The no-arg plugin generates a synthetic zero-argument constructor for annotated classes, which helps frameworks like JPA instantiate entities via reflection even when Kotlin doesn’t define a zero-arg constructor explicitly.
These are leadership-level concerns because they determine whether your migration scales. If teams manually sprinkle open and constructors across hundreds of files with inconsistent rules, you get a “looks migrated, runs broken” outcome.
Veen shows this in his service implementation example:
@Service("medicationDispenseService")
@Transactional
open class MedicationDispenseServiceImpl : BaseOpenmrsService(), MedicationDispenseService {
@Autowired
lateinit var dao: MedicationDispenseDAO
@Transactional(readOnly = true)
override fun getMedicationDispense(id: Int?): MedicationDispense? =
dao.getMedicationDispense(id)
override fun saveMedicationDispense(dispense: MedicationDispense): MedicationDispense {
require(dispense != null) { "MedicationDispense cannot be null" }
return dao.saveMedicationDispense(dispense)
}
@Transactional(readOnly = true)
override fun getMedicationDispenses(
patient: Patient,
drug: Drug?,
dateRangeParam: DateRangeParam?,
startIndex: Int?,
limit: Int?
): List<MedicationDispense> {
require(patient != null) { "Patient cannot be null" }
return dao.getMedicationDispenses(patient, drug, dateRangeParam, startIndex, limit)
}
} In the preceding code, the open keyword is a runtime requirement, not a stylistic choice, when frameworks proxy your classes for transactions.
A practical leadership move here is to standardize how these requirements are met: prefer compiler plugins (where feasible) over manual edits. Kotlin’s docs note that you can use a Spring-specific wrapper (kotlin-spring) on top of all-open, and that Spring project templates typically enable it by default. Likewise, Kotlin documents that kotlin-jpa is a wrapper on top of no-arg and automatically targets common JPA annotations.
3: AI accelerates conversion, but governance determines quality
Veen’s migration stance is not “AI replaces engineers.” It’s closer to: AI replaces repetitive conversion work, so humans can spend time on correctness and architecture.
Recent developments reinforce why that governance burden is growing. Anthropic positions Claude Opus 4.6 as improved for coding tasks that involve longer planning, more reliable operation in larger codebases, and stronger debugging and code review – with a very large context window intended for long, multi-step work. In practice, Anthropic’s telemetry-based research suggests users are already letting Claude Code run autonomously for longer on the most complex work: the 99.9th percentile “turn duration” in Claude Code nearly doubled from under 25 minutes to over 45 minutes over a three-month span, and experienced users enable auto-approve more often while interrupting when needed.
That pattern maps directly onto migration risk. The faster you transform files, the easier it is to:
Propagate a wrong nullability decision across a layer.
Preserve a Java anti-pattern in Kotlin clothing.
Break a proxy/reflection expectation in subtle ways.
For senior engineers and architects, the emphasis shifts to controlling the blast radius. Veen’s own best practices (small batches, frequent commits, tests between batches, and explicit pattern guides) are not process overload – they are how you turn “AI speed” into “organizational safety.”
Even Kotlin itself nudges teams toward explicitness where Java might have relied on convention. For example, Kotlin treats Java’s checked exceptions as unchecked – the compiler won’t force catches – so interoperability requires deliberate documentation patterns (for example, @Throws for Java callers, as Veen shows).
A smaller but telling example from Veen’s validator conversion shows how Kotlin idioms can reduce branching and clarify intent – if reviewers insist on it:
@Component
@Handler(supports = [EncounterRole::class])
class EncounterRoleValidator : RequireNameValidator() {
@Autowired
private lateinit var encounterService: EncounterService
override fun validate(obj: Any, errors: Errors) {
super.validate(obj, errors)
if (errors.hasErrors()) return
val encounterRole = obj as EncounterRole
val name = encounterRole.name?.trim()
if (!name.isNullOrBlank()) {
val duplicate = encounterService.getEncounterRoleByName(name)
if (duplicate != null && encounterRole.uuid != duplicate.uuid) {
errors.rejectValue(
"name",
"EncounterRole.duplicate.name",
"Specified Encounter Role name already exists"
)
}
}
}
} Key Takeaways
Migration success depends on semantic decisions, not just conversion volume. Lock down your nullability rules early, and review them as architecture, not style.
Use Kotlin’s compiler plugins to meet framework requirements at scale. Kotlin is
finalby default;all-open(and wrappers likekotlin-spring) exist because frameworks such as Spring AOP needopenclasses.Plan for reflection and JPA. If your entities need zero-arg constructors for runtime instantiation,
no-arg(and wrappers likekotlin-jpa) can generate them safely.AI makes migration fast, but it increases the need for governance. Long-context models are designed for sustained, multi-step coding work.
Adopt “human-in-the-loop” guardrails that match how agents are used in practice – users increasingly auto-approve and let agents run longer, intervening when needed.
🧠 Expert Insight
From Java to Kotlin: A Large-Scale Migration Story with AI Assistance
Recently, I had to migrate a Java codebase to Kotlin. Now, we are talking about proprietary software, so the code cannot be shared in this article. Thus, I decided to find an open-source project with approximately the same characteristics. That is the reason why we use OpenMRS in this article.
🔍 In case you missed it…
🛠️ Tool of the Week
Exposed — Kotlin’s type-safe SQL framework, now at 1.0
Exposed is JetBrains’ open-source SQL framework for Kotlin, hitting 1.0 this week with R2DBC support for reactive database access and a guaranteed stable API, making it a production-ready database layer for teams adopting Kotlin.
📎 Tech Briefs
Java 26 released — Java 26 ships ten JEPs including HTTP/3 for the HTTP Client API, lazy constants in second preview, and an AOT cache that now works with any garbage collector including ZGC.
Kotlin 2.3.20 released — The release removes significant manual Maven setup by automating source root configuration and stdlib inclusion, and adds name-based destructuring declarations to the language.
Java 26 support lands in IntelliJ IDEA — IntelliJ IDEA adds full Java 26 support this week, including inspections and quick-fixes for lazy constants, primitive patterns, and the extended ahead-of-time cache for ZGC.
Kotlin Foundation joins Google Summer of Code 2026 — The programme is accepting applications for projects spanning Swift-to-Kotlin interop, tail call support in Kotlin/Wasm, and a Kotlin education landscape research report, with the deadline on March 31.
That’s all for today. Thank you for reading this issue of Deep Engineering.
We’ll be back next week with more expert-led content.
Stay awesome,
Saqib Jan
Editor-in-Chief, Deep Engineering
If your company is interested in reaching an audience of senior developers, software engineers, and technical decision-makers, you may want to advertise with us.









