Create lean Java code using Project Lombok to eliminate unnecessary boilerplate code

In the Java world, certain tools and libraries have become indispensable to me. Before Java 8, dependencies like Google Guava and Joda Time were practically ubiquitous in my projects, no matter the specific domain.

While not your typical library or framework, Lombok has also earned a permanent spot in my project builds. Despite its maturity and long existence since its 2009 release, Lombok still feels somewhat underappreciated, considering its remarkable ability to streamline Java’s inherent verbosity.

This post delves into the features that make Lombok such a valuable asset in a Java developer’s toolkit.

Project Lombok

Java’s strengths extend beyond the exceptional JVM. It’s a mature and efficient language, boasting a vast and active community and ecosystem.

However, Java’s design choices and inherent quirks sometimes lead to verbosity. Certain constructs and class patterns, often essential for Java developers, contribute to code that’s lengthy, primarily serving to satisfy constraints or framework conventions rather than adding substantial value.

This is where Lombok steps in, dramatically reducing the “boilerplate” code we need to write. The creators of Lombok are clearly brilliant minds with a sense of humor—their this intro at a previous conference is not to be missed!

Let’s explore how Lombok achieves this and look at some practical examples.

Lombok: Under the Hood

Lombok functions as an annotation processor, essentially injecting code into your classes during compilation. Annotation processing was introduced in Java 5, allowing developers to include annotation processors (either custom-built or from third-party dependencies like Lombok) in the build classpath. During compilation, when the compiler encounters an annotation, it queries the classpath, effectively asking if any processors are interested in handling it. If a processor “raises its hand,” the compiler passes control and compilation context to it for processing.

While common use cases for annotation processors involve generating new source files or performing compile-time checks, Lombok takes a different route. It modifies the compiler’s internal data structures, specifically the abstract syntax tree (AST), which represent the code. This indirect manipulation of the AST ultimately affects the final bytecode generation.

This unconventional and rather intrusive approach has sometimes led to Lombok being labeled as a “hack.” While there’s some truth to this characterization, I prefer to view it as a “clever, technically ingenious, and innovative workaround.”

Admittedly, some developers avoid Lombok for this reason. However, my experience has shown that the productivity gains far outweigh these concerns. I’ve been using it confidently in production environments for years.

Two key reasons solidify my appreciation for Lombok:

  1. Clean and Concise Code: Lombok helps me write code that is clear, concise, and focused. While opinions may vary, I find Lombok-annotated classes to be highly expressive and intention-revealing.
  2. Rapid Prototyping: When starting a project, I often begin with rudimentary classes, iteratively refining them as my understanding of the domain model evolves. Lombok accelerates this process by eliminating the need to constantly adjust generated boilerplate code.

Bean Pattern and Common Object Methods

A significant portion of Java’s tools and frameworks rely on the Bean Pattern. These serializable classes typically have a default no-argument constructor (and potentially others) and use getters and setters, often backed by private fields, to expose their state. We encounter them frequently when working with JPA or serialization frameworks like JAXB or Jackson.

Imagine a User bean with up to five attributes (properties). We want an additional constructor for all attributes, a meaningful string representation, and equality/hashing based on the email field:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class User implements Serializable {

    private String email;

    private String firstName;
    private String lastName;

    private Instant registrationTs;

    private boolean payingCustomer;
    
    // Empty constructor implementation: ~3 lines.
    // Utility constructor for all attributes: ~7 lines.
    // Getters/setters: ~38 lines.
    // equals() and hashCode() as per email: ~23 lines.
    // toString() for all attributes: ~3 lines.

    // Relevant: 5 lines; Boilerplate: 74 lines => 93% meaningless code :(
    
}

For the sake of brevity, I’ve replaced the actual method implementations with comments indicating the method names and line counts. The actual boilerplate code would have constituted over 90% of the class!

Furthermore, any changes to the attributes (e.g., renaming email to emailAddress or changing registrationTs from Instant to Date) would necessitate tedious modifications to getters, setters, constructors, and more—a time-consuming endeavor for code that doesn’t directly contribute to business logic.

Let’s see how Lombok streamlines this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;

@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@ToString
@EqualsAndHashCode(of = {"email"})
public class User {

    private String email;

    private String firstName;
    private String lastName;

    private Instant registrationTs;

    private boolean payingCustomer;

}

With the addition of a few lombok.* annotations, we’ve achieved the desired outcome. This is all the code we need to write! Lombok takes care of generating everything else during compilation (as shown in the IDE screenshot below).

IDE Screenshot

Notice that the NetBeans inspector (this applies to other IDEs as well) recognizes the compiled bytecode, including Lombok’s contributions. Here’s a breakdown:

  • @Getter and @Setter at the class level instruct Lombok to generate getters and setters for all attributes. Annotating individual fields allows for selective generation.
  • @NoArgsConstructor provides a default empty constructor for bean compliance, while @AllArgsConstructor generates a constructor accepting all attributes.
  • @ToString auto-generates a convenient toString() method, displaying all class attributes with their names by default.
  • @EqualsAndHashCode, parameterized with the relevant field (email in this case), defines equals() and hashCode() based on the email field.

Customizing Lombok Annotations

Let’s customize Lombok’s behavior using the same User example:

  • Constructor Visibility: We’ll restrict the visibility of the default constructor to AccessLevel.PACKAGE using @NoArgsConstructor(access = AccessLevel.PACKAGE), encouraging the use of the constructor that takes all fields.
  • Null Safety: To prevent null values from being assigned to fields, we’ll annotate them with @NonNull. Lombok will generate null checks within the constructor and setters, throwing NullPointerException as needed.
  • Excluding Fields from toString(): For security reasons, we’ll exclude the password attribute from the generated toString() method using @ToString(exclude = "password").
  • Restricting Mutability: While we’ll expose state publicly via getters using @Getter, we’ll limit mutability from outside the class by setting @Setter to AccessLevel.PROTECTED.
  • Custom Logic in Setters: We can override Lombok’s generated methods with our own implementations. For instance, to enforce constraints on the email field, we can manually implement the setEmail() method.

Here’s the updated User class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter(AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PACKAGE)
@AllArgsConstructor
@ToString(exclude = {"password"})
@EqualsAndHashCode(of = {"email"})
public class User {

    private @NonNull String email;

    private @NonNull byte[] password;

    private @NonNull String firstName;
    private @NonNull String lastName;

    private @NonNull Instant registrationTs;

    private boolean payingCustomer;

    protected void setEmail(String email) {
        // Check for null (=> NullPointerException) 
        // and valid email code (=> IllegalArgumentException)
        this.email = email;
    } 
    
}

Note that some annotations use plain strings for class attribute names. Lombok provides safety by throwing compile-time errors if we mistype or reference non-existent fields.

As mentioned earlier, if a method or constructor is manually implemented, Lombok will recognize and skip its generation, ensuring flexibility.

Immutable Data Structures

Creating immutable data structures, often referred to as “value types,” is another area where Lombok shines. While some languages have native support for immutability, there’s ongoing work like a proposal to incorporate it into future Java versions.

Let’s consider modeling a response to a user login action (LoginResponse). This object would be instantiated and returned to other application layers (e.g., serialized as JSON in an HTTP response) and wouldn’t require mutability. Lombok allows us to represent this concisely:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import lombok.experimental.Wither;

@Getter
@RequiredArgsConstructor
@ToString
@EqualsAndHashCode
public final class LoginResponse {

    private final long userId;
    
    private final @NonNull String authToken;
    
    private final @NonNull Instant loginTs;

    @Wither
    private final @NonNull Instant tokenExpiryTs;
    
}

Key points:

  • @RequiredArgsConstructor: Generates a constructor for all final fields not explicitly initialized.
  • @Wither: Facilitates creating modified copies of immutable objects. In this case, @Wither on tokenExpiryTs generates a withTokenExpiryTs(Instant tokenExpiryTs) method that returns a new LoginResponse with all values identical to the original instance, except for the updated tokenExpiryTs. Placing @Wither on the class declaration would generate with-methods for all fields.

@Data and @Value Shorthands

Recognizing the prevalence of these patterns, Lombok offers convenient shortcuts:

  • @Data: Annotating a class with @Data is equivalent to using @Getter, @Setter, @ToString, @EqualsAndHashCode, and @RequiredArgsConstructor.
  • @Value: This annotation transforms your class into an immutable (and final) one, similar to using the combination mentioned above for @Data.

Builder Pattern

As classes grow, managing constructors with numerous arguments can become unwieldy. Let’s revisit our User example. The constructor now requires up to six arguments, and we might need to set default values for lastName and payingCustomer.

Lombok’s @Builder feature provides an elegant solution using the Builder Pattern. Let’s add it to our User class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;

@Getter
@Setter(AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PACKAGE)
@AllArgsConstructor
@ToString(exclude = {"password"})
@EqualsAndHashCode(of = {"email"})
@Builder
public class User {

    private @NonNull String email;

    private @NonNull byte[] password;

    private @NonNull String firstName;
    private @NonNull String lastName = "";

    private @NonNull Instant registrationTs;

    private boolean payingCustomer = false;

}

We can now effortlessly create new User instances:

1
2
3
4
5
6
7
User user = User
        .builder()
            .email("miguel.garcia@toptal.com")
            .password("secret".getBytes(StandardCharsets.UTF_8))
            .firstName("Miguel")
            .registrationTs(Instant.now())
        .build();

The Builder Pattern becomes increasingly valuable as the complexity of our classes increases.

Delegation and Composition

Java’s verbosity becomes apparent when adhering to the principle of “favor composition over inheritance” and implementing composition. Manually writing delegating methods can be tedious.

Lombok’s @Delegate offers a solution. Let’s introduce a new concept of ContactInformation, which our User class will possess, and other classes might need as well. We’ll represent this with an interface:

1
2
3
4
5
6
7
public interface HasContactInformation {

    String getEmail();
    String getFirstName();
    String getLastName();

}

Next, we’ll create a ContactInformation class using Lombok:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import lombok.Data;

@Data
public class ContactInformation implements HasContactInformation {

    private String email;

    private String firstName;
    private String lastName;

}

Finally, we’ll refactor our User class to include ContactInformation, leveraging Lombok to generate the necessary delegating methods to fulfill the interface contract:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.Setter;
import lombok.ToString;
import lombok.experimental.Delegate;

@Getter
@Setter(AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PACKAGE)
@AllArgsConstructor
@ToString(exclude = {"password"})
@EqualsAndHashCode(of = {"contactInformation"})
public class User implements HasContactInformation {

    @Getter(AccessLevel.NONE)
    @Delegate(types = {HasContactInformation.class})
    private final ContactInformation contactInformation = new ContactInformation();

    private @NonNull byte[] password;

    private @NonNull Instant registrationTs;

    private boolean payingCustomer = false;

}

Observe that we didn’t need to implement the methods from HasContactInformation. Lombok handles this, delegating calls to our ContactInformation instance.

We’ve also prevented external access to the delegated instance by using @Getter(AccessLevel.NONE), effectively suppressing getter generation for it.

Checked Exceptions

Java’s distinction between checked and unchecked exceptions can sometimes lead to overly verbose code. controversy and criticism arises when exception handling, particularly with APIs throwing checked exceptions, forces us to either catch them or propagate them, potentially cascading this burden to callers.

Consider this scenario:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class UserService {

    public URL buildUsersApiUrl() {
        try {
            return new URL("https://apiserver.com/users");
        } catch (MalformedURLException ex) {
            // Malformed? Really?
            throw new RuntimeException(ex);
        }
    }

}

This pattern is all too familiar. While confident that the URL is valid, the checked exception thrown by the URL constructor forces us to either catch it or declare our method as throwing it, perpetuating the issue. Wrapping checked exceptions in RuntimeException is a common practice, but this approach can become unwieldy as the number of checked exceptions increases.

Lombok’s @SneakyThrows annotation provides relief. It wraps any checked exceptions that might be thrown within a method into an unchecked exception, simplifying our code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import lombok.SneakyThrows;

public class UserService {

    @SneakyThrows
    public URL buildUsersApiUrl() {
        return new URL("https://apiserver.com/users");
    }

}

Logging

Adding logger instances to classes is a standard practice: (SLF4J example)

1
private static final Logger LOG = LoggerFactory.getLogger(UserService.class);

Lombok offers a convenient annotation that generates a logger instance with a customizable name (defaulting to “log”) and supports popular logging frameworks. Here’s an SLF4J-based example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class UserService {

    @SneakyThrows
    public URL buildUsersApiUrl() {
        log.debug("Building users API URL");
        return new URL("https://apiserver.com/users");
    }

}

Annotating Generated Code

While Lombok generates code for us, it doesn’t restrict our ability to annotate that generated code. We can guide Lombok on how to annotate generated elements using a specific notation.

Let’s look at an example involving dependency injection. Our UserService uses constructor injection to obtain references to UserRepository and UserApiClient.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.mgl.toptal.lombok;

import javax.inject.Inject;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor(onConstructor = @__(@Inject))
public class UserService {

    private final UserRepository userRepository;
    private final UserApiClient userApiClient;

    // Instead of:
    // 
    // @Inject
    // public UserService(UserRepository userRepository,
    //                    UserApiClient userApiClient) {
    //     this.userRepository = userRepository;
    //     this.userApiClient = userApiClient;
    // }

}

This example illustrates how to annotate a generated constructor. Lombok extends this capability to generated methods and parameters as well.

Exploring Further

This post highlighted the Lombok features I’ve found most beneficial. However, Lombok offers a wide range of additional features and customizations.

The Lombok’s documentation is an invaluable resource, providing comprehensive explanations and examples for each annotation. I encourage you to delve deeper into Lombok’s documentation.

The project site also outlines how to integrate Lombok with various programming environments. Support for popular IDEs like Eclipse, NetBeans, and IntelliJ ensures a seamless experience. I personally use Lombok without issues across these IDEs on a project-by-project basis.

Delombok: Reverting to Source

Delombok is a handy tool within the Lombok ecosystem. It generates Java source code from Lombok-annotated code, replicating the behavior of the Lombok-generated bytecode.

This is an excellent option for teams hesitant about committing to Lombok. It allows you to experiment with Lombok without vendor lock-in. If needed, you can use delombok to generate the equivalent source code, eliminating the Lombok dependency.

Delombok is also invaluable for understanding Lombok’s inner workings. Integrating it into your build process is straightforward.

Alternatives

The Java landscape offers various tools that leverage annotation processors for compile-time code enhancement, such as Immutables and Google Auto Value. These tools often overlap with Lombok in terms of functionality. I have a particular fondness for Immutables and have used it in several projects.

It’s also worth mentioning “bytecode enhancing” tools like Byte Buddy and Javassist. These tools typically operate at runtime and fall outside the scope of this post.

Concise and Maintainable Java

Modern JVM languages like Groovy, Scala, and Kotlin provide more expressive and concise syntax. However, if you’re working within a Java-only codebase, Lombok proves to be an invaluable tool for writing cleaner, more maintainable, and more expressive Java code.

Licensed under CC BY-NC-SA 4.0