Why You Should Migrate Microservices From Java to Kotlin: Experience and Insights

I work at one of the largest private banks in Eastern Europe, developing the backend for a mobile application. Our cluster consists of more than 400 microservices, and peak loads on individual services can reach five-digit values. When we initially started transitioning to a microservices architecture, all our code was written in Java. However, over time, we began actively migrating microservices to Kotlin. Today, all new microservices are created exclusively in Kotlin, and the share of Java code has decreased to less than 20%. 

In this article, I will explain why the migration to Kotlin has been so successful and why developers are eager to switch to this language, even with prior experience only in other JVM languages.

Kotlin vs Java

One of Kotlin’s main strengths is its full compatibility with Java code. Kotlin seamlessly interacts with Java classes and methods, and vice versa, allowing for a smooth transition to the new language without the need to rewrite existing code. Kotlin uses JVM bytecode, ensuring compatibility with all the Java libraries and frameworks that we actively use in our project.

The modular structure of microservices is ideal for the gradual introduction of Kotlin. We started by developing new components in Kotlin, then slowly migrated older ones. Importantly, we could continue using Java code without worrying about conflicts or breakdowns. This approach allowed us to avoid significant risks and ensured the stable operation of the entire cluster.

Kotlin offers a range of modern features that simplify development and make it more efficient. Here are a few examples:

Switching to Kotlin is smooth and natural because its syntax closely resembles Java. However, unlike Java, Kotlin eliminates much of the boilerplate, making the code more concise and readable. For instance, creating POJO classes in Kotlin can be done in a single line using data class. Similar improvements can be seen in other aspects of the language, such as working with collections, asynchronous programming, and handling nullable types.

Code Examples: Java vs. Kotlin

To illustrate, here are a few comparisons:

POJO Class vs. Data Class

Java
 
public class Person {
    private String name;
    private int age;
  
  /*
  Also here you need to add a constructor,
  getters and setters for each field, 
  toString,
  equals,
  hashCode
  */
}


In Java, creating a simple POJO (Plain Old Java Object) requires explicitly defining fields, constructors, getters, setters, and often overriding toString(), equals(), and hashCode() methods. This results in a lot of boilerplate code.

Kotlin
 
data class Person(val name: String, val age: Int)


Kotlin's data class automatically generates constructor, getters (and setters for var properties), toString(), equals(), hashCode(), and copy() methods. This single line of code is equivalent to dozens of lines in Java.

Nullable Types

Java
 
String name = null;
if (name != null) {
    System.out.println(name.length());
} else {
    System.out.println("Name is null");
}


In Java, you need to explicitly check for null before accessing an object to avoid NullPointerException. This often leads to verbose null-checking code.

Kotlin
 
val name: String? = null
println(name?.length ?: "Name is null")


Kotlin uses the safe call operator (?.) and the Elvis operator (?:) to handle nullables concisely. This line safely accesses the length if name is not null, or otherwise prints "Name is null". In Kotlin, the safe call operator, ?. and the ?: operator are used for default values, simplifying null handling.

Nested Object Checks

Java
 
if (person != null && person.getAddress() != null && person.getAddress().getCity() != null) {
    System.out.println(person.getAddress().getCity());
}


In Java, checking nested objects for null requires multiple checks, leading to deeply nested if statements or long boolean expressions.

Kotlin
 
person?.address?.city?.let {
    println(it)
}


Kotlin's safe call operator (?.) allows for chaining nullable calls. The let function executes the block only if all the previous calls in the chain are non-null.

Asynchronous Calls

Java
 
public Mono<String> fetchData() {
    Mono<String> first = fetchFirst();
    Mono<String> second = fetchSecond();
    
    return Mono.zip(first, second)
            .map(tuple -> tuple.getT1() + " " + tuple.getT2());
}


This Java code uses Project Reactor's Mono for asynchronous programming. It fetches two pieces of data asynchronously and combines them.

Kotlin
 
suspend fun fetchData() {
    val first = async { fetchFirst() }
    val second = async { fetchSecond() }

    println("${first.await()} ${second.await()}")
}


Kotlin's coroutines make asynchronous code look and behave like synchronous code. The async function starts coroutines for fetching data, and await() suspends execution until the result is available.

Collection Processing

Java
 
List<String> names = Arrays.asList("John", "Jane", "Doe");
List<String> filteredNames = names.stream()
    .filter(name -> name.startsWith("J"))
    .map(String::toUpperCase)
    .collect(Collectors.toList());


Java uses streams and lambda expressions for collection processing. This code filters names starting with "J" and converts them to uppercase.

Kotlin
 
val names = listOf("John", "Jane", "Doe")
val filteredNames = names.filter { it.startsWith("J") }
                         .map { it.uppercase() }


Kotlin provides concise functions for collection processing. The code does the same as the Java version but with a more readable syntax. Kotlin offers a more concise and expressive syntax for working with collections.

If-Else in Java and When in Kotlin

Java
 
int number = 3;
String result;

if (number == 1) {
    result = "One";
} else if (number == 2) {
    result = "Two";
} else {
    result = "Other";
}


Java uses traditional if-else statements for conditional logic. This can become verbose with multiple conditions.

Kotlin
 
val number = 3
val result = when (number) {
    1 -> "One"
    2 -> "Two"
    else -> "Other"
}


Kotlin's when expression provides a more concise and powerful replacement for switch statements and complex if-else chains.

Class Extensions

Java
 
public class StringUtils {
    public static String reverse(String s) {
        return new StringBuilder(s).reverse().toString();
    }
}


In Java, adding functionality to existing classes often requires creating utility classes with static methods.

Kotlin
 
fun String.reverse(): String = this.reversed()


Kotlin's extension functions allow adding new methods to existing classes without modifying their source code. This reverses a string and can be called directly on String objects.

Kotlin
 
val reversed = "Hello".reverse()  // "olleH"


The extension function can be called as if it were a method of the String class, making the code more intuitive and object-oriented.

These examples clearly show how Kotlin simplifies and enhances development compared to Java through concise syntax, built-in features, and powerful tools for asynchronous programming and collection handling.

Conclusion

Speaking of the compatibility of Java and Kotlin within the same project, I want to emphasize that using microservices in both languages does not require creating separate libraries and starters. The only limitation is the Java version, which must not be higher than the one used in the services. Spring also fully supports both languages, and the difference in use lies only in some dependencies that need to be pulled in through Maven or Gradle build systems.

After several years of using Java and Kotlin simultaneously on our project, I can confidently say that developers do not want to go back to Java. They like Kotlin much more — it is more concise and expressive and provides more opportunities for writing efficient code. Kotlin code is easier to read and understand, especially when regularly reviewing your colleagues' pull requests. For programmers with experience in other JVM languages, the transition to Kotlin is very fast.

Of course, Java is not standing still. In the new versions, features like records have appeared, which serve as an analog of data classes in Kotlin. Virtual threads, which could replace coroutines, are also in development. Additionally, work is underway to improve the support for nullable objects.

Nevertheless, most of our developers prefer to work with Kotlin. They appreciate its conciseness, expressiveness, and modern features that significantly improve productivity and code quality. The transition from Java to Kotlin has been simple and natural for us, allowing us to maintain the existing system and gradually introduce the new language.

 

 

 

 

Top