1. Overview
As we step into the world of software development, we often come across situations where we need to create objects with many properties. This can be quite daunting and can make our constructors messy, making our code hard to read. This is where the Builder Design Pattern comes to our rescue.
The Builder Pattern is a special type of java design patterns that helps us construct complex objects step by step. It separates the way an object is built from how it is represented, providing a neat and flexible way to create objects. So, let’s master the Java Builder Pattern.
2. Advantages of Java Builder Pattern
Before we jump into writing code, let’s quickly go over the benefits of using the Java Builder Pattern:
Flexibility – The Builder Pattern decouples the process of building an object from the actual object itself. This allows us to create objects with different configurations without having to clutter our code with multiple constructors or setters.
Readability – The Builder Pattern uses fluent interfaces, which makes our code more readable. This helps us and our fellow developers to understand the process of constructing complex objects easily.
Immutability – Once the construction of an object is complete, builders can ensure that the object remains immutable. This guarantees that the object is thread-safe and cannot be modified unintentionally.
Now that we’ve understood the basics and benefits of the Java Builder Pattern, let’s dive into the code and see it in action.
3. Using Lombok Builder
Lombok is a handy library that makes Java code simpler by automatically creating common methods like getters, setters, equals, hashCode, and even constructors.
One of the standout features of Lombok is its support for the Builder Pattern. By adding the @Builder annotation to a class, Lombok automatically generates a builder class with fluent methods for setting properties. This annotation removes the need for us to manually implement a builder class, significantly cutting down on the amount of code we need to write.
For instance, in our Pizza example, we can use Lombok’s @Builder annotation to automatically generate the builder class. This way, we don’t have to manually write the PizzaBuilder class.
To use Lombok, we need to add its dependency from the Maven central repository to our project:
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</dependency>
Here’s how we can use Lombok’s @Builder annotation:
import lombok.Builder;
import lombok.Singular;
@Builder
public class Pizza {
private String size;
private String crust;
@Singular private List<String> toppings;
}
Now, we can use the builder pattern out of the box to create new Pizza object:
Pizza pizza = Pizza.builder()
.size("Large")
.crust("Thin Crust")
.topping("Mushrooms")
.topping("Pepperoni")
.build();
In this example, the @Builder annotation has generated a builder API for our Pizza class, and the @Singular annotation has been used to handle collections.
4. Implementing Classic Builder Pattern
In the traditional implementation of the Builder Pattern, like in our Pizza example, we create a separate PizzaBuilder inner class. This inner class contains methods to set each property of the Pizza object we’re constructing.
This structured approach allows for a step-by-step configuration process, ensuring simplicity and ease of use. Moreover, it improves code organization and readability, making it easier to understand and maintain:
public class Pizza {
private String size;
private String crust;
private List<String> toppings;
private Pizza(PizzaBuilder builder) {
this.size = builder.size;
this.crust = builder.crust;
this.toppings = builder.toppings;
}
public static class PizzaBuilder {
private String size;
private String crust;
private List<String> toppings;
public PizzaBuilder(String size, String crust) {
this.size = size;
this.crust = crust;
this.toppings = new ArrayList<>();
}
public PizzaBuilder addTopping(String topping) {
this.toppings.add(topping);
return this;
}
public Pizza build() {
return new Pizza(this);
}
}
}
In the PizzaBuilder class, we’ve declared the same set of fields that the Pizza class contains. The PizzaBuilder class provides fluent methods to set each property of the Pizza. It also includes a build() method to create the Pizza instance:
Pizza pizza = new Pizza.PizzaBuilder("Large", "Thin Crust")
.addTopping("Mushrooms")
.addTopping("Pepperoni")
.build();
5. Using Generic Builder Design Pattern
With the introduction of lambda expressions and method references in Java 8, we have new possibilities, including a more generic form of the Builder Pattern. In our Pizza example, we can introduce a GenericBuilder class, which can construct various types of objects by using generics:
public class GenericBuilder<T> {
private final Supplier<T> instantiator;
private List<Consumer<T>> instanceModifiers = new ArrayList<>();
public GenericBuilder(Supplier<T> instantiator) {
this.instantiator = instantiator;
}
public static <T> GenericBuilder<T> of(Supplier<T> instantiator) {
return new GenericBuilder<>(instantiator);
}
public <U> GenericBuilder<T> with(BiConsumer<T, U> consumer, U value) {
Consumer<T> c = instance -> consumer.accept(instance, value);
instanceModifiers.add(c);
return this;
}
public T build() {
T value = instantiator.get();
instanceModifiers.forEach(modifier -> modifier.accept(value));
instanceModifiers.clear();
return value;
}
}
This class follows a fluent interface, starting with the of() method to create the initial object instance. Then, the with() method sets object properties using lambda expressions or method references.
The GenericBuilder provides flexibility and readability, allowing us to construct every object in a concise manner while ensuring type safety. This pattern showcases the expressive power of Java 8 and is an elegant solution for complex construction tasks.
However, a significant drawback is that this solution relies on class setters. This means that our attributes can no longer be final as in the previous Pizza example, thus losing the immutability offered by the Builder Pattern.
Now, we can use our GenericBuilder to create a Pizza.
Pizza pizza = GenericBuilder.of(Pizza::new)
.with(Pizza::setSize, "Large")
.with(Pizza::setCrust, "Thin Crust")
.with(Pizza::addTopping, "Mushrooms")
.with(Pizza::addTopping, "Pepperoni")
.build();
6. Conclusion
The Builder Pattern in Java brings a streamlined way of constructing objects and enhances the readability of our code. With different versions like the Classic, Generic, and Lombok Builder Patterns, we can customize our approach based on our specific requirements.
For instance, in our Pizza example, we saw how these patterns can be applied to create a Pizza object in a more efficient and readable way. By adopting this pattern and using tools like Lombok, we can write cleaner and more efficient code.