My favorite Java tools, Episode 1 (Lombok)

Lesezeit: 11 Min, veröffentlicht am 28.01.2020
My favorite Java tools, Episode 1 (Lombok)

I am writing Java code for over twenty years now and consider myself an experienced software developer.

But even after being a programmer for such a long time, I was introduced to many useful Java tools when I started working at cosee in April 2019. They made my life so much easier and I wish I would have known them before. Most of these already existed for a couple of years and are widely used by a broad audience.

I felt a bit embarrassed to acknowledge that I haven’t heard of those tools before. I must have been living in a bubble for a while…

This article will introduce Lombok, a simple code-generator. I will present how we are using this tool in our Java projects and show up some difficulties that we encountered with it.

What is Lombok?

As already mentioned, Lombok is a code generator. It allows you to replace a lot of boilerplate code by simple annotations. It uses the annotation processor API of the Java compiler to generate extra code when it encounters certain annotations.

Let’s have a look at some of these:

  • @Getter generates “get”-methods for all fields in a class
  • @Setter generates “set”-methods for all non-final in a class

Both annotations can also be applied to specific fields.

  • @ToString generates a toString-method for the class.
  • @EqualsAndHashCode creates an equals- and a hashCode-method.
  • @RequiredArgsConstructor creates a constructor for all non-final fields in the class.

Finally, the @Data-annotation can be used to get all of these annotations described above at once.

Another annotation that we use frequently is @Builder. It creates a builder class that implements a fluent API for instantiating your class or for calling methods. We will see how this maps into Java code below.

How do we use it?

There are different use cases for those annotations. I would like to present three of them. Two are rather simple and intuitive. The last one is more complex.

POJOs

The most straightforward use of these annotations is to generate POJOs (“Plain Old Java Objects”).

In the book “Clean Code” (2008), Robert C. Martin wrote that you should make a distinction between classes that contain business-logic and classes that are purely for storing data structures. Classes that are used to pass around data should not contain business logic.

The @Data annotation is perfect for data classes, therefore the name “Data”.

@Data
public class Person {
    private String firstName;
    private String lastName;
    private int age;
    private String phone;
    private String email;
}

The generated code contains all getters and setters as well as the important methods equals, hashCode and toString. Without Lombok, that class would contain a lot of boilerplate code. And it would be your responsibility to keep equals, hashCode and toString up to date. Unless your are paid per line-of-code, it makes perfect sense to just use the annotation and save yourself a lot of work.

We can use the class like this:

public class PersonTest {

    @Test
    public void testEquality() {
        Person person1 = new Person();
        person1.setAge(25);
        person1.setFirstName("John");

        Person person2 = new Person();
        person2.setAge(25);
        person2.setFirstName("John");

        assertThat(person1)
                .isEqualTo(person2)
                .hasSameHashCodeAs(person2)
                .hasToString(
                        "Person(firstName=John, lastName=null, " +
                                "age=25, phone=null, email=null)"
                );
    }
}

Immutable POJOs

In many cases, using immutable classes improves the code’s sturdiness greatly. A data-class can be made immutable by simply be declaring all fields as “final”.

@Data
public class ImmutablePerson {
    private final String firstName;
    private final String lastName;
    private final int age;
    private final String phone;
    private final String email;
}

Lombok will now generate a constructor for all fields instead of the setters:

public class ImmutablePersonTest {

    @Test
    public void createImmutablePerson() {
        ImmutablePerson person = new ImmutablePerson(
                "John", "Smith", 25, null, null
        );
        assertThat(person.getFirstName()).isEqualTo("John");
    }
}

At this point, we already encounter the first possible problem or caveat: The order of the constructor parameters is the same as the order of fields in the class. Reordering the fields also changes the order of the constructor parameters.

Why is that a problem?

Let’s say, we come across the class and swap lastName and firstName, because we think that lastName is more important and should come first.

  • Would you expect that you just changed the constructor? Well, you did.
  • The IDE does not notice anything and the code calling the constructor will not be touched.
  • The code will still compile, because both firstName and lastName are strings.

But the code will be wrong. The above test-case will fail:

ImmutablePerson person = new ImmutablePerson(
        "John", "Smith", 25, null, null
);
// assertion will fail: firstName will be "Smith", not "John"
assertThat(person.getFirstName()).isEqualTo("John"); 

That’s why we usually try to use the @Builder annotation to create a builder class.

@Data
@Builder
public class ImmutablePersonWithBuilder {
    private final String firstName;
    private final String lastName;
    private final int age;
    private final String phone;
    private final String email;
}

The @Builder annotation will make the constructor package-private, and add a static builder()-method instead. This method returns a new static inner class called ImmutablePersonWithBuilderBuilder that implements a fluent API for creating objects of the annotated class. In other words:

public class ImmutablePersonWithBuilderTest {

    @Test
    public void buildPerson() {
        ImmutablePersonWithBuilder john = builder()
                .firstName("John")
                .lastName("Smith")
                .age(25)
                .build();

        assertThat(john.getFirstName()).isEqualTo("John");
    }
}

You can now re-order the fields as you want, without breaking the code. The class is immutable and, if you have a lot of fields, the code is also easier to understand compared to a large constructor with five parameters.

We will come back to the @Builder later. But first, another simple use-case.

Spring Beans

The Spring-Framework is a system to weave functional Java classes together in order to create a large application. I don’t want to go into too many details, but just mention this:

When you define a field to a class and want it to be managed by Spring, there are two ways to do this:

  • Add an @Autowired annotation. Spring will inject the correct bean into the field using reflection.
  • Add a constructor parameter and assign the value to the field.

The second way is obviously a case for the @RequiredArgsConstructor annotation. Add the annotation, make all spring-managed fields final and concentrate on writing your methods. No more @Autowired, no large constructors polluting the view onto the class:

@RequiredArgsConstructor
@Service
public class PersonService {
    private final PersonRepository personRepository;

    public void savePerson(Person person) {
        personRepository.save(toEntity(person));
    }
    
    public Person findByLastName(String lastName) {
        return toPerson(personRepository.findByLastName(lastName));
    }

    [...]
}

Creating data for testing purposes

When we create database tests, we save data for testing our queries. In order to test the method called “find person by last-name”, we will perform the following steps:

  • Save a person with the name John Smith to the database
  • Save a person with the name Luke Groundwalker to the database
  • Find a person by the last name “Smith”
  • Verify that the first name is “John”

For different tests we need different data, so what we really need is an easy way to fill our database multiple times.

We usually create a helper class for each entity with methods that build and save entities to the database. Such a class may look like this:

@Service
@RequiredArgsConstructor
public class PersonDatabaseTestUtils {

    private final PersonRepository personRepository;

    public void createPerson(String firstName, String lastName) {
        PersonEntity personEntity = new PersonEntity();
        personEntity.setFirstName(firstName);
        personEntity.setLastName(lastName);
        personEntity.setAge(25);
        personRepository.save(personEntity);
    }
}

Now, we can create person-data rather quickly, as long as we only care about firstNameand lastName and not about the age.

The following test might use it:

@Autowired
private PersonDatabaseTestUtils personDatabaseTestUtils;

@Test
public void findByLastName() {
    personDatabaseTestUtils.createPerson("Joe", "Smith");
    personDatabaseTestUtils.createPerson("Luke", "Groundwalker");

    String smith = personService.findByLastName("Smith").getFirstName();
    assertThat(smith).isEqualTo("Joe");
}

Now assuming, as the application further evolves, we would add a test that uses the attribute age as well. We are now presented with two choices:

  • Add another method to the test-utils that includes the age. If we add a custom method for every new combination of parameters that we need, then we will very quickly have a very large class that is difficult to read and has a lot of redundant code.
  • Refactor the existing method and add an age-parameter. In the long run, this decision will lead to a class with a single createPerson-method, that accepts all fields as parameters. Every test that uses it will have to provide all fields, even though their actual value is not relevant for the current test. When new fields are added, you have changes in the git-history of all of your tests, which make the history unclear and code reviews really difficult.

    Having methods with a lot of parameters is never a good idea, because it is difficult to see which parameter corresponds to which field inside the entity.

A much more flexible way is the @Builder-annotation:

@Builder(
        buildMethodName = "save",
        builderClassName = "PersonTestBuilder",
        builderMethodName = "personBuilder"
)
public void createPerson(String firstName, String lastName) {
    PersonEntity personEntity = new PersonEntity();
    personEntity.setFirstName(firstName);
    personEntity.setLastName(lastName);
    personRepository.save(personEntity);
}

The builder can be obtained using the builder() method that Lombok generates (the name can be changed via annotation parameter):

@Test
public void findByLastName_usingBuilder() {
    personDatabaseTestUtils.personBuilder()
            .firstName("Joe")
            .lastName("Smith")
            .save();
    personDatabaseTestUtils.personBuilder()
            .firstName("Luke")
            .lastName("Groundwalker")
            .save();

    Person smith = personService.findByLastName("Smith");
    assertThat(smith.getFirstName()).isEqualTo("Joe");
}

Now, adding the age-parameter is not a problem. The existing test can stay as it is and we can use the new parameter in our new tests. We just have to make sure that a default value for age is saved if the parameter is null.

But instead of checking every parameter in the createPerson function, we can also provide a pre-filled builder with default values:

public PersonTestBuilder builderWithDefaultValues() {
    return personBuilder()
            .firstName("John")
            .lastName("Smith")
            .age(25)
            .phone("555-1234")
            .email("mail@example.org");
}

@Builder(
        buildMethodName = "save",
        builderClassName = "PersonTestBuilder",
        builderMethodName = "personBuilder"
)
public void createPerson(String firstName, String lastName, int age, String phone, String email) {
    PersonEntity personEntity = new PersonEntity();
    personEntity.setFirstName(firstName);
    personEntity.setLastName(lastName);
    personEntity.setAge(age);
    personEntity.setEmail(email);
    personEntity.setPhone(phone);
    personRepository.save(personEntity);
}

Now, as long as we use builderWithDefaultValues in our tests, we can just set the values we need in our test:

@Test
public void findByMinimumAge_usingBuilderWithDefaultValues() {
    personDatabaseTestUtils.builderWithDefaultValues().age(30).save();
    personDatabaseTestUtils.builderWithDefaultValues().age(25).save();
    personDatabaseTestUtils.builderWithDefaultValues().age(900).save();

    assertThat(personService.findByMinimumAge(30)).hasSize(2);
}

As more fields are being added to the PersonEntity, we just add a parameter to the createPerson-method and a default value to builderWithDefaultValues. The existing test-cases will continue to pass and new tests can use the field.

Caveats

Lombok has made many things easier for us. But there are also a few issues to mention:

Using Lombok with other annotation processors

We also use the MapStruct library, which generates code as well by using an annotation processor.

Generally, the two work well together as long as you only use a specific set of annotations. MapStruct is able to use getters and setters generated by Lombok when generating its own code.

We had some compilation errors when we tried to use MapStruct to create POJOs with a @Builder-annotation though. It has something to do with the order in which the annotation processors are running. Details can be found in the issue #1538 inside the Lombok repository.

Cyclic references

If you have cyclic references in your @Data-annotated POJOs, the generated toString, equals and hashCode methods will run into an endless loop. You can add an @Exclude-annotation to the fields causing the cycle, but we usually revert to using a @Getter and a @Setter in those cases. This is mostly relevant for Hibernate entities which often have cyclic references.

About static imports

Static imports are resolved by the Java compiler before the annotation processors run. That is why you cannot statically import a builder()-method defined in the same maven module.

Even worse is that after doing this, I saw many weird compilation errors in a lot of other unrelated classes with lombok annotations. It was really hard to trace down the errors because there were so many false positives shown in the IDE.

Note: I had experienced this error in the last months, but I could not reproduce it while writing this article. But the documentation mentions it as well, so I believe it is still there.

Integrating Lombok into your IDE

When using Lombok, your plain Java code will technically be invalid until the annotation processing is done. IDEs usually do their own code analysis, so they will show errors unless you do some additional setup.

For IntelliJ Idea, I installed the Lombok-plugin from the marketplace and so far, it works without any occuring problems. There is also an installer for the Eclipse IDE, but I haven’t tried this one out yet.

A cool feature of the Idea plugin is the “Delombok” command. Open your Java class, go to the “Refactor” menu and choose “Delombok”. The command replaces the Lombok-annotations by the generated code. That way you can see exactly what Lombok generates from your annotations.

Conclusion

Lombok can make your life as a programmer a lot easier, reduce the size of your code and improves readability. But the magic it does can also lead to problems that you don’t easily understand. There are a lot of annotations that I haven’t mentioned here (like @Slf4j to generate loggers and many more).

Further links and readings regarding Lombok

Tags

Verfasst von:

Foto von Nils

Nils

Nils ist Full Stack Developer bei cosee und treibt sich sowohl im Front- als auch im Backend rum.