From Java to Kotlin: A Large-Scale Migration Story with AI Assistance
Why Migrate to Kotlin in Modern Java Codebases?
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.
The OpenMRS project, a robust open-source medical records system serving healthcare providers worldwide, has been built on Java for over a decade. With hundreds of thousands of lines of code across domain models, service layers, data access objects, and utilities, the codebase represents years of careful engineering and community contribution.
So why consider migrating to Kotlin?
The decision wasn’t taken lightly. Kotlin offers several compelling advantages that align with modern software development practices:
Null Safety: Kotlin’s type system distinguishes between nullable and non-nullable types at compile time, eliminating the infamous
NullPointerExceptionthat plagues Java applications. In a medical records system where data integrity is paramount, this additional safety net is invaluable.Conciseness: Kotlin reduces boilerplate code significantly. Properties replace getter/setter pairs, data classes eliminate constructor and method boilerplate, and smart casts remove redundant type checks. Our migration showed an average code reduction of 20-47% across different layers.
100% Java Interoperability: Kotlin compiles to the same bytecode as Java and can seamlessly call Java code and vice versa. This meant we could migrate incrementally, file by file, without breaking existing functionality.
Modern Language Features: Extension functions, coroutines, sealed classes, and expressive lambda syntax make code more readable and maintainable.
Industry Adoption: Google declared Kotlin the preferred language for Android development, and it has gained significant traction in server-side development, with frameworks like Spring Boot fully supporting Kotlin.
Why Use AI for Large-Scale Migration?
Migrating a codebase of this magnitude manually would be a herculean task. While IntelliJ IDEA provides excellent Java-to-Kotlin conversion tools, they produce mechanical translations that don’t take full advantage of Kotlin’s idioms. The converted code works, but it doesn’t read like idiomatic Kotlin.
This is where AI assistance becomes transformative:
Pattern Recognition: AI can recognize common Java patterns (like builder patterns, static utility classes, or verbose null checks) and replace them with idiomatic Kotlin equivalents (apply blocks, object declarations, safe calls).
Consistency: Across hundreds of files, maintaining consistent style and patterns is crucial. AI applies the same transformation rules uniformly, ensuring the converted codebase feels cohesive.
Context Awareness: AI can understand the broader context—whether a class needs to be
openfor Spring proxying, whether properties should belateinitor nullable, and how to preserve framework annotations properly.Iterative Refinement: Unlike one-shot conversion tools, AI-assisted migration allows for learning and improvement. Early migrations establish patterns that are refined and applied consistently across subsequent conversions.
Scale: Manually reviewing and converting 250+ files while maintaining quality would take weeks or months. AI assistance can process multiple files per hour while maintaining high standards.
Why Claude Code?
The choice of AI tool was the first decision to be made. There are several candidates:
Anthropic Claude Code
Google Gemini CLI
OpenAI Codex
Microsoft CoPilot
OpenCode
Cursor
Most of these are so-called command-line tools, except Cursor. Choosing a model is very personal, based on one’s own experiences. It can easily lead to flame wars, like IntelliJ vs. Eclipse, or tabs vs. spaces.
Anthropic’s models differ from models like ChatGPT and Gemini in that the latter are mainly general AI models, while the former have a specific focus on software development. Anthropic offers 3 models:
Haiku
Opus
Sonnet
Each model is smarter and more intelligent than the previous. But this also means it uses more tokens and thus is more expensive to use.
Among various AI coding assistants, Claude Code proved particularly well-suited for this migration:
Long Context Window: Claude Code can maintain context across thousands of lines of code, understanding relationships between classes and interfaces across multiple files.
Tool Integration: Direct integration with file operations, git commands, and the ability to read, write, and edit files means the entire workflow—from reading Java files to committing Kotlin conversions—happens seamlessly.
Multi-Step Reasoning: Each migration required multiple steps: analyzing the Java code, understanding its purpose, applying appropriate Kotlin patterns, preserving annotations, and verifying correctness. Claude Code excels at this type of complex, multi-step reasoning.
Framework Knowledge: Claude Code demonstrated deep understanding of Spring Framework, Hibernate/JPA, and other technologies used in the codebase, correctly preserving critical annotations and architectural patterns.
Incremental Progress: The ability to work in batches, commit incrementally, and maintain a clear audit trail through git history was essential for a migration of this scale.
Migration Layer by Layer
Let’s dive into the specific transformations applied to each architectural layer of the application, examining real examples from our migration and explaining the Kotlin idioms that make the code more expressive and maintainable.
Domain Entities: The Foundation
Domain entities are the core data structures representing concepts like Patients, Observations, Concepts, and Locations. These classes are heavily annotated with JPA/Hibernate mappings and form the foundation of the entire application.
Example: LocationTag Entity
Java Version (65 lines):
@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 (65 lines → 40 lines, 38% reduction):
@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
}
} Key Kotlin Idioms Applied:
Properties Replace Getters/Setters: The
locationTagIdfield becomes a property with automatic getter/setter generation. JPA annotations are applied directly to the property.Nullable Types:
Int?explicitly declares that the ID can be null, making null-safety part of the type system rather than relying on documentation or runtime checks.Companion Objects: The
serialVersionUIDmoves to a companion object, Kotlin’s answer to static members. Theconst valmodifier ensures it’s compiled as a compile-time constant.Elvis Operator:
name ?: ""replaces the verbose ternary operator, providing a concise way to handle null values.Expression Bodies: Methods with single expressions can be written inline with
=, improving readability.Secondary Constructors: Kotlin’s secondary constructor syntax is more compact while maintaining all the functionality of the Java version.
Service Interfaces: Contracts and Boundaries
Service interfaces define the contract between the presentation layer and business logic. These are crucial for Spring’s dependency injection and testing.
Example: ConceptService Interface
Java Version (excerpt):
@Transactional
public interface ConceptService extends OpenmrsService {
@Transactional(readOnly = true)
@Authorized(OpenmrsConstants.PRIV_VIEW_CONCEPTS)
Concept getConcept(Integer conceptId) throws APIException;
@Transactional(readOnly = true)
@Authorized(OpenmrsConstants.PRIV_VIEW_CONCEPTS)
List<Concept> getAllConcepts(String sortBy, boolean asc, boolean includeRetired)
throws APIException;
@Authorized(OpenmrsConstants.PRIV_MANAGE_CONCEPTS)
Concept saveConcept(Concept concept) throws APIException;
@Transactional(readOnly = true)
@Authorized(OpenmrsConstants.PRIV_VIEW_CONCEPTS)
List<ConceptSearchResult> getConcepts(
String phrase,
List<Locale> locales,
boolean includeRetired,
List<ConceptClass> requireClasses,
List<ConceptClass> excludeClasses,
List<ConceptDatatype> requireDatatypes,
List<ConceptDatatype> excludeDatatypes,
Concept answersToConcept,
Integer start,
Integer size
) throws APIException;
} Kotlin Version:
@Transactional
interface ConceptService : OpenmrsService {
@Transactional(readOnly = true)
@Authorized(OpenmrsConstants.PRIV_VIEW_CONCEPTS)
@Throws(APIException::class)
fun getConcept(conceptId: Int?): Concept?
@Transactional(readOnly = true)
@Authorized(OpenmrsConstants.PRIV_VIEW_CONCEPTS)
@Throws(APIException::class)
fun getAllConcepts(sortBy: String?, asc: Boolean, includeRetired: Boolean): List<Concept>
@Authorized(OpenmrsConstants.PRIV_MANAGE_CONCEPTS)
@Throws(APIException::class)
fun saveConcept(concept: Concept): Concept
@Transactional(readOnly = true)
@Authorized(OpenmrsConstants.PRIV_VIEW_CONCEPTS)
@Throws(APIException::class)
fun getConcepts(
phrase: String?,
locales: List<Locale>?,
includeRetired: Boolean,
requireClasses: List<ConceptClass>?,
excludeClasses: List<ConceptClass>?,
requireDatatypes: List<ConceptDatatype>?,
excludeDatatypes: List<ConceptDatatype>?,
answersToConcept: Concept?,
start: Int?,
size: Int?
): List<ConceptSearchResult>
} Key Kotlin Idioms Applied:
Function Declaration:
funkeyword with parameter name followed by type (conceptId: Int?) is more natural to read than Java’s type-before-name convention.Explicit Nullability: Every parameter and return type explicitly declares whether it can be null.
Int?means nullable integer,Intmeans non-null. This eliminates entire classes of bugs.@Throws Annotation: Kotlin doesn’t have checked exceptions, so
@Throwsdocuments which exceptions might be thrown for Java interoperability.Immutable Collections by Default:
List<Concept>in Kotlin is read-only by default, preventing accidental modification.Named Parameters: Kotlin supports named parameters at call sites, making methods with many parameters more readable:
getConcepts(phrase = "diabetes", locales = listOf(Locale.ENGLISH), includeRetired = false, ...).
Service Implementations: Business Logic
Service implementations contain the business logic and orchestrate data access. These classes often have complex dependencies and transaction management.
Example: MedicationDispenseServiceImpl
Java Version (108 lines, excerpt):
@Service("medicationDispenseService")
@Transactional
public class MedicationDispenseServiceImpl extends BaseOpenmrsService
implements MedicationDispenseService {
private MedicationDispenseDAO dao;
@Autowired
public void setMedicationDispenseDAO(MedicationDispenseDAO dao) {
this.dao = dao;
}
public MedicationDispenseDAO getDao() {
return dao;
}
@Override
@Transactional(readOnly = true)
public MedicationDispense getMedicationDispense(Integer id) {
return dao.getMedicationDispense(id);
}
@Override
public MedicationDispense saveMedicationDispense(MedicationDispense dispense) {
if (dispense == null) {
throw new IllegalArgumentException("MedicationDispense cannot be null");
}
return dao.saveMedicationDispense(dispense);
}
@Override
@Transactional(readOnly = true)
public List<MedicationDispense> getMedicationDispenses(
Patient patient,
Drug drug,
DateRangeParam dateRangeParam,
Integer startIndex,
Integer limit
) {
if (patient == null) {
throw new IllegalArgumentException("Patient cannot be null");
}
return dao.getMedicationDispenses(patient, drug, dateRangeParam, startIndex, limit);
}
} Kotlin Version (83 lines, 23% reduction):
@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)
}
} Key Kotlin Idioms Applied:
Open Class: The
openkeyword is crucial for Spring—it allows CGLIB to create proxies for transaction management. Without it, Spring’s proxy-based AOP won’t work properly.lateinit var: Perfect for dependency injection. The property is non-nullable but initialized after construction. Accessing it before initialization throws a clear exception.
Property-Based Injection: No need for getter/setter—
@Autowiredcan be applied directly to the property. This eliminates 4 lines of boilerplate per dependency.require() Function: Kotlin’s built-in validation function. It throws
IllegalArgumentExceptionwith a clear message if the condition is false, replacing verbose if-throw blocks.Single-Expression Functions: Simple delegation methods can be written inline with
=, improving readability dramatically.String Templates: The error message in
requireuses a lambda, allowing for lazy evaluation and automatic string conversion.
Validators: Data Integrity
Validators ensure data integrity before persistence. They implement Spring’s Validator interface and contain complex validation logic.
Example: EncounterRoleValidator
Java Version (51 lines):
@Component
@Handler(supports = {EncounterRole.class})
public class EncounterRoleValidator extends RequireNameValidator {
@Autowired
private EncounterService encounterService;
@Override
public void validate(Object obj, Errors errors) {
super.validate(obj, errors);
if (errors.hasErrors()) {
return;
}
EncounterRole encounterRole = (EncounterRole) obj;
String name = encounterRole.getName();
if (name != null) {
name = name.trim();
}
if (StringUtils.hasText(name)) {
EncounterRole duplicate = encounterService.getEncounterRoleByName(name);
if (duplicate != null) {
if (!OpenmrsUtil.nullSafeEquals(encounterRole.getUuid(), duplicate.getUuid())) {
errors.rejectValue("name", "EncounterRole.duplicate.name",
"Specified Encounter Role name already exists");
}
}
}
}
} Kotlin Version (51 lines, same length but more readable):
@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 Kotlin Idioms Applied:
Array Syntax for Annotations:
[EncounterRole::class]is more concise than{EncounterRole.class}.Smart Casts: After
obj as EncounterRole, the compiler knowsobjis anEncounterRoleand allows direct property access without repeated casting.Safe Call Operator:
encounterRole.name?.trim()safely handles null values—if name is null, the entire expression evaluates to null rather than throwing NPE.isNullOrBlank(): Kotlin’s extension function combines null check and blank check in one readable operation, replacing
StringUtils.hasText().Property Access:
encounterRole.uuidinstead ofencounterRole.getUuid()is more concise and reads like natural language.Simplified Conditionals: The combined null check and UUID comparison is more readable without the verbose
OpenmrsUtil.nullSafeEquals().
Data Access Objects: Persistence Layer
DAOs handle database interactions using Hibernate. These range from simple interfaces to complex query builders.
Example: OpenmrsRevisionEntity
Java Version (48 lines):
@Entity
@RevisionEntity(OpenmrsRevisionEntityListener.class)
@Table(name = "liquibasechangelog")
public class OpenmrsRevisionEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@RevisionNumber
@Column(name = "id")
private int id;
@RevisionTimestamp
@Column(name = "dateexecuted")
private long timestamp;
@Column(name = "author")
private String author;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public long getTimestamp() {
return timestamp;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
} Kotlin Version (35 lines, 27% reduction):
@Entity
@RevisionEntity(OpenmrsRevisionEntityListener::class)
@Table(name = "liquibasechangelog")
open class OpenmrsRevisionEntity : Serializable {
@Id
@RevisionNumber
@Column(name = "id")
var id: Int = 0
@RevisionTimestamp
@Column(name = "dateexecuted")
var timestamp: Long = 0
@Column(name = "author")
var author: String? = null
companion object {
private const val serialVersionUID = 1L
}
} Key Kotlin Idioms Applied:
Open Class for Hibernate: Hibernate creates proxies of entity classes for lazy loading.
openallows this proxying to work correctly.Properties with Annotations: JPA annotations work directly on properties, eliminating all getter/setter boilerplate—12 lines reduced to 3 property declarations.
Default Values: Properties can have default values (
= 0), making the code more explicit about initial state.Nullable vs Non-Nullable:
author: String?is nullable (might not have an author), whileid: Intis non-null (always has an ID).Class Reference Syntax:
OpenmrsRevisionEntityListener::classis Kotlin’s class reference, equivalent to Java’s.class.
Migration Statistics and Outcomes
Our migration effort covered multiple architectural layers with impressive results:
Entities (167 files): 43,715 Java lines → 23,084 Kotlin lines (47% reduction)
Service Interfaces (10 files): Significant boilerplate reduction while maintaining full compatibility
Service Implementations (5 files): 412 lines → 346 lines (16% reduction)
Validators (5 files): 220 lines → 208 lines (5% reduction)
DAOs (15 files): ~350 lines → ~300 lines (14% reduction)
Utilities (20 files): 764 lines → 675 lines (12% reduction)
Overall, we achieved approximately 20-47% code reduction depending on the layer, with entity classes showing the most dramatic improvements due to property syntax replacing getter/setter boilerplate.
Challenges and Solutions
Challenge 1: Spring Framework Compatibility
Issue: Spring uses CGLIB proxies for transaction management and AOP, which require classes to be non-final.
Solution: Mark classes as open explicitly. Initially, we converted classes directly, but Spring couldn’t create proxies. Adding open to service implementations and certain entities solved this immediately.
Challenge 2: JPA/Hibernate Annotations
Issue: JPA annotations on getters needed to move to properties, but placement matters.
Solution: Annotations go on the property declaration. For field-based access, annotations work directly on the property. For property-based access, we used @field: or @get: site targets when necessary.
Challenge 3: Null Safety Migration
Issue: Java code doesn’t distinguish nullable from non-nullable. Making everything nullable defeats Kotlin’s purpose; making everything non-null causes crashes.
Solution: Analyzed each field contextually. IDs that might not be set before persistence are nullable (Int?). Required business fields are non-null. When in doubt, we started nullable and refined after testing.
Challenge 4: Collection Immutability
Issue: Kotlin’s List is read-only; Java code often expects mutable collections.
Solution: Used MutableList for collections that need modification. For return types from repository methods, List (immutable) is preferred unless the caller needs to modify the collection.
Challenge 5: Checked Exceptions
Issue: Kotlin doesn’t have checked exceptions, but Java callers expect them.
Solution: Added @Throws annotations to interface methods for Java interoperability, documenting which exceptions might be thrown.
Best Practices Learned
Migrate Layer by Layer: Start with leaf dependencies (entities) and work up to services. This minimizes compilation issues.
Commit Frequently: Small, focused commits make it easier to identify issues and revert if necessary.
Test Between Batches: Run tests after each batch of 10-15 files to catch issues early.
Preserve Annotations Carefully: Framework annotations are critical—double-check they’re preserved correctly.
Use IDE Verification: Even with AI assistance, run IntelliJ’s “Analyze Code” on converted files to catch issues.
Document Patterns: Maintain a pattern guide for the team showing how common Java constructs map to Kotlin idioms.
Leverage Companion Objects: Static members go in companion objects with
const valfor compile-time constants and@JvmStaticfor methods called from Java.Think About Nullability: Don’t blindly make everything nullable. Use non-null types when semantically appropriate, and let the compiler help you find bugs.
Conclusion: The Path Forward
Migrating from Java to Kotlin represents more than just a syntax change—it’s an evolution in how we think about code safety, expressiveness, and maintainability. The combination of Kotlin’s powerful features and AI-assisted migration allowed us to modernize a large codebase systematically while maintaining functionality and quality.
The results speak for themselves: significant code reduction, improved null safety, and more maintainable code. New developers joining the project find Kotlin code more approachable, with less boilerplate to wade through before understanding business logic.
For teams considering similar migrations:
Start Small: Migrate utilities and simple classes first to build confidence.
Establish Patterns: Document your Kotlin idioms early and apply them consistently.
Leverage AI: Use AI assistance not just for conversion but for learning idiomatic patterns.
Test Thoroughly: Maintain your test suite and run it frequently.
Incremental Progress: Don’t aim for perfection—migrate incrementally and refine over time.
The investment in migration pays dividends in long-term maintainability, developer productivity, and code safety. For OpenMRS, this migration positions the platform for modern development practices while preserving the stability and reliability that healthcare providers depend on.
As we continue migrating the remaining files, we’re not just translating code—we’re improving it, making it more robust, more readable, and more maintainable for the next decade of development.
One thing that we’ve learned from this migration is that, though AI is doing the bulk of the work, we, as humans, stay responsible for the final outcome. So, code reviews are essential, as is knowledge of not just Kotlin itself, but of the idiomatic use of Kotlin. Migration with AI, but with the user in the loop.



