Builder Pattern: Readable Construction of Complex Objects
How a builder makes objects with many optional fields readable and valid: telescoping constructors versus a fluent builder, validation in build(), and immutable results, tested in Java.
The builder pattern constructs an object step by step through named methods, then validates and creates an immutable result in a single build() call. It replaces telescoping constructors and half-built objects. Interviewers expect validation in build(), where all values are known, and each fluent method returning this so calls can chain.
The builder pattern constructs an object step by step through named methods, then checks it and creates an immutable result in one build() call. It replaces constructors with long parameter lists and avoids half-built objects. A Java pizza example produced a valid object, and a builder with six toppings was rejected at build time.
- A telescoping constructor offers overloads with growing parameter lists, so calls such as new Pizza("large", true, false, 2, null) are unreadable and easy to misorder.
- In a fluent builder each setter-like method returns this, which allows chained calls such as new Pizza.B().size("large").topping("olives").build().
- Builder validation belongs in build(), where all fields are known; a Java pizza builder with six toppings failed there with IllegalStateException: max 5 toppings.
- A builder-made object can be immutable by using a private constructor, final fields and List.copyOf, so later changes to the builder cannot alter it.
- A factory decides which type to create in one call, while a builder configures one object step by step; JDK examples include StringBuilder and HttpRequest.newBuilder().
01The problem with big constructors
A class with several optional settings tempts you into constructor overloads: one with 2 parameters, one with 3, one with 5, and so on (the telescoping constructor). Calls then look like new Pizza("large", true, false, 2, null), and nobody can tell what each value means without opening the class. Passing them in the wrong order compiles fine when they have the same type.
The alternative of a no-argument constructor with setters has a different problem: the object exists in an incomplete state between calls, cannot be immutable, and nothing forces required fields to be set.
02A fluent builder
final class Pizza {
final String size; final List<String> toppings; final boolean extraCheese;
private Pizza(B b) {
size = b.size; toppings = List.copyOf(b.toppings); extraCheese = b.extraCheese;
}
static class B {
String size = "medium"; List<String> toppings = new ArrayList<>(); boolean extraCheese;
B size(String s) { size = s; return this; }
B topping(String t) { toppings.add(t); return this; }
B extraCheese() { extraCheese = true; return this; }
Pizza build() {
if (toppings.size() > 5) throw new IllegalStateException("max 5 toppings");
return new Pizza(this);
}
}
}Usage reads like a sentence: new Pizza.B().size("large").topping("olives").topping("corn").extraCheese().build(). Printing the fields (p.size, p.toppings, p.extraCheese) gave large [olives, corn] +cheese; the snippet needs import java.util.*;, and printing a Pizza directly would need a toString(). Each method returns this, which is what allows the chain. The Pizza constructor is private, so the only way to make one is through the builder. The fields are final, so the result is immutable, and List.copyOf ensures later changes to the builder's list cannot alter a pizza already built.
03Validation belongs in build()
All the checks live in one place, run once the whole configuration is known, which allows rules involving several fields (a delivery pizza needs an address; at most five toppings). A builder with six toppings failed at build() with max 5 toppings:
var b = new Pizza.B();
for (int i = 0; i < 6; i++) b.topping("t" + i);
b.build(); // IllegalStateException: max 5 toppingsSetters cannot do this well, since each setter sees only one field at a time. With the builder, if build() returns, the object is valid and complete. Make required values constructor arguments of the builder (new Pizza.B(size)) so they cannot be forgotten.
04Where you already see it
StringBuilder accumulates pieces before producing a String, avoiding the cost of creating a new string for each concatenation in a loop. java.net.http.HttpRequest.newBuilder().uri(...).header(...).build() and Stream.Builder follow the same shape. Many libraries (Lombok's @Builder, Protobuf messages, AWS SDK requests) generate builders for you.
Java records pair well with builders when there are many fields: the record gives an immutable value type, and the builder gives readable construction.
05Costs and alternatives
A builder doubles the amount of code for a class, since fields appear in both the class and the builder. That is worth it for classes with many optional parameters, but not for a class with three required fields, where a constructor is clearer. Alternatives: static factory methods with descriptive names (Pizza.margherita("large")), parameter objects, default values, or in languages with named and default arguments (Python, Kotlin), no builder at all.
A step builder, where each method returns a different interface, can force a required order at compile time (size() must come before topping()), at the price of many small interfaces.
06Builder versus related patterns
A factory chooses which type to create and returns it in one call. A builder assembles one object in many steps with many options. They combine: a factory may return a prepared builder. The GoF book also describes a Builder with a director that runs standard build sequences for different representations, which is rarer than the fluent style shown here, which came from Joshua Bloch's Effective Java.
07Common interview questions
Q: What problem does the builder pattern solve?
A: Constructing objects with many optional or related parameters in a readable way, without telescoping constructors or inconsistent half-built objects.
Q: How does a fluent builder chain calls?
A: Each setter-like method returns this.
Q: Where should validation happen?
A: In build(), where all values are known, so an invalid object cannot be created.
Q: Why is the result usually immutable?
A: The builder holds mutable state during construction, and the built object copies it into final fields, making it safe to share.
Q: Builder versus factory?
A: A factory decides which type to make. A builder configures one object step by step.
Q: A JDK example?
A: StringBuilder, HttpRequest.newBuilder(), Stream.builder().