Builder Pattern and Immutable Objects
Fast recall
Quick revision
Core answer
Interview-ready guide
Interview answer
A builder can be a mutable workspace that creates an immutable result. The builder collects optional values, then build() passes them to an object with private final state and no mutating methods. Final references alone are not enough: if the result stores a caller-owned list or array, the caller can still modify what the object sees. I make defensive copies during construction and expose immutable values or copies from accessors, including considering whether collection elements are mutable. I also validate the full invariant at the final construction boundary. This gives a readable construction API without making the finished object mutable.
Builder Pattern and Immutable Objects
Direct Answer
A builder can collect values in a mutable helper and pass them once to an object whose state cannot change afterward. The result needs final private state, no mutating API, and defensive copies or immutable representations for mutable inputs such as lists, maps, arrays, and dates. Final references alone do not make an object deeply immutable.
Code Example
public final class ImmutableOrder { // final class prevents subclassing
private final Long orderId;
private final Long customerId;
private final List<String> items;
private final String paymentMethod;
private final String shippingAddress;
private ImmutableOrder(Builder builder) {
this.orderId = builder.orderId;
this.customerId = builder.customerId;
this.items = List.copyOf(builder.items); // defensive copy for immutability
this.paymentMethod = builder.paymentMethod;
this.shippingAddress = builder.shippingAddress;
}
public static class Builder {
private final Long customerId; // required, final in Builder
private Long orderId;
private List<String> items = List.of(); // immutable default
private String paymentMethod = "CREDIT_CARD";
private String shippingAddress;
public Builder(Long customerId) {
this.customerId = customerId;
}
public Builder orderId(Long id) {
this.orderId = id;
return this;
}
public Builder items(List<String> items) {
this.items = items;
return this;
}
public Builder paymentMethod(String method) {
this.paymentMethod = method;
return this;
}
public Builder shippingAddress(String address) {
this.shippingAddress = address;
return this;
}
public ImmutableOrder build() {
if (items.isEmpty()) {
throw new IllegalStateException("Order must have items");
}
return new ImmutableOrder(this); // single-shot construction
}
}
// Only getters, no setters
public Long getOrderId() { return orderId; }
public Long getCustomerId() { return customerId; }
public List<String> getItems() { return items; } // already immutable from copyOf
}
Common Mistakes & Pitfalls
- Keeping caller-owned mutable data: copy lists, maps, arrays, dates, and mutable elements as required.
- Returning internal mutable state: expose immutable values or defensive copies.
- Assuming
finalis deep immutability: it fixes the reference, not the referenced object's contents. - Relying only on the builder for invariants: protect every supported construction path.
- Calling subclass prevention sufficient: a final class can still expose mutable state.
Check your understanding
Answer these without looking back at the article.
- What is Java record and how does it compare to Builder for immutability?
- What is Lombok @Value and how does it differ from @Data?
Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.