I have to admit, that when I first saw Project Lombok few years ago it was quite odd to me. The whole "annotation thing" and code pieces appearing "magically" inside the classes made me skeptical. A few years later, I'm now a fan of using Lombok in my Java projects for many reasons. Not all of its users know, that Lombok has more than just reducing the boilerplate code to offer. Let's take a look at what's behind its magic and what could you benefit from adding it to your projects. Instead of making a hello world example using features like @Getter, @Setter or @Builder, this post is focused on some more complex concepts behind Lombok.

Alleged evil of annotation processing

A lot of people complain about the way Lombok works - so by modifying classes implementation. Code generating tools aren't anything new or special. The difference between them and Lombok it's mostly about how do they work under the hood.

OpenJDK compilation overview
Diagram from the OpenJDK Compilation Overview documentation.

Typical annotation processors, generate additional code aside from existing codebase. What Lombok does is it modifies the Abstract Syntax Tree (AST) - structure representing parsed source file, which is then translated into the bytecode by the compiler. That is the infamous Lombok "hack" and a reason for library criticism. Even if this is not how the annotation processors were designed, should we really care about this?

First of all, Lombok is a quite popular library, widely used by many companies. It is actively maintained, so the support for new versions of the JDK is added constantly. What does it mean for your project and your company? Even if something will go wrong in your project (library bug etc.) you can be almost sure that you are not alone - and probably you are not even the first who experienced this.

Secondly, application builds using Lombok are "stable" in terms of runtime behavior. This means that the compiled code will look identically no matter where it will be run in the future. You may say that this is not something groundbreaking, but did you ever think about how your Spring-based app works? Lots of its functionalities are built with cglib which generates bytecode at runtime. The advantage of Lombok's approach is that it will behave the same on any JVM because at the end it produces normal POJO classes without any special features. When using tools like cglib, using nonstandard JVMs (like the one from IBM) may lead to some unexpected compatibility issues. To make things even worse, you won't see them before running the app! Any potential Lombok problem will occur during the compilation phase so you won't be surprised by the bug on production. Spring vs Lombok - 0:1 :-)

Hacky & JVM-tight?

From my perspective, Lombok's "hack" is not an exception in the Java world. There are still tons of libraries using some deprecated or internal JVM APIs like the infamous sun.misc.Unsafe about most users just don't care. For the reason unknown to me, they are often not attacked the way that Lombok is. The question is - am I safe with using Lombok in my project?

If you have to adopt early (unstable) versions of the JDK or to compile your code on some nonstandard & exotic JVM implementations the Lombok is probably not for you. But hey, your job is already great, isn't it? ;) Otherwise, there is nothing to be afraid of - Lombok will run just smoothly and you shouldn't expect any further problems. In my 2+ years production experience with Lombok, I didn't have any serious issues. In the meantime, I even used it with GWT! Of course, some unusual setups (like multiple annotation processors within the projects) may require additional configuration of the IDE or Lombok itself (eg. via lombok.config configuration file), but in most cases, it will run out of the box without problems.

Equality done "better"

Most people tend to think about Lombok as a 1:1 replacement for IDE code generation features. But what if I told you, that Lombok is better[1] in generating equals & hashCode than IntelliJ Idea is?

Let's define two POJO classes - Worker & its extension called Developer. For both of them, we will generate equals & hashCode using default IntelliJ template (version 2018.1) with Accept subclasses as parameters to equals() method and Use getters during code generation options enabled.

class Worker {
    private int age;
    private String name;
    // getters & setters here
    
    public Worker() { }

    public Worker(int age, String name) {
        this.age = age;
        this.name = name;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Worker)) return false;
        Worker worker = (Worker) o;
        if (getAge() != worker.getAge()) return false;
        return getName().equals(worker.getName());
    }

    // hashCode here (not important now)
}
class Developer extends Worker {
    private String language;
    // getters & setters here

    public Developer(int age, String name, String language) {
        super(age, name);
        this.language = language;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Developer)) return false;
        if (!super.equals(o)) return false;
        Developer developer = (Developer) o;
        return getLanguage().equals(developer.getLanguage());
    }

    // hashCode here (not important now)
}

There are several rules of equality contract - one of them is that equals has to be symmetric, so if a.equals(b) is true, b.equals(a) also has to be true. We can test generated implementations against this requirement with the following code:

Worker worker = new Worker(30, "John");
Developer developer = new Developer(30, "John", "Java");

println("worker == developer ? " + worker.equals(developer));
println("developer == worker ? " + developer.equals(worker));

What is the final result? Trust me, you will be surprised:

worker == developer ? true
developer == worker ? false

That means that equals generated by the IDE might not always fulfill equality contract when handling subclasses!.

Maybe the problem is not within the generated equals but in the parameter that we set to allow subclasses? Let's check the equals implementation without it:

class Worker {
// rest of the class here...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Worker worker = (Worker) o;
        if (getAge() != worker.getAge()) return false;
        return getName().equals(worker.getName());
    }
}
class Developer extends Worker {
    // rest of the class here...
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        Developer developer = (Developer) o;
        return getLanguage().equals(developer.getLanguage());
    }
}

The results looks promising so far:

worker == developer ? false
developer == worker ? false

But what if we just add another test case for our example? What will be the result of equality check against the anonymous class like this:

Worker worker = new Worker(30, "John");
Worker anonymousWorker = new Worker() {
        @Override 
        public int getAge() { return 30; }
        @Override
        public String getName() { return "John"; }
};

println("anonWorker == worker ? " + anonymousWorker.equals(worker));
println("worker == anonWorker ? " + worker.equals(anonymousWorker))

What should be the expected behavior of this call? How does our anonymous worker differ from the instance created before? It's time to verify the results:

anonymousWorker == worker ? false
worker == anonymousWorker ? false

Even if getters for both classes return the same values, equality check returns false. The reason is that anonymous class has a different type than Worker (cause it's an anonymous class). We can argue now if this is the correct equals behavior or not, but even after the discussion, we are left a bit confused.

How could the Lombok strike back? Let's rerun our tests replacing the equals implementation generated by the IDE with Lombok's annotations:

@EqualsAndHashCode
class Worker { ... }

@EqualsAndHashCode(callSuper = true)
class Developer extends Worker { ... }

The result looks a lot better than previous ones:

worker == developer ? false
developer == worker ? false
anonymousWorker == worker ? true
worker == anonymousWorker ? true

Lombok generated implementation not only fulfills the equality contract but also has more predictable and intuitive behavior. How is that possible? Long story short - to implement it correctly (so the way that Scala did this) we need an additional method called canEqual. There is an excellent article written by the creators of Scala language describing this, so there is no need to repeat it here. You can also inspect how Lombok achieved this by running Delombok on our class:

class Developer extends Worker {
    // rest of the class here...

    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Developer)) return false;
        final Developer other = (Developer) o;
        // we are checking if object we are comparing ourselves to
        // supports objects of our types for equality checks
        if (!other.canEqual((Object) this)) return false;
        if (!super.equals(o)) return false;
        final Object this$language = this.getLanguage();
        final Object other$language = other.getLanguage();
        if (this$language == null ? other$language != null : !this$language.equals(other$language)) return false;
        return true;
    }

    protected boolean canEqual(Object other) {
        return other instanceof Developer;
    }
}

class Worker {
// rest of the class here...

    protected boolean canEqual(Object other) {
        return other instanceof Worker;
    }
}

Data classes for Java

Although Java is getting more and more attention from its maintainers, there are still some important concepts missing. Lack of features like truly immutable collections, pattern matching, built-in lazy evaluation or type extensions is what still differs it from modern languages like Scala or Kotlin. Some of them could be emulated using external libraries like Vavr, but even then this is not something that's available out-of-the-box.

For me, there is also another important pattern missing - the data classes. Almost every Java-based project implements its model or DTO classes like this:

class Book {
    private String title;
    private String author;
    private int numberOfPages;

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public int getNumberOfPages() {
        return numberOfPages;
    }

    public void setNumberOfPages(int numberOfPages) {
        this.numberOfPages = numberOfPages;
    }

    @Override
    public String toString() {
        return "Book{" +
                "title='" + title + '\'' +
                ", author='" + author + '\'' +
                ", numberOfPages=" + numberOfPages +
                '}';
    }
    
    // equals & hashCode are also pretty common here
}

This obviously leads to generating large codebase with questionable readability. Most of Lombok users would probably say now, that this library may greatly reduce it with @Getter, @Setter, @ToString and @EqualsAndHashCode annotations. This is something that I could not disagree, but why there still exist @Data annotation combining all of the mentioned ones? The answer is - to introduce data classes concept for Java!

Good code is all about the clear intentions. This is why we are using design patterns and why using functional solutions for many problems are so natural to apply. We want to make our code understandable and as expressive as possible. Data classes concept is exactly the same - we want to say loud and clear that some class will represent data rather than behavior/logic, so in that case, providing only fields definitions is more than sufficient:

@Data
public class Book {
    // mutable data class
    private String title;
    private String author;
    private int numberOfPages;
}

Anyone who will see a code like this should not have any doubts about the intentions. Additionally, Lombok easily allows us to make our class immutable with just a single final for each field (thanks to auto-generated all fields constructor):

@Data
public class Book {
    // this class is immutable!
    private final String title;
    private final String author;
    private final int numberOfPages;
}

// ...

Book novel = new Book("Famous Novel", "A. Uthor", 244);

You can even go one step further and apply @Value annotation instead. This will made all fields final by default as well as the class itself.

Conclusion

Lombok feels more like a native language extension rather than a library[2]. It not only helps you with getting rid of the boilerplate but also makes your code more expressive. Code written with Lombok is far more readable and easy to maintain, cause getters, setters and methods like equals and hashCode will be automatically updated for you. Equality checks with Lombok are well-designed and predictable in all use cases, so they are both easy & safe to use. Adding Lombok to your project won't give you the same power as using Scala or Kotlin but it will pimp your Java code since the very first use.


  1. There is no solution that is always better than the others - also in that case. IDE generated equals and hashCode are mostly sufficient and there is nothing to be afraid of. This means that IntelliJ Idea is still the great tool :-) Equality checks with inheritance is a controversial topic - because of that, Kotlin prevents inheritance for data classes. ↩︎

  2. There is an excellent Lombok Plugin which - along with annotation processing support - integrates Lombok with IntelliJ Idea seamlessly. ↩︎