02-creational-patterns

Builder Pattern: Fluent Construction Without Telescoping Constructors

How the Fluent Builder idiom replaces telescoping constructors and mutable JavaBeans, with mandatory-parameter enforcement, immutable build() results, and the step-builder trick for order-sensitive construction.

August 11, 2026
lldcreationalbuilderfluent-apitelescoping-constructorsimmutablestep-builder

Builder Pattern

Builder separates the construction of a complex object from its representation, so the same step-by-step construction process can produce different configurations, and — in its most common Java form — so an object with many optional parameters can be constructed readably instead of through an unreadable constructor call. This guide covers the classic GoF Builder (with a Director) and the far more common Java idiom: Joshua Bloch's Fluent Builder.


1. Intent

Objects with several optional fields and one or two mandatory ones are surprisingly hard to construct cleanly in Java: constructors can't have named or keyword arguments, so every combination of "which optional fields are set" tends to demand its own overload. Builder's intent is to give such objects a construction API that's readable at the call site, enforces mandatory fields, and — ideally — produces a fully immutable result, all without an explosion of constructor overloads.


2. The Naive Approaches (Violations)

2.1 Telescoping Constructors

java
// VIOLATION: telescoping constructors — every combination of optional
// fields needs its own overload, and call sites are unreadable.
class HttpRequest {
    private final String url;
    private final String method;
    private final Map<String, String> headers;
    private final String body;
    private final int timeoutMs;
 
    HttpRequest(String url) { this(url, "GET"); }
    HttpRequest(String url, String method) { this(url, method, Collections.emptyMap()); }
    HttpRequest(String url, String method, Map<String, String> headers) {
        this(url, method, headers, null);
    }
    HttpRequest(String url, String method, Map<String, String> headers, String body) {
        this(url, method, headers, body, 5000);
    }
    HttpRequest(String url, String method, Map<String, String> headers, String body, int timeoutMs) {
        this.url = url; this.method = method; this.headers = headers;
        this.body = body; this.timeoutMs = timeoutMs;
    }
}
 
// Call site: which parameter is which? You need the IDE or the source to know.
HttpRequest req = new HttpRequest("https://api.example.com", "POST", Map.of("X-Id", "1"), "{}", 10000);

2.2 The Mutable JavaBean Alternative (Also a Violation)

java
// VIOLATION: readable construction, but the object is mutable and can exist
// in an inconsistent, half-built state at any point after construction.
class HttpRequest {
    private String url;              // no `final` — every field is mutable
    private String method = "GET";
    private Map<String, String> headers = new HashMap<>();
    private String body;
    private int timeoutMs = 5000;
 
    // getters and setters for every field...
    void setUrl(String url) { this.url = url; }
    void setMethod(String method) { this.method = method; }
    // ...
}
 
HttpRequest req = new HttpRequest();
req.setMethod("POST");
// req is now in an invalid state — url is null — until setUrl() is ALSO called.
// Nothing in the type system prevents using req in this half-built state,
// and nothing prevents another thread from observing it mid-construction.

3. Structure


4. Fluent Builder Implementation (Joshua Bloch's Idiom)

java
// FIXED: fluent builder — readable call sites, mandatory fields enforced
// at build() time, and the resulting object is fully immutable.
final class HttpRequest {
    private final String url;             // mandatory
    private final String method;          // has a sensible default
    private final Map<String, String> headers;
    private final String body;
    private final int timeoutMs;
 
    // Private constructor — only the Builder can call it.
    private HttpRequest(Builder b) {
        this.url = b.url;
        this.method = b.method;
        this.headers = Map.copyOf(b.headers); // defensive copy → truly immutable
        this.body = b.body;
        this.timeoutMs = b.timeoutMs;
    }
 
    public static Builder builder(String url) {
        return new Builder(url);
    }
 
    // getters only — no setters anywhere on HttpRequest itself
    public String getUrl() { return url; }
    public String getMethod() { return method; }
    public Map<String, String> getHeaders() { return headers; }
    public String getBody() { return body; }
    public int getTimeoutMs() { return timeoutMs; }
 
    public static class Builder {
        private final String url;                 // mandatory — passed to Builder's constructor
        private String method = "GET";             // optional, with default
        private final Map<String, String> headers = new HashMap<>();
        private String body;
        private int timeoutMs = 5000;
 
        private Builder(String url) {
            if (url == null || url.isBlank()) {
                throw new IllegalArgumentException("url is mandatory");
            }
            this.url = url;
        }
 
        public Builder method(String method) { this.method = method; return this; }
        public Builder header(String key, String value) { this.headers.put(key, value); return this; }
        public Builder body(String body) { this.body = body; return this; }
        public Builder timeoutMs(int timeoutMs) { this.timeoutMs = timeoutMs; return this; }
 
        public HttpRequest build() {
            return new HttpRequest(this); // validation already happened in the constructor + here if needed
        }
    }
}
java
// Usage: reads like a sentence, mandatory field is impossible to omit
// (it's a constructor/factory-method parameter, not a fluent call).
HttpRequest req = HttpRequest.builder("https://api.example.com")
    .method("POST")
    .header("X-Id", "1")
    .body("{}")
    .timeoutMs(10_000)
    .build();

Mandatory vs. optional parameters: mandatory fields belong in the builder's constructor (or a static factory method's parameters), never as a fluent setter — that's what makes omitting them a compile error instead of a runtime surprise. Optional fields get fluent setters with sensible defaults. This is the single most important design decision in a builder; getting it backwards (making everything fluent) just re-creates the JavaBean problem with extra steps.

💡

Map.copyOf(b.headers) in the constructor is doing real work: without it, HttpRequest.headers would be the same HashMap reference the builder mutated during construction — if the builder were reused for a second build() call, mutating it afterward would silently corrupt the "immutable" object already handed out. Defensive copying at the build() boundary is what makes the immutability claim actually true.


5. Step Builder: Enforcing Build Order

The fluent builder above still lets a caller call .build() before setting anything, relying on runtime checks for mandatory fields. Step Builder goes further and enforces order at compile time using a chain of single-method interfaces:

java
// Each interface exposes exactly one next step — the compiler enforces order.
interface UrlStep { MethodStep url(String url); }
interface MethodStep { BuildStep method(String method); }
interface BuildStep {
    BuildStep header(String key, String value);
    HttpRequest build();
}
 
class HttpRequestStepBuilder implements UrlStep, MethodStep, BuildStep {
    private String url;
    private String method;
    private final Map<String, String> headers = new HashMap<>();
 
    private HttpRequestStepBuilder() { }
 
    static UrlStep newBuilder() { return new HttpRequestStepBuilder(); }
 
    public MethodStep url(String url) { this.url = url; return this; }
    public BuildStep method(String method) { this.method = method; return this; }
    public BuildStep header(String key, String value) { headers.put(key, value); return this; }
    public HttpRequest build() { return HttpRequest.builder(url).method(method).build(); }
}
 
// Usage — the compiler physically will not let you call .build() before
// .url(...).method(...): BuildStep's methods aren't visible until then.
HttpRequest req = HttpRequestStepBuilder.newBuilder()
    .url("https://api.example.com")
    .method("POST")
    .header("X-Id", "1")
    .build();
⚠️

Step Builder trades runtime validation for a meaningfully more complex implementation (one interface per mandatory step). It earns its cost when getting the construction order wrong is a real, easy-to-make mistake with costly consequences (e.g. security-sensitive configuration objects) — not as a default for every builder. For most objects, constructor-enforced mandatory fields plus runtime validation in build() is the right amount of ceremony.


6. The Classic GoF Builder (with Director)

The original GoF pattern is slightly different from Bloch's fluent idiom: it separates the Builder interface (defines construction steps) from a Director (owns the sequence/policy for calling those steps), so the same director can drive different builders to produce different representations from the same construction process.

java
interface MealBuilder {
    void addBread();
    void addProtein();
    void addSide();
    Meal build();
}
 
class VeggieMealBuilder implements MealBuilder {
    private final Meal meal = new Meal();
    public void addBread() { meal.add("whole grain bun"); }
    public void addProtein() { meal.add("grilled tofu"); }
    public void addSide() { meal.add("side salad"); }
    public Meal build() { return meal; }
}
 
class ProteinMealBuilder implements MealBuilder {
    private final Meal meal = new Meal();
    public void addBread() { meal.add("brioche bun"); }
    public void addProtein() { meal.add("double patty"); }
    public void addSide() { meal.add("fries"); }
    public Meal build() { return meal; }
}
 
// Director owns the SEQUENCE — the same sequence produces different
// meals depending on which builder is handed to it.
class MealDirector {
    Meal construct(MealBuilder builder) {
        builder.addBread();
        builder.addProtein();
        builder.addSide();
        return builder.build();
    }
}
 
// Usage:
Meal veggieMeal = new MealDirector().construct(new VeggieMealBuilder());
Meal proteinMeal = new MealDirector().construct(new ProteinMealBuilder());

The Director is largely absent from modern Java code — the Fluent Builder idiom folds "the sequence" into whatever order the caller chooses to chain methods in, which is usually flexible enough that a separate Director class adds ceremony without benefit. GoF's Director earns its place when the same fixed sequence genuinely needs to be reused across many different builders — e.g. a document-export pipeline that always does header → body → footer, regardless of output format.


7. Builder vs. Factory vs. Constructor

Plain ConstructorFactory (Method/Static)Builder
Best forFew parameters (≤ 3-4), all typically mandatoryChoosing which concrete type to instantiateMany parameters, especially with several optional ones
Readability at call siteDegrades fast with parameter countGood — a named method communicates intentBest for complex objects — each field is named at the call site
Enforces immutabilityYes, triviallyYes, if the factory returns a fully-built objectYes, if build() defensively copies mutable state
Handles optional parametersPoorly — telescoping overloadsAwkward — factory methods multiply per combinationNaturally — omit the fluent call, default applies
Overkill riskN/ALowHigh for simple objects — see below
⚠️

When Builder is overkill: a class with two or three mandatory fields and no optional ones gets no benefit from a builder — it's strictly more code than a constructor for the same readability, and adds an extra allocation (the builder object itself) for no payoff. Reach for Builder specifically when telescoping constructors or a mutable JavaBean would otherwise be the alternative — i.e., when there are multiple optional parameters or the combination of parameters is genuinely hard to read positionally.


8. Real-World / Production Examples

  • StringBuilder / StringBuffer — the canonical fluent builder in the JDK itself, chaining .append() calls.
  • java.time builders, e.g. constructing complex DateTimeFormatter instances via DateTimeFormatterBuilder.
  • Stream.Builder (Stream.builder()...build()) — builds an immutable stream from incrementally added elements.
  • Lombok's @Builder — code-generates exactly the Fluent Builder idiom shown in Section 4 from field annotations, because it's such a common boilerplate pattern in Java codebases.
  • HTTP client libraries (java.net.http.HttpRequest.newBuilder(), OkHttp's Request.Builder) — precisely the HttpRequest example used throughout this guide, taken directly from the JDK's own HTTP client API.
  • Test object mothers — builders are the standard way to construct complex test fixtures (OrderTestDataBuilder) with sensible defaults and a few explicitly overridden fields per test.

Interview Questions

  • Why does Java's lack of named/keyword arguments make Builder more necessary than in languages that have them?
  • Walk through the telescoping constructor problem and explain exactly why it degrades as parameter count grows.
  • In a fluent builder, why should mandatory fields be constructor/factory-method parameters rather than fluent setters?
  • What does "the builder's build() method defensively copies mutable state" protect against, concretely?
  • What's the difference between the classic GoF Builder (with a Director) and Joshua Bloch's Fluent Builder idiom used in modern Java?
  • How does Step Builder enforce construction order, and what's the cost of using it?
  • When is a Builder overkill compared to a plain constructor?
  • Compare Builder to Factory: when would you reach for one over the other for the same class?