How to Implement Builder Pattern for an Immutable Class
The Builder Pattern is a creational design pattern that separates the step-by-step construction of a complex object from its final representation.
🟢 Junior Level
30-Second Summary
The Builder Pattern is a creational design pattern that separates the step-by-step construction of a complex object from its final representation.
For immutable classes, the Builder pattern resolves two primary challenges:
- Eliminates the Telescoping Constructor Anti-Pattern: Avoids overloading numerous constructors with varying parameter counts, where arguments of identical types (e.g.
String name, String email, String role) can easily be swapped by mistake. - Preserves Strict Immutability: The Builder instance is mutable (accumulating state via a fluent chaining API), while the final
build()invocation returns a strictly immutable object withprivate finalfields and no mutators.
Code Example: Telescoping Constructor Anti-Pattern vs. Builder
// ❌ TELESCOPING CONSTRUCTOR ANTI-PATTERN: Which boolean flag means what?
User user = new User("John", "Doe", 30, true, false, true, "john@test.com", null);
// ✅ WITH IMMUTABLE BUILDER PATTERN: Readable, self-documenting, and type-safe
User user = new User.Builder("John", "Doe") // Mandatory parameters
.age(30) // Optional parameters
.email("john@test.com")
.emailVerified(true)
.build(); // Returns strictly immutable User
🟡 Middle Level
The Canonical Implementation Blueprint (Effective Java, Item 2)
import java.util.Objects;
public final class ServerConfig {
// 1. All fields are strictly private and final:
private final String host;
private final int port;
private final int timeoutMs;
private final boolean sslEnabled;
// 2. Private constructor: Enforces creation exclusively via the Builder:
private ServerConfig(Builder builder) {
this.host = builder.host;
this.port = builder.port;
this.timeoutMs = builder.timeoutMs;
this.sslEnabled = builder.sslEnabled;
}
// 3. Expose only getters; zero setters:
public String getHost() { return host; }
public int getPort() { return port; }
public int getTimeoutMs() { return timeoutMs; }
public boolean isSslEnabled() { return sslEnabled; }
// 4. Static nested Builder:
public static class Builder {
// Mandatory fields:
private final String host;
private final int port;
// Optional fields with production defaults:
private int timeoutMs = 5000;
private boolean sslEnabled = false;
public Builder(String host, int port) {
this.host = Objects.requireNonNull(host, "Host cannot be null");
if (port <= 0 || port > 65535) {
throw new IllegalArgumentException("Invalid port number: " + port);
}
this.port = port;
}
public Builder timeoutMs(int timeoutMs) {
this.timeoutMs = timeoutMs;
return this; // Enables fluent method chaining
}
public Builder sslEnabled(boolean sslEnabled) {
this.sslEnabled = sslEnabled;
return this;
}
public ServerConfig build() {
// Complex cross-field validation before instantiation:
if (sslEnabled && port == 80) {
throw new IllegalStateException("Port 80 cannot be configured with SSL");
}
return new ServerConfig(this);
}
}
}
Non-Destructive Mutations via toBuilder()
To easily derive modified copies of an existing immutable instance, provide a toBuilder() method:
public Builder toBuilder() {
return new Builder(this.host, this.port)
.timeoutMs(this.timeoutMs)
.sslEnabled(this.sslEnabled);
}
// Usage:
ServerConfig prodConfig = devConfig.toBuilder().port(443).sslEnabled(true).build();
Lombok Integration: @Value, @Builder, and @Singular
In production Spring Boot microservices, boilerplate is often reduced via Project Lombok:
@Value // Generates final class, private final fields, getters, equals/hashCode/toString
@Builder(toBuilder = true)
public class Order {
String orderId;
// @Singular generates item(String item) methods and wraps collection in List.copyOf() on build()
@Singular
List<String> items;
}
🔴 Senior Level
Defending Against the Builder Reuse Exploit
If a Builder accumulates elements in a mutable collection (List<String> items), the immutable class must defend itself against post-construction mutations on the builder:
// ❌ VULNERABLE IMPLEMENTATION:
public Order build() {
return new Order(this.orderId, this.items); // Direct reference leak!
}
// Exploit workflow:
Order.Builder builder = Order.builder().orderId("ORD-1").addItem("Shoes");
Order order = builder.build();
builder.addItem("Hacked Item"); // 💥 Mutates the previously published immutable order!
// ✅ BULLETPROOF IMPLEMENTATION:
private Order(Builder builder) {
this.orderId = builder.orderId;
// Defensive copy inside target constructor neutralizes builder reuse leaks:
this.items = (builder.items == null) ? List.of() : List.copyOf(builder.items);
}
Step Builder Pattern (Compile-Time Enforced Sequence)
When multiple mandatory parameters must be provided in a strict sequence, use the Step Builder (Staged Builder) Pattern using nested interfaces:
public class UserRegistration {
public interface NameStep { EmailStep withName(String name); }
public interface EmailStep { PasswordStep withEmail(String email); }
public interface PasswordStep { BuildStep withPassword(String password); }
public interface BuildStep {
BuildStep withAge(int age); // Optional parameter
UserRegistration build();
}
public static NameStep builder() {
return new Steps();
}
private static class Steps implements NameStep, EmailStep, PasswordStep, BuildStep {
private String name, email, password;
private int age;
public EmailStep withName(String name) { this.name = name; return this; }
public PasswordStep withEmail(String email) { this.email = email; return this; }
public BuildStep withPassword(String pwd) { this.password = pwd; return this; }
public BuildStep withAge(int age) { this.age = age; return this; }
public UserRegistration build() { return new UserRegistration(this); }
}
}
Architectural Win: The Java compiler forbids invoking .build() until all mandatory step methods have been called in exact order.
Builders for Java Records
While Java records offer concise canonical constructors, declaring a static builder for records with 6+ components and optional defaults is a recognized enterprise practice:
public record UserDTO(String id, String name, String email, int roleLevel, boolean active) {
public static class Builder {
private String id;
private String name;
private String email = "unspecified@domain.com";
private int roleLevel = 1;
private boolean active = true;
public Builder(String id, String name) {
this.id = id;
this.name = name;
}
public Builder email(String email) { this.email = email; return this; }
public Builder roleLevel(int roleLevel) { this.roleLevel = roleLevel; return this; }
public Builder active(boolean active) { this.active = active; return this; }
public UserDTO build() {
return new UserDTO(id, name, email, roleLevel, active);
}
}
}
4 Tricky Questions
1. What vulnerability arises if build() passes the builder’s internal ArrayList directly into the private constructor of the immutable class?
Answer:
This introduces a mutable state leak via builder reuse.
If the calling code preserves a reference to the builder instance after calling Order order = builder.build(), and subsequently invokes builder.addItem("New Item"), the underlying list inside order is mutated in place. This breaks the immutability invariant and introduces concurrency data races.
To maintain strict immutability, either build() must pass List.copyOf(this.items), or the target class constructor must take an unmodifiable defensive snapshot.
2. How does the Step Builder (Staged Builder) pattern guarantee mandatory fields at compile time?
Answer:
The Step Builder defines a sequence of separate single-method interfaces (NameStep, EmailStep, BuildStep). Each step method returns the interface corresponding to the next required step, while the terminal build() method is exposed only on the final step interface.
The IDE auto-complete and javac compiler will fail compilation if .build() is called prematurely, eliminating runtime validation exceptions for missing mandatory parameters.
3. Where should cross-field business validation be performed: in builder setters, in build(), or in the constructor of the target class?
Answer:
- Simple single-field checks (
Objects.requireNonNull, numeric bounds) should be evaluated immediately in the builder’s setter methods for early Fail-Fast behavior. - Cross-field validation (e.g.
startDate.isBefore(endDate)) must be executed inside the target class’s private constructor after defensive copies are extracted. Placing complex checks only inbuild()leaves an opening if other factory methods, deserializers, or internal mechanisms invoke the constructor directly. The target class constructor is the ultimate guardian of class invariants.
4. Is creating a Builder for a Java record an architectural anti-pattern?
Answer:
No, it is not an anti-pattern when a record contains 5 or more components, particularly when several have identical types (String, boolean) or default fallback values.
The canonical constructor of a record forces callers to supply all arguments in exact positional sequence. Swapping adjacent string or boolean values is a frequent source of defects in large records. An external Builder provides readable, named fluent methods and default assignments, delegating final instantiation safely to the record’s canonical constructor.
🎯 Interview Cheat Sheet
Key Takeaways
- Core Purpose: Safe, fluent, step-by-step construction of complex immutable objects (4+ properties).
- Architecture: Mutable
static class Buildergathers inputs $\to$ targetprivateconstructor freezes fields intoprivate finalinstance state. - Defensive Snapshot: Always take defensive copies of collections (
List.copyOf()) inside the constructor to defend against builder reuse leaks. - Object Evolution: Implement
toBuilder()to permit non-destructive derived copies. - Lombok Ecosystem:
@Value+@Builder(toBuilder = true)+@Singularautomates the entire boilerplate stack.
Construction Strategy Comparison
| Approach | Readability | Argument Reversal Defense | Immutability Support |
|---|---|---|---|
| Telescoping Constructors | 🔴 Poor | ❌ High risk with identical types | ✅ Supported |
| JavaBeans (Setters) | 🟡 Fair | ❌ Temporarily invalid states | ❌ Incompatible (Mutable) |
| Builder Pattern | 🟢 Optimal | 🟢 Zero risk (Named methods) | 🟢 Ideal |
| Record Canonical Constructor | 🟡 Moderate (6+ fields) | ❌ High risk if same types | 🟢 Ideal |
Red Flags to Avoid
- ❌ “The Builder class must also be immutable.” (False; the Builder is a transient mutable accumulator; the generated target class is immutable).
- ❌ “Passing the Builder’s ArrayList directly into the constructor is safe.” (False; continuing to mutate the builder afterwards corrupts the immutable instance).
- ❌ “Use a Builder for every class, even with 1–2 fields.” (Over-engineering; use static factories or standard records for small classes).
Related Topics
- How to Create an Immutable Class in Java — Fundamental immutability rules
- What is Record and How Does It Help Create Immutable Classes — Records vs. Builders
- Are There Disadvantages to Immutable Objects — Allocation trade-offs
- How to Properly Work with Collections in Immutable Classes — Defensive collection strategies