Using Builder in Java to Efficiently Create Safe Objects

This article can be read in about 44 minutes.

Introduction

As noted in books like Effective Java (3rd Edition), when designing a class with many parameters, it’s a good idea to consider using a Builder class. This article explains the features and implementation of the Builder class.

When you search for “Java Builder”, you’ll find articles covering both the Builder class described here and the Builder pattern from GoF; however, these two concepts are different. To avoid confusion with the Builder pattern, a design pattern discussed in GoF, this article refers specifically to the Builder class in the narrower sense.

Characteristics of Classes Without a Builder

Example: Using a Constructor for a Specific Purpose

To implement a class with many parameters, one approach is to create a constructor for each specific purpose, as shown below, for example.

public class User {
  private final long id; // Required
  private final String name; // Required
  private int age = 0; // Optional
  private String nickname = "guest"; // Optional
  private String email =""; // Optional
  private String location; // Optional
  private boolean isAdmin = false; // Optional
  // Constructor that sets all parameters
  public User(int id, String name, int age, String nickname, String email, String location, boolean isAdmin) {
    this.id = id;
    this.name = name;
    this.age = age;
    this.nickname = nickname;
    this.email = email;
    this.location = location;
    this.isAdmin = isAdmin;
  }
  // Constructor to set only required parameters
  public User(int id, String name) {
    this.id = id;
    this.name = name;
  }
  // Constructor to set required parameters + optional parameters
  public User(int id, String name, int age) {
    this.id = id;
    this.name = name;
    this.age = age;
  }
  public User(int id, String name, String nickname) {
    this.id = id;
    this.name = name;
    this.nickname = nickname;
  }
  // ...Omitted...
}

An example of using the User class above is as follows:

User user = new User(1, "user", 30, "foo", "user@sample.com", "Japan", true);

If the implementation approach is to create purpose-specific constructors as shown above, the number of constructors can grow quickly.
You might have a constructor that sets all parameters, another that sets only the required parameters, one that only takes the optional age parameter, another that only takes nickname, and so on. The more parameters there are, the more constructor variations you may end up with.

Another drawback is that it can be challenging to understand what each constructor argument represents.
For instance, what does the fourth argument in the following code represent?
new User(1, "user", 30, "foo", "user@sample.com", "Japan", true);
At first glance, the code is not easy to read.

Also, when there are consecutive arguments of the same type, there’s a risk of accidentally swapping two parameters.
Furthermore, if implemented incorrectly, these mistakes won’t trigger compile-time errors, making them harder to detect.

As you can see, the above User class is neither very readable nor maintainable.

Example: Setting Values with a Setter After Instantiation

Alternatively, the implementation might look something like this:

public class User {
  private String name;
  private int age = 0;
  private String nickname = "guest";
  private String email ="";
  private String location;
  private boolean isAdmin = false;

  public User() {
  }

  // The following contains the setter for each field
  public void setName(String name) { this.name = name; }
  public void setAge(int age) { this.age = age; }
  // ...Omitted...
}

Here’s an example of using the User class above:

User user = new User();
user.setName("user");
user.setAge(30);
user.setNickname("foo");
user.setEmail("user@sample.com");
user.setLocation("Japan");
user.setAdmin(true);

The main drawback of this class is that it is not immutable.

Because it includes setters, the field values in the User class can be changed at any time.
If a bug occurs when someone uses this class in an inconsistent state—due to either an incorrectly implemented setter or a missing setter call—it may be difficult to trace the bug’s cause. This is because the implementation error and the location where the bug appears might be far apart.

Even if incorrect values are not set, managing state transitions and timing within the class makes the code more complex.
The greater the complexity, the harder it is to investigate issues, and the higher the likelihood of bugs.

As noted above, mutable classes have the disadvantage of being less reliable and more challenging to manage in terms of state.

Characteristics of the Builder Class

Conversely, if you use a User class that implements a Builder, the code will look like this:
(The contents of the Builder will be explained later.)

User user = new User.Builder()
    .setName("user")
    .setAge(30)
    .setNickname("guest")
    .setEmail("user@sample.com")
    .setLocation("Japan")
    .setAdmin(true)
    .build();

With new User.Builder(), you create a Builder instance, then call the Builder’s setters to set values, and finally, the build method creates the User instance.

Compared to preparing a constructor for each purpose, as shown earlier, the code above makes it easier to understand both the values being set and their meanings.
You can call only the necessary setters to set values, which makes this approach more convenient than writing and calling a dedicated constructor for each purpose.

Additionally, by not implementing setters in the instances created by the Builder, the User instance becomes immutable.

As you can see, using the Builder method solves the drawbacks mentioned in the examples of preparing purpose-specific constructors and setting values with setters after instance creation.

Implementing the Builder Class

Creating a Custom Builder Class

An example implementation of the Builder for the User class, as described above, is as follows:

package com.example;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import com.example.Email;

public class User {
  // Builder class
  public static class Builder {
    // Required parameters
    private final long id;
    private final String name;
    // Optional parameters
    private int age = 0;
    private boolean isAdmin = false;
    private String nickname = "guest";
    private List<String> groups = new ArrayList<>();
    private Email email = null;

    // Constructor arguments are mandatory parameters
    public Builder(long id, String name) {
      // Argument validation
      if (id < 1L) { throw new IllegalArgumentException("id must be greater than 0."); }
      if (name == null || name.isEmpty()) { throw new IllegalArgumentException("name must be not empty."); }
      this.id = id;
      this.name = name;
    }

    // Setter for each field
    public Builder setAge(int age) {
      // Argument validation
      if (age < 0) { throw new IllegalArgumentException("age must be greater than or equal to 0."); }
      this.age = age;
      return this; // Return the Builder instance itself to enable method chaining
    }
    public Builder setAdmin(boolean isAdmin) {
      this.isAdmin = isAdmin;
      return this;
    }
    public Builder setNickname(String nickname) {
      if (nickname == null) { throw new NullPointerException("nickname must be not null."); }
      this.nickname = nickname;
      return this;
    }
    public Builder addGroup(String group) {
      groups.add(group);
      return this;
    }
    public Builder setGroups(List<String> groups) {
      if (groups == null) { throw new NullPointerException("groups must be not null."); }
      this.groups = new ArrayList<>(groups); // Defensive copy in case of immutability
      return this;
    }
    public Builder setEmail(Email email) {
      this.email = email;
      return this;
    }
    // Method to create a User object
    public User build() {
      return new User(this);
    }

    @Override
    public String toString() {
      return String.format(
          "Builder(id=%d, name=%s, age=%d, isAdmin=%b, nickname=%s, groups=%s, email=%s)", id, name,
          age, isAdmin, nickname, groups, email);
    }
  }

  // Each field should be marked as final
  private final long id;
  private final String name;
  private final int age;
  private final boolean isAdmin;
  private final String nickname;
  private final List<String> groups;
  private final Email email;

  // Keep the constructor private so instances can only be created via the Builder
  private User(Builder builder) {
    // Validate multiple parameters
    if (builder.isAdmin && builder.age < 20) { throw new IllegalStateException("Admin's age must be greater than or equal to 20."); }
    id = builder.id;
    name = builder.name;
    age = builder.age;
    isAdmin = builder.isAdmin;
    nickname = builder.nickname;
    groups = Collections.unmodifiableList(builder.groups); // Ensure immutability
    email = builder.email;
  }

  public long getId() { return id; }
  public String getName() { return name; }
  public int getAge() { return age; }
  public boolean isAdmin() { return isAdmin; }
  public String getNickname() { return nickname; }
  public List<String> getGroups() { return groups; }
  public Email getEmail() { return email; }

  @Override
  public String toString() {
    return String.format(
        "User(id=%d, name=%s, age=%d, isAdmin=%b, nickname=%s, groups=%s, email=%s)", id, name, age,
        isAdmin, nickname, groups, email);
  }
}

The Builder class should be implemented within the class for the instance you want to create.

The Builder class typically includes three types of methods: a constructor, setters for various values, and a build method to generate the desired instance.

Setters in the Builder

Implement a setter for each field.
Within each setter, start by validating the input values to immediately throw an exception if an invalid value is passed.
In the example code, the setAge method throws an exception if the age argument is negative, ensuring that the age value in the Builder is always 0 or greater.

public Builder setAge(int age) {
  // Argument validation
  if (age < 0) { throw new IllegalArgumentException("age must be greater than or equal to 0."); }
  this.age = age;
  return this; // Return the Builder instance itself to enable method chaining
}

In the code example, throw new IllegalArgumentException(...) is used for simplicity, but in a real project, it’s best to centralize validation logic and call it as needed.
For more on Java validation methods, refer to my previous article, “How to Use Java’s Validation Methods and a Comparison of Major Libraries“.

At the end of each setter, include return this;.
This allows the Builder to be called in a chain, as in Builder.setXxx(value).setXxx(value)....

When implementing a setter for a collection like List or Map, it’s usually sufficient to provide two methods: one that accepts the entire collection as an argument and sets it directly, and another that adds individual elements.
In the example code, I have setGroups(List<String> groups) for setting the entire groups list and addGroup(String group) for adding single elements.
If needed, you could also add methods like removeGroup(int index) to remove an element or addAllGroups(List<String> groups) to merge a collection into the field.

public Builder addGroup(String group) {
  groups.add(group);
  return this;
}
public Builder setGroups(List<String> groups) {
  if (groups == null) { throw new NullPointerException("groups must be not null."); }
  this.groups = new ArrayList<>(groups); // Defensive copy in case of immutability
  return this;
}

As a precaution, when accepting a collection as an argument and setting it to a field, it’s good practice to create a defensive copy in case the argument is immutable.
In the example, new ArrayList<>(groups); is used to ensure that even if the original collection is immutable, it will be mutable when assigned to the field.

Constructor of the Builder

Parameters that are essential for the instance to be created are set as arguments to the constructor of the Builder class.
This helps prevent any omission of required parameters.
Since the Builder class constructor is the first method called, these mandatory parameters are set at this initial stage.
Like with the setters, validation is performed to throw an exception early if any parameter is invalid.

public Builder(long id, String name) {
  // Argument validation
  if (id < 1L) { throw new IllegalArgumentException("id must be greater than 0."); }
  if (name == null || name.isEmpty()) { throw new IllegalArgumentException("name must be not empty."); }
  this.id = id;
  this.name = name;
}

In the example above, setters for required parameters are not implemented, so once set, the required parameters cannot be changed mid-build.
However, in some cases, you may need to change these values during the build process.
In such cases, you can implement setters for required parameters as shown below and call these setters within the constructor.

// Required parameters
private long id;
private String name;

public Builder(long id, String name) {
  setId();
  setName();
}

public Builder setId(long id) {
  // Set the id
}

public Builder setName(String name) {
  // Set the name
}

Fields in the Builder

In the code example, default values are set for optional parameters, but you can omit these defaults if desired.

private int age;
private boolean isAdmin;
private String nickname;
private List<String> groups;
private Email email;

If omitted, the following default values are automatically assigned based on the variable type:

TypeVariable TypesDefault Value
Booleanbooleanfalse
Integerint/short/byte/long0
Floating Pointfloat/double0.0
Characterchar\u0000
OtherString/Array/Class, etc.null

In the code example above, the default values for optional parameters are as follows:

private int age = 0;
private boolean isAdmin = false;
private String nickname = "guest";
private List<String> groups = new ArrayList<>();
private Email email = null;

Therefore, in this example, there’s no need to explicitly specify default values for age, isAdmin, or email.
However, I’ve chosen to include them so that they’re immediately visible. This is simply my preference, and if your project has a standard policy, you should follow it.

That said, for strings and collections, it’s generally better to set a default value other than null.
For strings, the default value is null, but if you want to indicate the absence of a value, it’s often more convenient to use an empty string ("").
Allowing null requires handling the possibility of null values carefully, which can increase the risk of bugs.
Similarly, for List types, the default value should ideally be an empty list rather than null.

On the other hand, for fields of a class type, null is commonly used as the default value.
In this example, the field email, which is a custom Email type, has a default value of null.

Fields and Constructor of the Target Class

// Keep the constructor private so instances can only be created via the Builder
private User(Builder builder) {
  // Validate multiple parameters
  if (builder.isAdmin && builder.age < 20) {
    throw new IllegalStateException("Admin's age must be greater than or equal to 20.");
  }
  id = builder.id;
  name = builder.name;
  age = builder.age;
  isAdmin = builder.isAdmin;
  nickname = builder.nickname;
  groups = Collections.unmodifiableList(builder.groups); // Ensure immutability
  email = builder.email;
}

In the target class, implement a single constructor with a private modifier that accepts only one argument—the builder.
This setup ensures that instances can only be created through the builder.

Additionally, any validation involving multiple parameters should be performed at this stage.
In the example code, an exception is thrown if admin is set and age is under 20.

Be sure to add the final modifier to each field.
While it might seem unnecessary since external modification is prevented without setters, the final modifier also prevents any modification from within the User class itself, helping to detect any omissions in setting values during implementation.

// Without the final modifier
private long id;
private String name;
private int age;

private User(Builder builder) {
  id = builder.id;
  // Omitted setting process for the name field
  age = builder.age;

  // User instance with name: null is created!
}

When setting a collection value, like a List or Map, use methods like Collections.unmodifiableList() to ensure immutability.

Simply adding the final modifier to a List or Map field doesn’t make it immutable.
The final modifier prevents reassignment of the field but still allows modification of elements within it.
In the example above, groups = Collections.unmodifiableList(builder.groups); makes groups immutable, but if you set groups = builder.groups;, then elements could still be added to groups in a User instance.

User user = new User.Builder(1L, "user").addGroup("foo").build();
user.getGroups().add("bar"); // group: ["foo", "bar"]

Remember to use methods like Collections.unmodifiableList() to ensure immutability in collection values.

Implementing a Builder with Lombok

Lombok is a widely used library in Java development that automatically generates boilerplate code.
By using Lombok annotations, you can automatically generate methods such as getters, setters, toString, equals, hashCode, and more.

Using Lombok makes implementing a Builder simple.

To implement a class similar to the previous User class in a simplified way with Lombok, the setup might look like this:

package com.example;

import java.util.List;
import com.example.Email;
import lombok.AllArgsConstructor;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.NonNull;
import lombok.Singular;
import lombok.Value;

@AllArgsConstructor(access = AccessLevel.PRIVATE) // Make the constructor private
@Builder(setterPrefix = "set") // Create a Builder, prefixing setter methods with "set"
@Value // Generate getters and other necessary methods
public class User {
  private long id;

  @NonNull // Mark fields that do not accept null values
  private String name;

  private int age;

  private boolean isAdmin;

  @NonNull
  @Builder.Default // Add a default value if you wish to specify one
  private String nickname = "guest";

  @NonNull
  @Singular
  private List<String> groups;

  private Email email;
}

The target class is annotated with @Value and @Builder.
Adding @Builder generates a Builder for the class.
The @Value annotation is equivalent to applying all the following annotations:

  • @ToString
  • @EqualsAndHashCode
  • @AllArgsConstructor
  • @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE)
  • @Getter

In other words, by adding @Value and @Builder to the class, essential methods like toString are implemented automatically, along with the Builder and getters.

Be sure to include @AllArgsConstructor(access = AccessLevel.PRIVATE) as well.
This ensures that the constructor is private.
Without these annotations, the class would be in a state where you could create User instances with new User(...) instead of using the Builder.

The code generated from the above example using Lombok is shown below.
If you’re interested, please take a look—it’s folded due to its length. Some parts have been modified for readability.

Builder class

package com.example;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import com.example.Email;
import lombok.NonNull;

public class User$UserBuilder {
  private long id;
  private String name;
  private int age;
  private boolean isAdmin;
  private String nickname$value;
  private boolean nickname$set;
  private ArrayList<String> groups;
  private Email email;

  User$UserBuilder() {}

  public User$UserBuilder setId(long id) {
    this.id = id;
    return this;
  }
  public User$UserBuilder setName(@NonNull String name) {
    if (name == null) { throw new NullPointerException("name is marked non-null but is null"); }
    else {
      this.name = name;
      return this;
    }
  }
  public User$UserBuilder setAge(int age) {
    this.age = age;
    return this;
  }
  public User$UserBuilder setIsAdmin(boolean isAdmin) {
    this.isAdmin = isAdmin;
    return this;
  }
  public User$UserBuilder setNickname(@NonNull String nickname) {
    if (nickname == null) { throw new NullPointerException("nickname is marked non-null but is null"); }
    else {
      this.nickname$value = nickname;
      this.nickname$set = true;
      return this;
    }
  }
  public User$UserBuilder setGroup(String group) {
    if (this.groups == null) { this.groups = new ArrayList(); }
    this.groups.add(group);
    return this;
  }
  public User$UserBuilder setGroups(Collection<? extends String> groups) {
    if (groups == null) { throw new NullPointerException("groups cannot be null"); }
    else {
      if (this.groups == null) { this.groups = new ArrayList(); }
      this.groups.addAll(groups);
      return this;
    }
  }
  public User$UserBuilder clearGroups() {
    if (this.groups != null) { this.groups.clear(); }
    return this;
  }
  public User$UserBuilder setEmail(Email email) {
    this.email = email;
    return this;
  }

  public User build() {
    List groups;
    switch (this.groups == null ? 0 : this.groups.size()) {
      case 0:
        groups = Collections.emptyList();
        break;
      case 1:
        groups = Collections.singletonList((String)this.groups.get(0));
        break;
      default:
        groups = Collections.unmodifiableList(new ArrayList(this.groups));
    }

    String nickname$value = this.nickname$value;
    if (!this.nickname$set) { nickname$value = User.$default$nickname(); }
    return new User(this.id, this.name, this.age, this.isAdmin, nickname$value, groups, this.email);
  }

  public String toString() {
    // ...Omitted...
  }
}

User class

package com.example;

import java.util.List;
import com.example.Email;
import lombok.NonNull;

public final class User {
  private final long id;
  private final @NonNull String name;
  private final int age;
  private final boolean isAdmin;
  private final @NonNull String nickname;
  private final @NonNull List<String> groups;
  private final Email email;

  private static String $default$nickname() { return "guest"; }

  public static UserBuilder builder() { return new UserBuilder(); }

  public long getId() { return this.id; }
  public @NonNull String getName() { return this.name; }
  public int getAge() { return this.age; }
  public boolean isAdmin() { return this.isAdmin; }
  public @NonNull String getNickname() { return this.nickname; }
  public @NonNull List<String> getGroups() { return this.groups; }
  public Email getEmail() { return this.email; }

  public boolean equals(Object o) {
    // ...Omitted...
  }
  public int hashCode() {
    // ...Omitted...
  }
  public String toString() {
    // ...Omitted...
  }

  private User(long id, @NonNull String name, int age, boolean isAdmin, @NonNull String nickname, @NonNull List<String> groups, Email email) {
    if (name == null) { throw new NullPointerException("name is marked non-null but is null"); }
    else if (nickname == null) { throw new NullPointerException("nickname is marked non-null but is null"); }
    else if (groups == null) { throw new NullPointerException("groups is marked non-null but is null"); }
    else {
      this.id = id;
      this.name = name;
      this.age = age;
      this.isAdmin = isAdmin;
      this.nickname = nickname;
      this.groups = groups;
      this.email = email;
    }
  }
}

Instances of the above User class using Lombok can be created in two ways:

User user1 = new User.UserBuilder()
    .setId(1L)
    .setName("user")
    .build();

User user2 = User.builder()
    .setId(1L)
    .setName("user")
    .build();

While you could use new User.UserBuilder() as shown in the first method, the second method with builder() produces cleaner and more readable code.

In the code example, @Builder(setterPrefix = "set") is used.
The setterPrefix = "set" option specifies that the Builder’s setter methods should be prefixed with set.
If you omit setterPrefix with @Builder, the Builder’s setter methods won’t follow the setXxx format.
The code generated without setterPrefix would look like this:

User user = User.builder()
    .id(1L)
    .name("user")
    .build();

Adding @Builder(setterPrefix = "set") makes it clear that the methods are setters, so I recommend using this prefix.

While Lombok annotations are highly convenient, there are limitations to what can be achieved with annotations alone.
Annotations alone cannot handle validation processes beyond null checks, such as those seen in the User class example, or enforce required parameters in the Builder constructor. These situations require additional implementation beyond what annotations can provide.
Depending on the complexity of your implementation, it may actually be simpler to implement the Builder pattern without Lombok.
If the functionality you need is complex, consider implementing the Builder manually instead of relying on @Builder.

Summary

Using a Builder enables the flexible creation of immutable instances, which is especially useful for classes with many parameters.
This approach has several advantages over creating constructors for various parameter configurations or using setters to assign values after instantiation.
Lombok makes implementing this pattern quick and straightforward.
However, if you require complex validation or unique processing, it may be better to implement the Builder manually rather than using Lombok.

References

Effective Java (3rd Edition)

Comments