Java is Dead, Long Live Kotlin (Part 2)

Emily Fujimoto
Thumbtack Engineering
6 min readMay 16, 2024

We did it! That’s right, we’ve finally removed the last Java code from our Android codebase in favor of Kotlin. As you might be able to tell from our blog post back in August 2018 when we were about 60% Kotlin, it’s been a long tail of a migration. In this blog post, we share our reflections on what we’ve learned!

cloc breakdown of our android codebase. Main thing is the lack of Java code

How we got there

At the time of the previous blog post, nearly all new code was written in Kotlin. In other words, any completely new files were Kotlin and the only new Java being written was minor updates to existing files. From there, we started pushing people to Kotlinize any files they wanted to modify before adding new code. The obvious concern with this was “will it slow down product development too much?” In our experience, the answer was “no, not really.” For starters, the Java to Kotlin conversion tool in Android Studio was pretty good. And while its output should never be taken on blind faith, it does mean that you mostly need to just review what it generated. Plus, if you were already planning on adding some new functionality to that code, you must already have some idea of how to test that your new code (and by extension some of the old code) works, right? That would definitely save time over someone else coming in cold with no context of the feature at all trying to make sure everything still works post-conversion.

The Java to Kotlin conversion tool under the Android Studio Code Menu

That pushed us most of the way to a fully Kotlin codebase. The final step was, of course, Kotlinizing the files for features nobody was modifying. This was the step that took us the longest, mostly because we just didn’t prioritize it for the longest time. What finally pushed us to prioritize it was a revamp of our Android architecture last year, aimed at incorporating newer technologies like Jetpack Compose and Kotlin coroutines into our codebase. While this was exciting, it would add yet another migration to our list. In addition to this, an internal survey showed “old Java code” was one of the top areas where Android engineers thought our codebase could be improved. At this point, we were down to about a hundred files that were still in Java, so we decided to make converting the final files one of our Must Have goals for H1 2024.

One thing that helped with this was having a burndown doc listing out all the remaining files to Kotlinize. People could assign themselves to a given file, to avoid multiple people trying to work on the same thing, and then update the file’s status when they merged their change. This provided a nice visual representation of our progress that we could call out at our weekly Android team meetings. It was especially helpful to call out right before we started one of our company-wide quarterly “Fixit” Weeks (a week where we put aside normal product work to work on things like tech-debt cleanup, polish tasks, etc) since the work fell right in line with the spirit of “Fixit.” When we got down to about 20 remaining files, we added a HeyTaco bounty for every file converted for some extra gamification.

What we learned

By far, the hardest part of Kotlinizing old files is dealing with nullability, both in our code, in third party code, and even in Android OS code. For this reason, I would implore you, if you write Java code, to please make use of annotations such as @Nullable and @NonNull. If nothing else, at least add a comment about nullability. I would also further suggest that you use both forms of the annotation and not just one. There were files that needed conversion that tended to have only one of the two annotations instead of both. I suspect the reasoning at the time was that anything not annotated should be assumed to be the opposite. For example, if you annotate everything that isn’t going to be null as NonNull, it makes sense that anything without an annotation must be Nullable, right? I can tell you it’s definitely a thought my younger, less experienced self had while writing Java code. However, it’s important to then ask yourself two questions:

  1. Is that assumption explicitly stated somewhere as the overall practice of your codebase?
  2. How confident are you that every developer who touched that file remembered to follow that practice?

If done perfectly, the practice of only using one of the two annotations can work. But given all the things that can go wrong over time, is there really a good reason not to just use both annotations?

Obviously, we can’t really force our past selves to adhere to this logic, so we’re stuck worst-case playing it safe and assuming everything is nullable. But even then, the expected behavior for the original code isn’t always obvious. Take the following class, which takes a User object and tells DBFlow how to store that User in the database as a String and then how to convert that String back into a User.

@TypeConverter
public class UserConverter extends TypeConverter<String, User> {

@Override
public String getDBValue(User model) {
return ModelModule.getGson().toJson(model);
}

@Override
public User getModelValue(String data) {
return ModelModule.getGson().fromJson(data, User.class);
}
}

Ok, so no annotations means we should assume both the User object and the database String representation are nullable. But if they are null, what exactly is the output of each function? Does a null User create a null String and vice versa? Without digging into how the Gson library works, I certainly couldn’t tell you. Furthermore, since there are presumably User strings already stored in the databases of devices, we need to make sure our implementation is the same. The best way to do this is to write tests for both of these functions (if they don’t already exist) for null and non-null cases and make sure they pass again once the files have been Kotlinized.

This then naturally brings us to the other main thing we learned: write tests before you Kotlinize. Even if you don’t normally use test driven development, it’s worth adopting a similar practice for conversions. As I mentioned earlier, the last part of our migration was converting files no one was actively working on. This then often meant they were files written by people who had left the company years prior. Even if they hadn’t left, it would be optimistic to hope that they’d still remember all the details of how their implementation was supposed to work and what assumptions they made while writing it. On rare occasions, we were lucky that the original authors had already written tests that we could leverage. But more often than not, we had to write our own based on what the code was doing. This practice not only improved our confidence in our conversions, but also improved our codebase in the long run. On a few occasions, the tests we wrote were the only thing that prevented us from introducing a bug when we tried to update Java types or code styles to something more Kotlin-y (for example, updating from an ArrayList to Kotlin’s List type).

Conclusion

It took us a while to get there, but we finally managed to remove all the Java from our Android codebase. We saw first hand how important it is to make nullability assumptions explicit and to have good testing in place. As a bonus, our approach of writing unit tests before conversion has bumped up our overall code coverage and increased our confidence in the correctness of our code. This also means we won’t have to jump through hoops to get Kotlin coroutines working in Java files, helping out the migration to use that. Plus, completing it now rather than later means we have more context on the really old code than we would have had a year from now. We are confident that gamifying the conversion progress will help us finish a few more of our ongoing migrations.

--

--