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.
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 classBoth 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.
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.
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)"
);
}
}
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.
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.
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:
@Autowired
annotation. Spring will inject the correct bean into the field using reflection.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));
}
[...]
}
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:
John Smith
to the databaseLuke Groundwalker
to the databaseFor 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
firstName
and 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:
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.
Lombok has made many things easier for us. But there are also a few issues to mention:
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.
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.
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.
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.
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).