The Builder Pattern
Reviewed & published by Brayan K
The Builder pattern constructs a complex, usually immutable object step by step through a fluent helper class — replacing confusing multi-argument constructors with readable, named, chainable calls.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should know classes, constructors and access modifiers, and ideally the previous lesson on immutability — builders and immutable objects are natural partners.
What You'll Learn
- The telescoping-constructor problem the pattern fixes
- Writing a static nested Builder class
- Fluent setters that return this for chaining
- Validating in build() before creating the object
- Producing an immutable result via a private constructor
- When a builder is worth it (and when it isn't)
The Big Idea — Ordering a Sandwich
💡 Analogy: A telescoping constructor is like ordering by shouting "one, twelve, true, false, true!" and hoping the kitchen guesses what each value means. A Builder is the sandwich shop counter: you say "start with a footlong, add cheese, add mushrooms, hold the pepperoni", one clear instruction at a time, and only when you say "that's it" (build()) does the kitchen check your order is valid and hand over the finished, sealed sandwich. You configured a mutable order; you received an immutable meal.
Each instruction is named, so nobody confuses cheese for mushrooms — and the order can't go out the door until it passes the final check.
1️⃣ The Problem — Telescoping Constructors
With several optional fields you end up with a ladder of constructors, and call sites become a row of mystery true/false values. Which flag is which? You can't tell without opening the class. Add one option and you add another constructor.
public class Main {
// THE PROBLEM: a telescoping constructor — many overloads, unreadable calls.
static final class Pizza {
final int size;
final boolean cheese, pepperoni, mushroom;
Pizza(int size) { this(size, false, false, false); }
Pizza(int size, boolean cheese) { this(size, cheese, false, false); }
Pizza(int size, boolean cheese, boolean pepperoni) { this(size, cheese, pepperoni, false); }
Pizza(int size, boolean cheese, boolean pepperoni, boolean mushroom) {
this.size = size; this.cheese = cheese; this.pepperoni = pepperoni; this.mushroom = mushroom;
}
public String toString() {
return "Pizza(" + size + "in cheese=" + cheese + " pepperoni=" + pepperoni
+ " mushroom=" + mushroom + ")";
}
}
public static void main(String[] args) {
// Which boolean is which? Easy to mix up.
Pizza p = new Pizza(12, true, false, true);
System.out.println(p);
}
}2️⃣ The Solution — a Fluent Builder
Move construction into a static nested Builder. Required values go to the builder's constructor; optional ones get named methods that set a field and return this so calls chain. The product's constructor is private and takes the builder, so objects can only be made the validated way.
Pizza p = Pizza.builder(12) // required field
.cheese() // each call returns the builder...
.mushroom() // ...so they chain
.build(); // validate + create the immutable Pizzapublic class Main {
// THE SOLUTION: a static nested Builder with fluent setters.
static final class Pizza {
private final int size;
private final boolean cheese, pepperoni, mushroom;
// private constructor: build only through the Builder
private Pizza(Builder b) {
this.size = b.size;
this.cheese = b.cheese;
this.pepperoni = b.pepperoni;
this.mushroom = b.mushroom;
}
public static Builder builder(int size) { return new Builder(size); }
@Override public String toString() {
return "Pizza(" + size + "in cheese=" + cheese + " pepperoni=" + pepperoni
+ " mushroom=" + mushroom + ")";
}
static final class Builder {
private final int size; // required
private boolean cheese, pepperoni, mushroom; // optional, default false
Builder(int size) { this.size = size; }
public Builder cheese() { this.cheese = true; return this; } // return this -> chain
public Builder pepperoni() { this.pepperoni = true; return this; }
public Builder mushroom() { this.mushroom = true; return this; }
public Pizza build() {
if (size <= 0) throw new IllegalArgumentException("size must be > 0");
return new Pizza(this); // validation, then immutable result
}
}
}
public static void main(String[] args) {
Pizza p = Pizza.builder(12)
.cheese()
.mushroom()
.build();
System.out.println(p); // crystal clear which toppings
}
}3️⃣ Validation in build()
The setters just record values; build() is the one place where the whole object exists, so it's where you validate. Check required fields and cross-field rules there, throwing IllegalStateException if anything is off — an invalid object is then never created.
public class Main {
// Builder with VALIDATION in build() — reject incomplete/invalid objects.
static final class User {
private final String name;
private final String email;
private final int age;
private User(Builder b) { this.name = b.name; this.email = b.email; this.age = b.age; }
public static Builder builder() { return new Builder(); }
public String toString() { return "User{name=" + name + ", email=" + email + ", age=" + age + "}"; }
static final class Builder {
private String name;
private String email;
private int age = -1;
public Builder name(String n) { this.name = n; return this; }
public Builder email(String e) { this.email = e; return this; }
public Builder age(int a) { this.age = a; return this; }
public User build() {
if (name == null || name.isBlank())
throw new IllegalStateException("name is required");
if (email == null || !email.contains("@"))
throw new IllegalStateException("a valid email is required");
if (age < 0)
throw new IllegalStateException("age must be set and non-negative");
return new User(this);
}
}
}
public static void main(String[] args) {
User u = User.builder().name("Ana").email("ana@site.com").age(30).build();
System.out.println("Built: " + u);
try {
User.builder().name("Ben").email("not-an-email").age(25).build();
} catch (IllegalStateException e) {
System.out.println("Rejected: " + e.getMessage());
}
}
}🧠 Quick Recall
Predict before you peek.
Answer: chaining would break — builder.cheese().mushroom() wouldn't compile because cheese() would return nothing for mushroom() to call. Each fluent method must return this.
Answer: so the only path to an instance is through the builder's build(), which guarantees validation runs and the object is complete. A public constructor would let callers bypass all of that.
Answer: in build(). Only there does the whole object exist, so cross-field rules can be checked. Setters just record intent.
🎯 YOUR TURN — Finish the Burger builder
Complete the nested Builder: a patties(int) setter, a no-arg cheese() flag, and build() — all chainable.
public class Main {
static final class Burger {
private final String bun;
private final int patties;
private final boolean cheese;
private Burger(Builder b) { this.bun = b.bun; this.patties = b.patties; this.cheese = b.cheese; }
public static Builder builder(String bun) { return new Builder(bun); }
public String toString() { return "Burger(" + bun + ", patties=" + patties + ", cheese=" + cheese + ")"; }
// YOUR TURN: finish the Builder. 'bun' is required (constructor arg).
// 'patties' defaults to 1 with a fluent setter; 'cheese' defaults to
// false with a no-arg fluent method that sets it true. Each fluent
// method must "return this". build() returns a new Burger.
//
// Expected output:
// Burger(brioche, patties=2, cheese=true)
static final class Builder {
private final String bun;
private int patties = 1;
private boolean cheese;
Builder(String bun) { this.bun = bun; }
// add patties(int), cheese(), and build()
}
}
public static void main(String[] args) {
Burger b = Burger.builder("brioche").patties(2).cheese().build();
System.out.println(b);
}
}🧩 MINI-CHALLENGE — Config builder with defaults & validation
Build an immutable HttpRequestConfig with a required URL, defaulted method/timeout, and validation in build().
public class Main {
// MINI-CHALLENGE: build an immutable HttpRequestConfig.
// - url is REQUIRED (builder constructor argument)
// - method defaults to "GET", timeoutSeconds defaults to 30
// - fluent setters return this; build() validates that url is non-blank
// and timeoutSeconds > 0, throwing IllegalStateException otherwise.
//
// Expected output:
// Built: POST https://api.example.com (timeout 10s)
// Defaults: GET https://api.example.com/data (timeout 30s)
// Rejected: timeout must be positive
public static void main(String[] args) {
// HttpRequestConfig c = HttpRequestConfig.builder("https://api.example.com")
// .method("POST").timeoutSeconds(10).build();
// ... build a defaulted one, and a failing one in a try/catch
}
}Common Errors
- ❌ Fluent setters returning void. Chaining won't compile. Fix: every setter must return this.
- ❌ A public product constructor. Callers skip validation. Fix: make it private and build only via the builder.
- ❌ Validating in setters instead of build(). Cross-field rules can't be checked early. Fix: validate in build().
- ❌ Mutable product fields. Defeats the safety the pattern offers. Fix: make product fields private final.
- ❌ Forgetting to copy mutable inputs. The builder can leak a list reference into the product. Fix: defensively copy in build() (see the immutability lesson).
- ❌ Using a builder for a 1-field class. It's overkill. Fix: a plain constructor or a record is clearer.
Pro Tips
- 💡 Put required fields in the builder's constructor so they can't be forgotten, and give optional ones sensible defaults.
- 💡 Lombok's @Builder generates the whole pattern for you in real projects — but knowing the hand-written version is essential.
- 💡 The JDK uses it: HttpRequest.newBuilder()...build() and Stream.builder() are fluent builders you've already seen.
- 💡 Pair with records: a builder can assemble values and pass them to a record's canonical constructor for the final immutable product.
📋 Quick Reference
| Concept | Syntax | Note |
|---|---|---|
| nested builder | static final class Builder | Inside the product |
| entry point | static Builder builder() | Factory method |
| required field | Builder(int size) | Constructor arg |
| optional default | int x = 30; | Field initializer |
| fluent setter | Builder x(v) | Returns this |
| chaining | .a().b().build() | Reads top to bottom |
| validation | throw in build() | Single check point |
| private ctor | private T(Builder b) | Build only via builder |
| build() | T build() | Returns immutable T |
| when to use | many optional params | Else use a record |
| in the wild | HttpRequest.newBuilder() | JDK example |
📚 Resources / Keep Learning
- 📘 HttpRequest.Builder — a real JDK builder (docs.oracle.com)
- 📘 Stream.Builder — Java 21 API (docs.oracle.com)
- 📗 dev.java — official tutorials and guides
🎉 Lesson Complete!
You can now recognise the telescoping-constructor problem, write a static nested Builder with fluent return this setters, validate in build(), produce an immutable result through a private constructor, and judge when a builder beats a plain constructor or record.
Practice quiz
What problem does the Builder pattern solve?
- Slow loops
- Memory leaks
- Telescoping constructors with many parameters
- Thread starvation
Answer: Telescoping constructors with many parameters. It replaces a pile of confusing multi-argument constructors with readable, named, step-by-step construction.
Where is the Builder usually defined?
- As a static nested class inside the product
- In a separate package
- As a global function
- In the main method
Answer: As a static nested class inside the product. The Builder is conventionally a static nested class of the class it builds, with access to its private constructor.
Why do fluent setter methods return 'this'?
- To save memory
- To make them static
- Required by the compiler
- To allow method chaining
Answer: To allow method chaining. Returning the builder itself lets you chain calls: builder.cheese().mushroom().build().
What does the build() method typically do?
- Nothing
- Validate, then create the immutable product
- Print the object
- Reset the builder
Answer: Validate, then create the immutable product. build() is the single place to validate the accumulated state and then construct the final, usually immutable, object.
Why make the product's constructor private?
- So objects can only be created through the Builder
- For speed
- To allow subclassing
- It's optional styling
Answer: So objects can only be created through the Builder. A private constructor forces all construction through the builder, guaranteeing validation runs and the object is fully formed.
How are required vs optional fields usually handled?
- All in setters
- All optional
- Required via the builder constructor, optional via fluent setters
- Required via build()
Answer: Required via the builder constructor, optional via fluent setters. Required values are passed to the Builder's constructor; optional ones get fluent setters with sensible defaults.
The object produced by a good builder is typically...
- mutable
- immutable
- static
- abstract
Answer: immutable. Builders pair naturally with immutability — the builder mutates, the finished product does not.
When is the Builder pattern most worth it?
- For a 1-field class
- For utility methods
- For enums
- For classes with many optional parameters
Answer: For classes with many optional parameters. It shines when there are several optional parameters; for one or two fields a plain constructor is simpler.
What does method chaining look like?
- b; cheese; build;
- b.cheese().mushroom().build()
- build(b, cheese, mushroom)
- new B(cheese, mushroom)
Answer: b.cheese().mushroom().build(). Each fluent method returns the builder so the next call attaches directly, ending in build().
Which JDK class uses a builder-like fluent API?
- ArrayList
- Integer
- HttpRequest.newBuilder()
- Math
Answer: HttpRequest.newBuilder(). HttpRequest.newBuilder()...build() and Stream.Builder are real fluent-builder examples in the JDK.
Continue this course
- Previous: Immutability
- Next: Dependency Injection — Decouple classes by injecting collaborators through constructors
- Quick reference: Java cheat sheet
Frequently asked questions
What is the telescoping-constructor problem?
When a class has several optional fields, developers often add a chain of constructors with growing parameter lists — one for two fields, one for three, one for four, each delegating to the next. The call sites become unreadable: new Pizza(12, true, false, true) gives no clue which boolean is cheese and which is mushroom, and adding a new option means another constructor. The Builder pattern replaces all of that with named, chainable methods so each value is self-documenting.
How do I implement the Builder pattern?
Make the product's fields private and final and give it a private constructor that takes the builder. Add a static nested Builder class holding the same fields (mutable). Required values go to the Builder's own constructor; optional ones get fluent setter methods that assign the field and return this so calls chain. A build() method validates the accumulated state and returns a new product instance. Expose the builder via a static factory like Pizza.builder(size).
Why does each builder method return 'this'?
Returning this — the builder itself — enables method chaining, the 'fluent' style: builder.cheese().mushroom().build(). Each call hands the same builder to the next call so you can configure everything in one readable expression. Without it you'd need a separate statement per setter and a final variable to thread through, which defeats the readability the pattern is designed for.
Where should validation go in a builder?
Put validation in build(), not in the individual setters. Setters just record intent; build() is the single moment where the whole object is assembled, so it's the right place to check that required fields are present and all values are consistent (e.g. a valid email, a positive timeout). If anything is wrong, throw IllegalStateException (or IllegalArgumentException) there, so an invalid object is never created in the first place.
When should I NOT use the Builder pattern?
Skip it for small classes with one or two fields — a plain constructor or a record is shorter and clearer. The Builder earns its extra boilerplate when there are many parameters, especially several optional ones, when you want a readable fluent API, or when construction needs validation across fields. For purely immutable data with few fields, a Java record is usually the better tool; reach for a builder when the record's positional constructor would itself become a telescoping mess.