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 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 Pizza
public 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

Pro Tips

📋 Quick Reference

ConceptSyntaxNote
nested builderstatic final class BuilderInside the product
entry pointstatic Builder builder()Factory method
required fieldBuilder(int size)Constructor arg
optional defaultint x = 30;Field initializer
fluent setterBuilder x(v)Returns this
chaining.a().b().build()Reads top to bottom
validationthrow in build()Single check point
private ctorprivate T(Builder b)Build only via builder
build()T build()Returns immutable T
when to usemany optional paramsElse use a record
in the wildHttpRequest.newBuilder()JDK example

📚 Resources / Keep Learning

🎉 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

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.

Related lessons