This repo contains equivalent code samples to compare Lombok-ed Java 21 with Kotlin. It examines whether using Kotlin has benefits in terms of code maintainability and how easy it is to leverage these for a Java developer.
- Kotlin is designed to be easy for a Java developer to learn.
- It was designed with full interoperability with Java in mind, i.e., Kotlin code can call Java code and vice versa.
- It is not necessary to convert an entire codebase from Java to Kotlin in order to benefit from its features.
- Common pitfalls in Java code are avoided or reduced, such as unexpected
NullPointerException
s or the cognitive complexity that comes with mutable code. - The features described on this page can be leveraged by a Java developer after about 4 hours of study, reading through the references provided.
- Introduced in 2011 and open-sourced under the Apache 2 License, Kotlin is managed by the Kotlin Foundation:
- The foundation was created by JetBrains and Google. It advances the development of the language.
- According to the foundation, Kotlin has 710k active developers and is used in 1.2 million GitHub repos. 32 of the top 100 universities teach Kotlin.
- StackOverflow Trends reports a steady upward trend of Kotlin since 2016, contrasting with a downward trend of Java over the same period. Now, Kotlin receives 30% as many StackOverflow postings as Java.
- Google selected Kotlin in 2019 as its preferred language for Android development.
- Spring Boot, the most popular framework for Java microservices, provides first-class support for Kotlin and shows all code examples in both Java and Kotlin.
- Gradle, one of the most popular build systems for Java, chose Kotlin over Groovy due to its strong typing and ease of creating DSLs (domain-specific languages).
- The source code of Kotlin is hosted on GitHub and has seen 226 releases since 2013, most recently version 2.0.0, which saw up to 94% improvements in compilation performance.
- Kotlin Multiplatform is a new initiative and supports the development of cross-platform projects, both on the server-side and client-side (Android, iOS, Desktop, Web). Thus, language skills in Kotlin are transferable across different domains.
Modern Java projects typically rely on Project Lombok to gain some of the code maintainability benefits that Kotlin provides. Thus, one may wonder whether the combination of Java 21 and Lombok affords the same benefits as Java 21 and Kotlin. Let's compare them.
- Lombok is a 3rd party library. As it relies on the
javac
compiler, some of its features may fall into disrepair in future Java versions. It may also not support (LTS) Java versions. Some examples:- The use of Lombok's
val
resulted in very slow compile times when the Java 11 LTS version was officially released. This bug lasted for months until a fix was provided. Until that happened, we were forced to replace allval
references withvar
since the compilation times had increased from 10s to 2 minutes. - The Java code samples in this project won't compile on Java 22 due to
post-compiler 'lombok.bytecode.SneakyThrowsRemover' caused an exception: java.lang.IllegalArgumentException: Unsupported class file major version 66
since at this time, only initial Java 22 support has been added.
- The use of Lombok's
- Lombok relies on annotations, but as it does not support composable annotations, this can result in a large number of annotations cluttering the codebase and reducing maintainability.
- Lombok is an open-source project which is very complex and largely driven forward by a single developer.
- Kotlin is similar to Java and cooperates well with the overall ecosystem:
- The learning curve is small; certainly not bigger than comprehending Lombok and its idiosyncrasies.
- It can be viewed as a Java developer productivity tool to enrich Java-based projects. Some Java code can be converted to Kotlin to improve code maintainability while leaving the rest as Java.
- JetBrains IntelliJ IDEA provides first-class support for Kotlin and offers a shortcut to convert Java to Kotlin code.
- Unlike Lombok, it does not rely on the
javac
compiler, thus simplifying the upgrade to new Java versions. - It does not rely on annotations to extend Java features, thus improving readability.
- The language has a large number of active contributors, and JetBrain's Kotlin team has 100+ engineers. It is backed by both JetBrains and Google.
A short selection of "Getting Started" guides:
- Spring Boot & Kotlin: Pain or Gain? by Urs Peter @ Spring I/O 2024. Discusses Kotlin benefits, its use in Spring Boot microservices, as well as simplified reactive development using Kotlin
couroutine
.
Kotlin provides a number of useful features for a Java developer which are easy to learn. Properly leveraged, they result in code that's easier to read and maintain than the equivalent Java code.
Kotlin
Data holder classes with default values, providing getter/setter/toString/equals/hashCode
as well as copy
methods:
data class Dept(val name: String = "IT", val staffCount: Int = 0)
val itDept = Dept()
val itDept2 = itDept.copy(staffCount = 1)
val hrDept = Dept(name = "HR")
Java
Java is more verbose and requires builder/toBuilder/build
methods.
Additional downsides:
- If using Lombok's
@Value
annotation, then Lombok's@Builder.Default
can be used for default values. However, this feature is not available of using Javarecord
s instead. - If starting out with Java
record
s and the for default values arises, then switching towards@Value
requires refactoring since both approaches use different field declarations and different syntax for getter methods.
Java record
approach which cannot uses defaults and exposes a name()
method:
@Builder(toBuilder = true)
record Dept(@NonNull String name, int staffCount) {
}
val itDept = Dept.builder().name("IT").build();
val itDeptName = itDept.name();
val itDept2 = itDept.toBuilder().staffCount(1).build();
val hrDept = Dept.builder().name("HR").build();
Lombok @Value
approach which can use defaults and exposes a getName()
method:
@Value
@Builder(toBuilder = true)
class Dept {
@NonNull
@Builder.Default
String name = "IT";
int staffCount;
}
val itDept = Dept.builder().build();
val itDeptName = itDept.getName();
// ...as above...
Kotlin
Allows instantiation of classes without new
, accessing properties without get/set
, and comparing objects
semantically with ==
:
val isItDept = Dept("IT").name == "IT"
Java
Java is more verbose:
val isItDept = new Dept("IT").name().equals("IT");
Kotlin
Kotlin does not have checked exceptions since they have fallen out of favor with many developers as they increase code clutter:
fun foo() {
methodWhichThrowsCheckedFooException()
}
Java
Java is more verbose, requiring workarounds that introduce code clutter:
void foo() {
try {
methodWhichThrowsCheckedFooException();
} catch (FooException e) {
throw new RuntimeException(e);
}
}
or
@SneakyThrows
void foo() {
methodWhichThrowsCheckedFooException();
}
Kotlin
Variables can be defined as val
(immutable) and var
(mutable). Parameters are always immutable.
val a = "hello"
var b = "world"
fun log(s: String) {
println(s)
}
Java
Lombok has val
and Java provides var
, but neither is supported for method parameters and instance/class fields, resulting in more verbose code.
val a = "hello";
var b = "world";
void log(final String s) {
System.out.println(s);
}
Kotlin
Explicit type declaration as non-nullable (default) or nullable, using the ?
null-safety operator, allowing for compile-time null safety:
val foo: String
val bar: String?
Java
Java has no compile-time null-safety and is more verbose, regardless of which of the following alternatives is used:
// Lombok-based, generates code that throws NPE if setter for this field passes in null
@NonNull
final String foo;
// Spring-based, informs IDE to show warning and will also be interpreted by Lombok
@Nullable
final String bar;
// Denotes s as maybe absent
final Optional<String> maybeBar;
Kotlin
Access nullable fields via the ?. operator, defining fallbacks via the ?: "Elvis" operator:
val bar = a?.bar ?: null
val departmentHeadBirthday = department.head?.birthday
Java
The equivalent Java code is considerably more difficult, especially when deeply nested fields are accessed.
One can either use null checks:
val bar = a == null ? null : a.getBar();
val departmentHeadBirthday = (department != null && department.getHead() != null)
? department.getHead().getBirthday()
: null;
or rely on Optional:
val bar = Optional.ofNullable(a)
.map(a2 -> a2.getBar())
.orElse(null);
val departmentHeadBirthday = Optional.ofNullable(department.getHead())
.map(Person::getBirthday)
.orElse(null);
Kotlin
Reference variables directly from templated strings:
val s = "foo=$foo, bar=$bar"
Java
Java is harder to read, especially when using many variables, since string placeholder and referenced variable are often far apart:
val s = "foo=%s, bar=%s".formatted(foo, bar);
Kotlin
Long test method names can contain spaces. They are thus more readable.
In particular, if using Cucumber BDD (Behavior Driven Development) step annotations, the annotation can exactly match the method name and can be kept in sync more easily:
// JUnit 5
@Test
fun `Given foo When bar Then baz`() {
}
// Cucumber BDD Step
@When("user {word} logs in")
fun `user {word} logs in`(userName: String) {
}
Java
Java is harder to read and maintain due to using underscores instead of spaces. In addition, some BDD step declarations contain characters which are unsupported by Java methods, requiring further divergence:
// JUnit 5
@Test
void Given_foo_When_bar_Then_baz() {
}
// Cucumber BDD Step
@When("user {word} logs in")
void user_word_logs_in(final String userName) {
}
Kotlin
One-line function without result type, return
statement, and braces to reduce ceremony and preserve screen real estate:
fun add(a: Int, b: Int) = a + b
Java
Java consumes more vertical screen real estate, leading to additional scrolling and context switching:
int add(int a, int b) {
return a + b;
}
Kotlin
Nested private helper functions to improve encapsulation:
fun foo(): Int {
fun helperOnlyUsedByFoo(): Int {
//...
}
//...
}
Java
Splitting methods quickly results in pollution of the class namespace, making it harder to see which method a dedicated helper method is used by:
int foo() {
//...
}
private int helperOnlyUsedByFoo() {
//...
}
Kotlin
Extend an existing Kotlin or Java API with new features, for example to provide a fluent API for improved readability:
// Add new methods to classes Foo and Dto; only needed once
fun Foo.toDto(): Dto = dto(this)
fun Dto.toJson(): String = json(this)
val json = foo.toDto().toJson()
Java
Java is harder to read since multiple transformations result in increasing nesting levels. Creating a fluent API is not easy for third-party libraries beyond the developer's control:
val json = json(dto(entity));
Kotlin
Allows for fluent code via method chaining and it/this
references, e.g., to log the result of a map(a)
invocation as a side effect of returning it:
return doSomething(a)
.also { log { "result=$it" } }
Java
Java is more verbose and adds increased cognitive load due to "single use" variables which pollute a method's namespace instead of leveraging a fluent API:
val result = doSomething(a);
log("result={}",result);
return result;
This chapter compares equivalent code in Java and Kotlin.
Purpose: JSON to DTO conversions, mapping, and logging.
- Java Implementation
- Kotlin Implementation
- Java Test of both Java and Kotlin implementations
- Kotlin Test, ditto