Stop using Lombok; Start using Kotlin instead
Lombok and Kotlin have their costs and benefits, but for the vast majority of use cases, Kotlin’s costs are lower than Lombok’s but provide equal or better benefits. In this post, I’ll first review both tools’ costs. Then I’ll go over each feature Lombok provides and show what corresponding feature Kotlin provides.
Costs
Integrating with your Project
Adding either tool to your project takes about the same amount of effort. For Lombok and Gradle, you would add Lombok to your dependencies. For Kotlin and Gradle, you would add the Kotlin JVM plugin.
Lombok in Gradle:
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
testCompileOnly 'org.projectlombok:lombok:1.18.24'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.24'
}
Kotlin in Gradle:
plugins {
kotlin("jvm") version "1.8.0"
}
Learning Curves
If you’re using Kotlin as a pure Lombok replacement, the learning curve is essentially identical. For each feature in Lombok, you would read a couple of paragraphs explaining what the feature does and the syntax for using that feature. The equivalent features in Kotlin require the same effort or are simpler. Kotlin offers more advanced features requiring more significant learning effort, but you can ignore those features if you don’t want to spend time learning about them. Then, if and when your team feels more confident and adventurous, they can explore the more advanced features.
JVM Support
Where Lombok becomes more costly than Kotlin, is in terms of the JVM integration. Lombok uses bytecode manipulation that violates the public contract of the Java spec, which means that every time a new version of Java is released, Lombok breaks until a new version is released. In contrast, Kotlin adheres to the JVM spec, and code written in older versions of Kotlin will continue to work on all future versions of the JVM.
Take a look at this Github thread for an example of this.
Java + Lombok is essentially a brand-new language distinct from Java. If you’re going to use a non-Java language on the JVM anyway, you should use Kotlin, which is a better-designed language than Java + Lombok. This is not entirely Lombok’s fault: Lombok is mostly annotation based on top of Java, which restricts what syntax it’s allowed to introduce. Regardless of “whose fault it is,” Kotlin comes out on top due to not facing the same restrictions.
Furthermore, bytecode manipulation tools tend not to compose well. This means that the more bytecode manipulation tools you use together, the more likely you will encounter conflicts. Kotlin does not manipulate the bytecode of any existing class. Instead, it generates new classes. In Kotlin’s approach, every class file has a clear owner, which eliminates the possibility of conflicts.
Benefits
Now let’s go through Lombok’s “Features” page and show how every feature is also available in equal or simpler form in Kotlin.
val/var
Lombok offers two new keywords that perform type inference and mutability status. These keywords are built into Kotlin and function a bit better than the Lombok counterpart. In Lombok, these keywords work on local variables only and do not work on fields. In Kotlin, they work equally well as local variables and fields. Furthermore, in Lombok, you cannot override the inferred type, so the following Kotlin snippet cannot be expressed in Lombok:
var x: Object = "Hello"
x = Color.RED
@NonNull
In Kotlin, nullability is built into the type system. For example, a variable declared to have type String
cannot be null
. To allow for null values, you instead use the type String?
, with a question mark at the end. Kotlin’s nullability check happens at compile time, which is better than Lombok’s check, which happens at runtime (and would crash your program).
@Cleanup
Lombok’s cleanup annotation automatically calls the close method on a local variable when that variable reaches the end of the current scope. Kotlin provides an API called use
, which provides the same functionality but additionally lets you control the point at which the close happens.
Lombok example:
public void main(String[] args) throws IOException {
@Cleanup InputStream in = new FileInputStream(args[0]);
@Cleanup OutputStream out = new FileOutputStream(args[1]);
byte[] b = new byte[10000];
while (true) {
int r = in.read(b);
if (r == -1) break;
out.write(b, 0, r);
}
}
Kotlin example:
fun main(args: Array<String>) {
val b = ByteArray(10_000)
FileInputStream(args[0]).use { input ->
FileOutputStream(args[1]).use { output ->
while (true) {
val r = input.read(b)
if (r == -1)
break
output.write(b, 0, r)
}
}
}
}
@Getter, @Setter
Getters and setters are essentially free in Kotlin. Just declare the fields as normal.
Lombok example:
public class GetterSetterExample {
@Getter @Setter private int age = 10;
@Setter(AccessLevel.PROTECTED) private String name;
}
Kotlin example:
open class GetterSetterExample {
var age = 10
var name: String? = null
protected set
}
@SneakyThrows
Kotlin treats all exceptions as unchecked, so you get this for free on every method.
@Synchronized
Here is one of the few situations where Kotlin’s solution is a bit more complex in exchange for significantly more flexibility. Lombok’s annotation automatically creates a lock object for you, whereas in Kotlin, you would need to declare the lock object yourself:
Lombok example:
public class SynchronizedExample {
@Synchronized
public int answerToLife() {
return 42;
}
}
Kotlin example:
class SynchronizedExample {
private val lock = Any()
fun answerToLife(): Int {
synchronized(lock) {
return 42
}
}
}
However, Kotlin provides a richer API. For example, there’s syntactic sugar for working with Java’s Read/Write lock:
class ReadWriteLockExample {
private val lock = ReentrantReadWriteLock()
fun read() {
lock.read {
/*
Multiple threads can execute here at the same time, as no one has the write lock.
*/
}
}
fun write() {
lock.write {
/*
Only one thread can execute here at any given time.
*/
}
}
}
Finally, Kotlin also provides coroutines, a somewhat more advanced topic that provides concurrency without locking, allowing your application to use the underlying hardware resources more effectively.
@Getter(lazy=true)
Kotlin has a general feature called “delegated properties,” which enables many features, one of which is lazy getters.
Lombok example:
public class GetterLazyExample {
@Getter(lazy=true) private final double[] cached = expensive();
private double[] expensive() {
double[] result = new double[1000000];
for (int i = 0; i < result.length; i++) {
result[i] = Math.asin(i);
}
return result;
}
}
Kotlin example:
class LazyPropertyExample {
private val cached by lazy {
val result = DoubleArray(1_000_000)
for (i in result.indices) {
result[i] = asin(i.toDouble())
}
result
}
}
@Log
Admittedly, I’m not aware of an equivalent in Kotlin here. You would need to declare your logger manually.
Lombok example:
@Slf4j
public class LogExampleOther {
public void main() {
log.error("Something is wrong here");
}
}
Kotlin example:
private val log = LoggerFactory.getLogger(LogExampleOther::class.java)
class LogExampleOther {
fun main() {
log.error("Something is wrong here")
}
}
@ToString, @EqualsAndHashCode, @NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor, @Data, @Value, @Builder, @With
Kotlin unifies all of these features into one feature called data class
.
Lombok example:
@Data
@Builder
@With
public class DataExample {
@NonNull private final String name;
private int age;
@ToString.Exclude
@EqualsAndHashCode.Exclude
private final int score = 42;
}
Kotlin example:
data class DataExample(
val name: String,
var age: Int
) {
val score = 42
}
The comparison here is a little complicated. Kotlin allows for names parameters with default values. In Java-terms, this is akin to automatically generating all 2^N possible constructor overloads depending on which parameters you provide default arguments to. Lombok isn’t able to do this, but it covers the most common usecases with @NoArgsConstructor, @RequiredArgsConstructor and @AllArgsConstructor.
Similarly, Kotlin’s named parameter feature obviates the need for the most common use case for Java builders: avoiding the need to remember what order the parameters are in. For the second most common use case for builders—constructing an object in stages—Kotlin does not provide a shortcut there. However, the implementation that Lombok generates does not produce a type-safe builder for this second use case: There is no type-level check that you’ve provided all the necessary fields to a builder before you ask it to generate your object for you. In both the Lombok+Java case and the Kotlin case, you would want to manually write out your own type-safe builders.
Finally, for the @With
annotation, Kotlin’s copy
method is more powerful. Again, it uses named parameters to automatically generate all 2^N possible overrides of the method that lets you create a copy of the object with certain fields overridden to have new values. In contrast, with Lombok, you have to call each with
method in succession, creating a bunch of unnecessary temporary objects if you want to set multiple fields.
Conclusion
Hopefully, this illustrates that for almost every use case, Kotlin is a better solution than Java+Lombok.