🔒 Section 13 · Question #26

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:

  1. 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.
  2. 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 with private final fields 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 in build() 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 Builder gathers inputs $\to$ target private constructor freezes fields into private final instance 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) + @Singular automates 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).