On my projects, I mostly work with modern Java (11+), but I still occasionally work with older versions (6,7) as well. Every time I switch to an older one, I miss modern Java features, especially those found in functional programming such as lambdas and streams, which I find very useful.
Imagine you have an old maintenance project with poor old Java 6 or 7, and you want to modernise it. So why not just upgrade, right? Because sometimes this is not possible for various reasons; for example:
- The cost of the required effort and potential risks may exceed the benefits
- It may be technically impossible due to coexistence with other technologies on the project
- The system is supposed to be replaced in the not-so-distant future anyway
- And so on
In such a case, it would be useful if there was an option to write the code in modern Java and use our favourite advanced features but then actually compile and run it on an older Java version. However, this is not possible because the new features require the new Java Runtime Environment. There are some ways of getting around this with third-party libraries such as Retrolambda, but this approach brings another layer of complexity and potential problems.
Fortunately, there seems to be another saviour in the JVM sea—a modern and promising JVM language called Kotlin. It has all the glamour we need. It has many of the cool features of the latest Java, it can compile and run even on poor old JRE 6 and, most importantly, it can also be used alongside existing Java code in the same project!
As we will see, this interoperability is one of the main reasons why Kotlin can be so helpful in this case. Because on a project where a Java upgrade is unacceptable, a complete migration to another language would probably be unacceptable as well.
So, instead of a big bang migration, we could just iteratively migrate small and carefully selected parts of a system; for example, write new code in Kotlin and use it along with the existing Java codebase. How easy would it be, and are there any drawbacks? In the following text, I will summarise some essential points to consider.
Before we start, it is good to keep in mind that such an approach is a kind of refactoring, so we should follow good refactoring practices as well. Here I recommend a great book by Martin Fowler called Refactoring. I think the essential takeaway for our use case is that we should do small and controlled iterations and regularly validate that the system has not broken down.
Quick feature overview
First, let us take just a brief look at some (but undoubtedly not all) of the features that Kotlin provides to see how it can help us.
With Kotlin, we gain access to many great features, some of which you are already familiar with from Java 8+. First of all, Kotlin is quite similar to Java, so if you already know Java, you will be able to read it in no time. Give it a little more time, and you will be able to write it as well, at least to some extent! This is because there are also concepts that are quite different in Kotlin, which may require a different approach to design or thinking, as we will see later.
Many things are more straightforward and concise in Kotlin than in Java such as properties.
- Java:
public class Order { public List<OrderLine> getOrderLines(); public void setOrderLines(List<OrderLine> orderLine); public Integer getOrderNumber(); public void setOrderNumber(Integer orderNumber); public BigDecimal getPrice(); }
- Kotlin:
class Order { var orderLines : List<OrderLine> var orderNumber: Int val price : BigDecimal }
As you can see, it looks similar but more readable. Instead of getters and setters, we only declare the properties, and Kotlin automatically generates the getters and setters for us.
If we use var, we get a mutable property, so getter and setter. With val, we get an immutable property, so just getter.
When we want to access the property, we just use the property name, not the accessor methods, so:
- Kotlin:
order.orderNumber = 12345
println(order.price)
The problem may come in when mixing Java and Kotlin code because you have to get used to both of them and be able to switch back and forth between them constantly. And, although the concepts are very similar, the syntax is sometimes a little different; for example:
public String formatText(String text)
vs
fun formatText(text: String): String
Another thing is the code conventions. You have to keep in mind that the same syntax can be fine in one language but not in the other. Consider this example again.
order.orderNumber = 12345
println(order.price)
Because this is Kotlin code, it is just standard property access. Still, in the case of Java, this would be direct field access, which is usually bad practice (some would even say that getters and setters themselves are generally bad practice, but that is not the story here). So, whenever you switch between Java and Kotlin, you also have to switch your code conventions’ internal alarm.
By the way, something similar can be achieved in Java as well (even in the older one) with the Lombok library and its @Data annotation.
@Data class Order { private List orderLines; private Integer orderNumber; private final BigDecimal price; }
Extension methods
An extension method is functionality to extend an existing class without inheriting it or using any type of design pattern.
Kotlin provides extensions to many Java library classes so it is easier to use them.
Do you need a new ArrayList?
return arrayListOf(1, 5, 2)
Are you calculating the sum of an array?
val sum = arrayOf(12, 33).sum()
It is also possible to create new extensions yourself.
Smart casts
Why should you cast an object if you have just checked it has the correct type? It is so redundant! Kotlin knows that, so instead of checking an object against a type and then casting to it,
- Java:
if (obj instanceof String) { print(((String)obj).length); }
You just check it and go.
- Kotlin:
if (obj is String) { print(obj.length) }
Null safety
Kotlin helps eliminate unpopular NullPointerException (NPE)—you always declare if the reference can be null or not, and Kotlin can check this in compilation time.
By default, reference is not nullable.
var order: Order = new Order() order = null // Compilation error - cannot be null
To make it nullable, add “?”.
var order: Order? = new Order() order = null // OK
Then, in Kotlin we are protected from NullPointerException because we cannot access the property unless we are sure that it is not null.
var orderPrice = order.price // Compilation error
So, we must check it explicitly:
if (order != null) orderPrice = order.price
As you see, this is similar to the smart cast above—after “if (order != null)”, Kotlin already knows that the order is not null, so we can safely access it.
Or, we can use a safe operator.
var price = order?.price
This returns null if the order is null and “order.price ” otherwise.
But you have to be careful when you call Java code from Kotlin.
public class StringUtils { static String capitalizeFirst(String text) { return text.isEmpty() ? null : text.substring(0,1).toUpperCase() + text.substring(1); } } fun main() { val text: String = StringUtils.capitalizeFirst("") println("text length : " + text.length) // java.lang.IllegalStateException: Utils.capitalizeFirst("") must not be null }
In this case, Kotlin does not check at compilation-time, so we always have to know if we are calling Java or Kotlin code. On a mixed code project, it can be hard to follow.
Functional programming: Lambdas and streams
Lambdas and streams are a great way to incorporate principles of functional programming into our code in Java 8+. Let’s look at a simple example.
Example: This method returns information if the given person ordered at least one promoted item.
- Java 6:
public boolean hasPromotedItem(Person person) { for (Order order : person.getOrders()) { for (OrderLine orderLine : order.getOrderLines()) { if (orderLine.isPromoted()) { return true; } } } return false; }
- Kotlin:
fun hasPromotedItem(Person: person) : Boolean { return person.orders.flatMap { it.orderLines }.any { it.promoted } }
Not only is the code more concise but it’s more illustrative as well. It’s easy to see what the method does because we are using common patterns.
It is very similar to lambdas and streams from Java 8+, but it has some enhancements. You can see that the lambda is shorter because we can omit the left part.
- Java 8:
public boolean hasPromotedItem(Person person) { return person.getOrders().stream().flatMap(it -> it.getOrderLines().stream()).any(it -> it.isPromoted()); }
But be careful, there is one crucial difference. Kotlin collections are not lazy by default!
To get lazy behaviour, we have to use an asSequence:
fun hasPromotedItem(person: Person) : Boolean { return person.orders.asSequence().flatMap { it.orderLines }.any { it.promoted } }
For more details, see https://dzone.com/articles/kotlin-beware-of-java-stream-api-habits.
Example: This method returns the total count of promoted items ordered by the given person.
- Java 6:
public int countPromotedItems(Person person) { int count = 0; for (Order order : person.getOrders()) { for (OrderLine orderLine : order.getOrderLines()) { if (orderLine.isPromoted()) { count += 1; } } } return count; }
- Kotlin:
fun countPromotedItems(person: Person) : Int { return person.orders.flatMap { it.orderLines }.filter { it.promoted }.size; }
Because lambdas in Kotlin are first-class citizens, we can pass them around as method parameters, for example, as predicates. Here we count the number of order items that satisfy a condition that is provided as a method parameter.
- Kotlin:
fun countByPredicate(person: Person, condition : (OrderLine) -> Boolean) : Boolean { return person.orders.flatMap { it.orderLines }.filter { condition(it) }.size; }
Note that in Java 6, we would need a class to pass the predicate as a parameter (with a little help from the Google Guava predicate class, for example).
Operator overloading
Operator overloading can help us write more readable code. One of the situations is working with the BigDecimal class. In Java, you have to use methods to do the math.
Example: This method calculates the house price based on its base price per square meter and the number of doors and windows, and it discounts 5 percent for each discount coupon up to MAX_DISCOUNT percent, in other words:
(basePrice + doorPrice * doorCount + wndPrice * wndCount) * max(1 – couponDiscount * couponCount, 1 – MAX_DISCOUNT)
- Java 6:
basePrice.add(wndPrice.multiply(wndCount)).add(doorPrice.multiply(doorCount)).multiply(BigDecimal.ONE.subtract(couponDiscount.multiply(couponCount))).max(BigDecimal.ONE.subtract(MAX_DISCOUNT));
It’s not immediately apparent what happens in this formula, what the order of operations is and what operations associate. Notice the max function at the end—does it apply only to the discount or to the entire formula? Let us rewrite it to make it more readable.
basePrice .add(wndPrice .multiply(wndCount) ) .add(doorPrice .multiply(doorCount) ) .multiply( BigDecimal.ONE .subtract(couponDiscount .multiply(couponCount) ) ) .max(BigDecimal.ONE.subtract(MAX_DISCOUNT));
Now we can already see that this code has a bug! The max method is evaluated for the entire formula, not just for a discount. Let us fix it.
basePrice .add(wndPrice .multiply(wndCount) ) .add(doorPrice .multiply(doorCount) ) .multiply( ( BigDecimal.ONE .subtract(couponDiscount .multiply(couponCount) ) ) .max(BigDecimal.ONE.subtract(MAX_DISCOUNT)) );
This is much better but still harder to read than the formula in the description. What about Kotlin?
- Kotlin:
fun calculateTotalPrice( basePrice: BigDecimal, wndPrice: BigDecimal, wndCount: BigDecimal, doorPrice: BigDecimal, doorCount: BigDecimal, couponDiscount: BigDecimal, couponCount: BigDecimal, maxDiscount: BigDecimal): BigDecimal { return (basePrice + wndPrice * wndCount + doorPrice * doorCount) * maxOf( 1.toBigDecimal() - couponDiscount * couponCount, 1.toBigDecimal() – maxDiscount); }
This code almost looks like its description! It’s easy to see what is happening here (as long as we know some basic math and operator precedence).
In practice, we would, of course, split the complex formula into smaller ones to make it more readable. Still, this example formula is actually already pretty simple, and even here these operators help a lot!
Named parameters
The method “calculateTotalPrice” has a lot of parameters, so it’s not obvious which is which in the call.
- Java:
calculateTotalPrice(9.toBigDecimal(), new BigDecimal(0.1), new BigDecimal(8), new BigDecimal(0.2), new BigDecimal(1), new BigDecimal(0.1), new BigDecimal(20), new BigDecimal (0.3));
In Java, a method with so many parameters usually signifies bad design, and one would use a dedicated object as a parameter. With Kotlin we can use named parameters to make it clear.
- Kotlin:
calculateTotalPrice(basePrice = 9.toBigDecimal(), wndPrice = 0.1.toBigDecimal(), wndCount = 8.toBigDecimal(), doorPrice = 0.2.toBigDecimal(), doorCount = 1.toBigDecimal(), couponDiscount = 0.1.toBigDecimal(), couponCount = 20.toBigDecimal(), maxDiscount = 0.3.toBigDecimal());
There are many more great features in Kotlin that help us modernise and refactor old code bases. However, we will not address them all here. I encourage you to look at them.
Migration options
Kotlin looks excellent, so how can we incorporate it into our Java project?
Java and Kotlin mixture is simple—just let both Java and Kotlin files coexist in the same project. Java uses .java files, Kotlin uses .kt files and they both translate to .class files in the end. So, in your code, you can call Kotlin from Java and vice versa. Therefore, if we need a new functionality and we want to use Kotlin, we can just add a new Kotlin file, write the code and then call it from Java.
But wait, if we add a new file, doesn’t it mean that we also add a new class? That is not always the way we extend our code. Sometimes we need to add new methods or statements to an existing class. We could create a new utility class for all new methods, something like MyClassUtils. However, it has some disadvantages. It can break an existing design, it is not always the right design choice and it may not even be technically possible—like adding a new private method. And last but not least, it kind of ruins the transparency of our desired Kotlin-Java mixture. Fortunately, Kotlin can help with that as well.
Convert the entire class
One option is to rewrite the existing Java file to Kotlin. IntelliJ IDEA (IDE from the same company as Kotlin) has excellent support for that—two mouse clicks or one key shortcut and it automatically converts your Java file to a Kotlin file. Now you can write new Kotlin code or modify existing code, and from the outside, no one will know (most of the time). Converting an existing file has some drawbacks. Even with automatic conversion, it is necessary to check the converted code and ensure its validity. First, there can be some semantic differences in both languages that you should check (like null safety, etc.) and you cannot be 100% sure that the conversion is correct. It’s always good to revise and test the converted code. As it stands in day-to-day development, the more automatic tests you have and the better the class design is, the easier the task is. It’s much easier to convert small, concise, encapsulated Java classes than big monsters.
The good thing is that IDEA tries to report if some part of the conversion should be manually corrected and revised. The IDE also proposes automatic translation when you copy-paste Java code to an existing Kotlin file.
Extension methods
If we don’t want to convert the entire class, there is another option. We can add new methods to an existing class without changing the original class at all (so, no modifications of the original file)! We just define a new method anywhere in the project, and then we can reference it the same way as a conventional method of the original class. This method even has access to other class members. The feature is called extension methods, and as you saw earlier, Kotlin itself widely uses it to enhance standard libraries, for example, for collections.
- Java:
class Dog { fun bark() { println("I can bark") } }
- Kotlin:
fun Dog.bite() { bark(); println("I bark and then I bite!") } val dog = Dog() dog.bite()
What to choose?
Although migration using extension methods is technically possible, it leads to mixing two languages in the same class, which will be harder to maintain, so I think converting an entire class is generally a better option. What I think we should strive for is not to migrate individual classes but to always migrate a collection of associated classes that together comprise standard encapsulated functionality, or a module or a component, to have a clear separation and avoid unmaintainable mixing of languages.
As I mentioned in the beginning, we should regularly validate the system. One of the prerequisites is to have good automatic test coverage. If a migrated part does not have tests, we should pay the technical debt and write tests against the old code first and then, only after that, try to migrate it. And again, we apply the principles mentioned above. Since we are migrating encapsulated parts, those automatic tests should not change too much after migration because the interface should remain the same. We are only changing the internal implementations.
What is essential to consider is that although there are a lot of similarities between Kotlin and Java, there are also a lot of language differences that often make it necessary, or at least useful, to think and design differently; for example, concerning:
- Immutable objects
- Companion objects
- Extension methods
- No static methods
- Coroutines
- And so on
Conclusion
It is fair to say that mixing two languages on one project naturally increases its accidental complexity (complexity not related to the problem itself). In such a case, developers working on the project will need to know two languages and be able to switch between them regularly. As I have shown, that means not only new syntax but also semantics and conventions that must be kept in mind. For some people, it may be an obstacle, and it is good to consider that upfront. In the case of a long-term project (which such a maintenance project probably is), developers on the project can (will) change over time, and this will make it a little harder for new developers to get used to the project codebase. Of course, the situation is different when comparing a “one-man show” project and a large multi-person project. Anyway, this can all be addressed with carefully planned migration and clean separation.
As you can see, maintaining an older application does not have to mean using old technologies all the time. With a little help, you can modernise it to a more advanced level and enjoy new features as well!
Last but not least, if you have not tried Kotlin yet, give it a try! No need to install anything, just start in the playground.
Author: Štěpán Poljak
Senior Software Engineer