Builder Pattern

Step-by-step construction of complex objects, fluent API, telescoping-constructor problem solved.

Pattern Foundational
9 min read
pattern creational builder fluent-api

What it is#

The Builder pattern separates the construction of a complex object from its representation, so the same construction process can produce different results — and so the caller can specify only the parts it cares about. The Gang of Four framed it as a remedy for constructors that accumulate parameters as a class grows.

The shape is small. A Builder is a mutable companion to a target class. The caller creates a builder, calls setX(...) / withX(...) methods one at a time — usually returning this so the calls chain — and finally calls build() to get an instance of the target. The target’s constructor is typically private, taking the builder as its only argument; the only way to construct the target is through its builder.

The pattern solves two distinct problems in one shape:

  1. The telescoping-constructor problem. A class with eight fields, four of them optional, requires 2^4 = 16 overloaded constructors to cover every combination. Builder reduces this to one constructor on the target plus chained setter calls.
  2. Construction with steps. When building a Pizza requires choices that depend on earlier choices (size before toppings, dough before sauce), the builder is a place to enforce that order — and to validate the final object only when build() is called.

Class structure#

┌─────────────────────────┐ ┌──────────────────────┐
│ Director │ uses │ Builder │
├─────────────────────────┤────────►├──────────────────────┤
│ + construct(Builder) │ │ + reset() │
└─────────────────────────┘ │ + setPartA(...) │
│ + setPartB(...) │
│ + build() : Product │
└──────────▲───────────┘
┌──────────┴───────────┐
│ ConcreteBuilder │
├──────────────────────┤
│ + setPartA(...) │
│ + setPartB(...) │
│ + build() : Product │
└──────────┬───────────┘
│ builds
┌──────────────────────┐
│ Product │
├──────────────────────┤
│ - partA, partB, ... │
└──────────────────────┘

The Director is optional — in modern usage it is usually omitted and the caller drives the builder directly.

When to use it#

Reach for Builder when:

  • The target class has many fields, some optional, and the constructor parameter list is becoming unmanageable.
  • You want immutable target objects but a mutable way to construct them.
  • Construction needs to validate invariants that only make sense when all fields are set.
  • The same construction process should be able to produce different representations (rare in practice, the GoF’s original motivator).
  • You want the API to read like a sentence: Pizza.builder().size(LARGE).cheese(MOZZARELLA).addTopping(MUSHROOM).build().

Concrete shapes this takes:

  • HTTP request constructionHttpRequest.newBuilder().uri(uri).header(k,v).POST(body).build().
  • DSL-like configurationRetryPolicy.builder().maxAttempts(5).initialDelay(100).backoff(2.0).build().
  • Test dataUser.builder().name("Alice").age(30).build() reads better than new User("Alice", null, null, 30, null, null).
  • Multi-step domain objects — pizza, burger, sandwich (the textbook food examples), but also SqlQuery, Email, Report.

When not to use it:

  • When the target has two or three fields, all required. A constructor is simpler.
  • When the target is mutable anyway. Setters on the target itself are equivalent.
  • When you need validation between steps, not just at the end — consider a state machine instead.

How it works#

A Pizza example: a few required fields (size, dough), a few optional ones (sauce, cheese, toppings). Immutable target, fluent builder, validation at build().

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
public final class Pizza {
public enum Size { SMALL, MEDIUM, LARGE }
public enum Dough { THIN, THICK, GLUTEN_FREE }
public enum Sauce { TOMATO, WHITE, NONE }
private final Size size;
private final Dough dough;
private final Sauce sauce;
private final boolean cheese;
private final List<String> toppings;
private Pizza(Builder b) {
this.size = b.size;
this.dough = b.dough;
this.sauce = b.sauce;
this.cheese = b.cheese;
this.toppings = List.copyOf(b.toppings); // defensive immutable copy
}
public Size size() { return size; }
public Dough dough() { return dough; }
public Sauce sauce() { return sauce; }
public boolean cheese() { return cheese; }
public List<String> toppings() { return toppings; }
public static Builder builder() { return new Builder(); }
public static final class Builder {
private Size size;
private Dough dough;
private Sauce sauce = Sauce.TOMATO; // sensible default
private boolean cheese = true;
private final List<String> toppings = new ArrayList<>();
private Builder() {}
public Builder size(Size size) { this.size = size; return this; }
public Builder dough(Dough dough) { this.dough = dough; return this; }
public Builder sauce(Sauce sauce) { this.sauce = sauce; return this; }
public Builder cheese(boolean on) { this.cheese = on; return this; }
public Builder addTopping(String topping) {
Objects.requireNonNull(topping, "topping");
this.toppings.add(topping);
return this;
}
public Pizza build() {
// Validate invariants once, when the object is about to materialise.
if (size == null) throw new IllegalStateException("size is required");
if (dough == null) throw new IllegalStateException("dough is required");
if (toppings.size() > 10) throw new IllegalStateException("max 10 toppings");
if (dough == Dough.GLUTEN_FREE && size == Size.LARGE) {
throw new IllegalStateException("gluten-free only available in SMALL/MEDIUM");
}
return new Pizza(this);
}
}
}
// Usage:
// Pizza p = Pizza.builder()
// .size(Pizza.Size.LARGE)
// .dough(Pizza.Dough.THIN)
// .sauce(Pizza.Sauce.TOMATO)
// .addTopping("mushroom")
// .addTopping("olive")
// .build();

Five things worth noticing:

  • The target is immutable. All fields are final; collections are defensively copied with List.copyOf. Once built, a Pizza cannot change.
  • The constructor is private. The only way to make a Pizza is through Pizza.builder(). This is enforced by the type system, not by convention.
  • Defaults live in the builder. sauce = Sauce.TOMATO, cheese = true. The caller specifies only what differs.
  • Validation runs in build(), not in setters. Cross-field invariants (gluten-free + size) cannot be checked partway through — only at the end. Building this in build() keeps the setters simple and the invariants in one place.
  • The builder is a nested static class. This signals “this builder belongs to Pizza,” lets it touch Pizza’s private constructor, and keeps the public API surface clean.

Variants#

VariantMechanismWhen it fits
Classical (with Director)A separate Director orchestrates the build steps; client picks the builder.The original GoF form — useful when the recipe is reusable across builders. Rarely needed today.
Fluent (chained setters)Setters return this; client drives directly.The dominant modern form. What Pizza.Builder above is.
Step builder (typesafe stages)Each setter returns a different builder type so the type system enforces ordering and required fields.When required-field validation must be at compile time, not runtime.
Static-factory + recordFoo.of(...) with a record target.When the field count is small enough that named parameters in a single factory call are clear.

The step builder is worth understanding even if you do not use it often:

// Conceptually:
// Pizza.builder().size(LARGE) // returns NeedDough
// .dough(THIN) // returns ReadyToBuild
// .addTopping(...) // still ReadyToBuild
// .build();
//
// The compiler refuses Pizza.builder().build() — the type does not have build().

It is verbose to write but produces an API where calling build() without the required fields is a compile error rather than a runtime exception.

Example systems#

The pattern is the JDK’s default style for any class with more than a handful of fields:

  • StringBuilder — the original Java builder. new StringBuilder().append("hello").append(" ").append("world").toString().
  • Stream.BuilderStream.<String>builder().add("a").add("b").build().
  • HttpRequest.Builder (JDK 11+) — HttpRequest.newBuilder().uri(uri).header(k,v).GET().build().
  • Locale.Builder — assembling locales with language, country, variant, extensions.
  • Calendar.Builder — assembling calendars by year/month/day/hour/minute/second.
  • Protobuf — every generated message class has a newBuilder(); the fluent API is the standard way to construct messages.
  • Lombok @Builder — generates the boilerplate version of this pattern from a single annotation.

Trade-offs#

What you gain:

  • No telescoping constructors. One builder absorbs every optional-field combination.
  • Readable call sites. Named methods describe what each value is — setSize(LARGE) vs new Pizza(LARGE, ...) where positional arguments are guessing.
  • Immutable targets. The builder is the only mutable state; once build() returns, the object is frozen.
  • Centralised validation. Cross-field invariants live in build(), not scattered across constructors.
  • Defaults are explicit. Optional fields have a single declared default; callers override only the relevant ones.

What you pay:

  • Boilerplate. Even for a five-field class, you write the target, the builder, the setters, the build() method, and the private constructor. Lombok @Builder or records hide this.
  • Construction is two steps. builder().setX(...).build() is more code than new Foo(x). Not a problem at scale, but visible in test fixtures.
  • Forgetting build() returns the builder, not the target. A common bug: var p = Pizza.builder().size(LARGE).dough(THIN);p is a Builder, not a Pizza. IDE warnings catch this; reviews do too.
  • No compile-time enforcement of required fields (unless you use the step-builder variant). Forgetting size(...) blows up at runtime, not at compile time.
  • Builder reuse is dangerous. Calling build() twice from the same builder gives two objects that share mutable internals if you forget the defensive copy. The List.copyOf above prevents this.

Telescoping constructors

  • new Pizza(L, THIN) / new Pizza(L, THIN, TOMATO) / new Pizza(L, THIN, TOMATO, true, ...).
  • Combinations explode: 2^n for n optional fields.
  • Positional arguments — easy to mix up at call sites.
  • Required-only constructor enforces required fields at compile time.

Builder

  • Pizza.builder().size(L).dough(THIN).build() regardless of how many optional fields.
  • One builder, any combination of setters.
  • Named methods at every call site.
  • Required fields enforced at runtime in build() (unless using a step builder).
  • Factory Method Pattern — Factory Method picks the class; Builder configures the instance. A factory that returns a builder is a common combination.
  • Abstract Factory Pattern — when products in a family are themselves complex, the factory’s createX() methods often return builders.
  • Prototype Pattern — an alternative when copy-and-modify is more natural than build-from-scratch. Some libraries provide both: a builder for creation, a toBuilder() for copy-modify.
  • Singleton Pattern — a builder is the inverse philosophy: every build() returns a fresh object.
  • Single Responsibility Principle (SRP) — extracting construction logic into a builder is a textbook SRP refactor; the target’s responsibility is to be the thing, not to validate the assembly of itself.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.