The Builder Pattern in Java — When It Helps and When It Becomes a Liability
by Eric Hanson, Backend Developer at Clean Systems Consulting
The problem builders solve
A class with many parameters — some required, some optional — has two bad constructor options. A telescoping constructor provides one constructor per combination of optional parameters:
public Order(String customerId, List<LineItem> items) { ... }
public Order(String customerId, List<LineItem> items, String couponCode) { ... }
public Order(String customerId, List<LineItem> items, String couponCode, Address shippingAddress) { ... }
public Order(String customerId, List<LineItem> items, String couponCode, Address shippingAddress, String notes) { ... }
Four constructors for five parameters is manageable. For ten parameters with multiple optional subsets, the combinatorial explosion is not.
A single constructor with all parameters passes the problem to callers:
Order order = new Order("cust-123", items, null, address, null, null, true, false, "PRIORITY", null);
Six null arguments. The reader has no idea what the fourth parameter is without counting and checking the constructor signature. Positional arguments with no names are a readability failure at scale.
A builder solves both: all parameters are named, optional parameters have sensible defaults, and the construction is readable at the call site.
A well-designed builder
public final class Order {
private final String customerId;
private final List<LineItem> items;
private final String couponCode; // optional
private final Address shippingAddress; // optional
private final String notes; // optional
private Order(Builder builder) {
this.customerId = builder.customerId;
this.items = List.copyOf(builder.items); // defensive copy
this.couponCode = builder.couponCode;
this.shippingAddress = builder.shippingAddress;
this.notes = builder.notes;
}
public static Builder builder(String customerId, List<LineItem> items) {
return new Builder(customerId, items);
}
public static final class Builder {
// Required — set in constructor, never null
private final String customerId;
private final List<LineItem> items;
// Optional — defaults to null/absent
private String couponCode;
private Address shippingAddress;
private String notes;
private Builder(String customerId, List<LineItem> items) {
Objects.requireNonNull(customerId, "customerId is required");
Objects.requireNonNull(items, "items is required");
if (items.isEmpty()) throw new IllegalArgumentException("items must not be empty");
this.customerId = customerId;
this.items = new ArrayList<>(items);
}
public Builder couponCode(String couponCode) {
this.couponCode = couponCode;
return this;
}
public Builder shippingAddress(Address address) {
this.shippingAddress = address;
return this;
}
public Builder notes(String notes) {
this.notes = notes;
return this;
}
public Order build() {
return new Order(this);
}
}
}
Several design decisions worth noting:
Required parameters in the Builder constructor, not as fluent methods. Order.builder(customerId, items) makes it impossible to call build() without providing required parameters — the compiler enforces it. If required parameters were fluent methods, Order.builder().build() would compile and throw at runtime.
Validation in the Builder constructor, not in build(). Fail as early as possible. An invalid customerId should throw when the builder is created, not when build() is called ten lines later.
Defensive copy of items in both the Builder constructor and Order's private constructor. The builder takes a copy so the original list can't be modified before build() is called. Order takes a copy (via List.copyOf) so the builder can't be mutated after building. Both copies are necessary for a truly immutable Order.
Where builders become a liability
When the class has fewer than four or five parameters. A three-parameter class with no optional parameters needs a constructor, not a builder. The builder adds 20 lines of boilerplate for zero readability improvement:
// Unnecessary builder
User user = User.builder()
.id(123L)
.email("alice@example.com")
.name("Alice")
.build();
// Just use a constructor
User user = new User(123L, "alice@example.com", "Alice");
When validation is deferred or incomplete. The most common builder mistake: validation that happens in build() rather than in the builder's setters or constructor. A builder that accepts contradictory state without complaint, then throws in build(), gives the developer no feedback at the point they made the mistake:
// Bad — deferred validation gives no feedback until build()
Order.builder("cust-123", items)
.shippingAddress(address)
.shippingMethod(ShippingMethod.EXPRESS)
// ... 20 more lines of configuration ...
.build(); // IllegalStateException: EXPRESS shipping requires a verified address
// Better — validate in the setter
public Builder shippingMethod(ShippingMethod method) {
if (method == ShippingMethod.EXPRESS && !this.shippingAddress.isVerified()) {
throw new IllegalArgumentException("EXPRESS shipping requires a verified address");
}
this.shippingMethod = method;
return this;
}
Cross-field validation that requires multiple fields is the exception — it genuinely belongs in build() because the fields may be set in any order. Single-field validation belongs in the setter.
When the builder becomes a mutable shared object. A builder is a mutable object. Storing a builder in a field and calling fluent methods from multiple threads without synchronization produces a race condition. This sounds obvious but appears in code that stores a "template" builder and customizes it per-request:
// Anti-pattern — shared mutable builder
private static final Request.Builder TEMPLATE = Request.builder()
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30));
// Two threads calling this concurrently race on TEMPLATE
public Request buildRequest(String body) {
return TEMPLATE.body(body).build(); // body() mutates TEMPLATE
}
The fix: build a new builder per use, or make the template an immutable object that produces configured builders on demand.
When the builder obscures required state. A builder where all methods are optional creates the impression that all state is optional. If Order can't function without a customerId, making customerId an optional fluent method hides a required invariant behind an optional-looking API:
// Looks optional — is required
Order order = Order.builder()
.items(items)
.build(); // NullPointerException: customerId is required
The caller has no indication from the API that customerId is required. Required parameters should be required at the API level — in the constructor or a factory method with mandatory parameters.
Step builders — enforcing ordering with types
For objects where the construction must proceed in a specific order — a DSL, a protocol builder, a workflow — a step builder enforces the sequence at compile time:
// Step builder for HTTP request — must set method before URL, URL before body
public interface MethodStep { UrlStep method(String method); }
public interface UrlStep { BodyStep url(String url); }
public interface BodyStep { BuildStep body(String body); BuildStep noBody(); }
public interface BuildStep { HttpRequest build(); }
public class HttpRequest {
// ... fields ...
public static MethodStep newRequest() {
return new Builder();
}
private static class Builder implements MethodStep, UrlStep, BodyStep, BuildStep {
// ... implementation ...
}
}
Usage:
HttpRequest request = HttpRequest.newRequest()
.method("POST") // returns UrlStep — only url() available
.url("https://...") // returns BodyStep — only body() or noBody() available
.body("{\"key\":1}") // returns BuildStep — only build() available
.build();
The compiler enforces the step order — build() is not available until all required steps are completed. Skipping a step or calling steps out of order is a compile error, not a runtime exception.
Step builders are worth the complexity for APIs used by many callers where incorrect construction is a real risk. For internal, single-codebase builders, the complexity usually isn't justified.
Records and compact construction — the modern alternative
Java 16 records eliminate most of the builder boilerplate for value-carrying types:
public record Order(
String customerId,
List<LineItem> items,
String couponCode, // nullable — optional
Address shippingAddress, // nullable — optional
String notes // nullable — optional
) {
public Order {
Objects.requireNonNull(customerId, "customerId is required");
Objects.requireNonNull(items, "items is required");
if (items.isEmpty()) throw new IllegalArgumentException("items must not be empty");
items = List.copyOf(items); // defensive copy in compact constructor
}
}
The compact constructor (public Order { ... }) runs validation and normalization without repeating parameter names. Records are immutable by default — fields are final, no setters generated.
For records with many optional fields, a builder is still appropriate. But for records with only required fields, the compact constructor with named parameters and withers (pattern matching and with in Java 21+) replaces the need for a builder entirely.
The decision
Use a constructor when parameters are few (fewer than four or five), all required, and their purpose is clear from position or names.
Use a builder when parameters are many, some are optional, the object is immutable, and readability at the call site matters.
Use a step builder when construction has a required sequence that should be enforced at compile time.
Use a record when the type is a data carrier and mutability isn't needed — the compact constructor handles validation, and Java 21's with expressions handle "copy with one field changed."
The builder pattern earns its boilerplate at the point where unnamed positional arguments make call sites unreadable, or where optional parameters create too many constructor overloads. Below that threshold, a constructor is cleaner.