$ emrebener

Builder Pattern

author: emre bener read time: 8 min about: builder pattern, java repository: https://github.com/Emrebener/GoF-Design-Patterns-Creational
published: updated: mentions: software design pattern, creational pattern, effective java, project lombok, java record

1. What the Builder pattern is

The Builder pattern is a creational pattern for constructing complex objects step by step, separating how an object is assembled from what the final object looks like. The caller hands the builder a series of pieces, the builder accumulates them, and a final call returns the finished object.

Like the factory pattern, “Builder” is a slightly overloaded term in Java. In practice it covers two distinct things, and conversations about builders tend to mean different ones depending on context:

  • The fluent Builder — the one mentioned in Effective Java. A static inner Builder class with chained with... methods and a final build() call, used to construct objects with many parameters (especially optional ones) without telescoping constructors. This is the popular one, and it’s what most Java developers mean by “Builder pattern.”
  • The GoF Builder — the one the Gang of Four named. A separate Director class drives an abstract Builder interface through a fixed sequence of construction steps; concrete builders supply different concrete Product types. This pattern is heavier and less popular.

Both solve the same problem: constructing a complex object without a giant constructor. The next section is about why that problem is worth solving.

2. Why constructors aren’t enough

For a class with two or three required fields, a constructor is the right answer. The Builder pattern only becomes useful when the field count starts to climb with a bunch of optional fields. At that point, three traditional approaches all fail in different ways.

Consider an HTTP request: a URL and a method are required, but headers, body, timeout, retry count, follow-redirects and authentication are all optional. The class might have ten fields total, and most callers set three or four. Here’s what the constructor-only approaches look like.

Telescoping constructors. One overload per combination:

public HttpRequest(URI url, String method) { ... }
public HttpRequest(URI url, String method, Duration timeout) { ... }
public HttpRequest(URI url, String method, Duration timeout, int retries) { ... }
public HttpRequest(URI url, String method, Duration timeout, int retries, boolean followRedirects) { ... }
// ...and so on

“Telescoping constructors” is the specific anti-pattern of writing a chain of overloaded constructors where each one takes one more parameter than the last and delegates to the next. The name “telescoping” comes from the visual: the parameter list extends one slot at a time like the segments of a collapsible telescope.

Every new optional field doubles the surface area of the API, and callers still have to pick the right overload by counting positional arguments. With ten fields, there’s no realistic way to cover every meaningful combination.

No-arg constructor plus setters. A no-arg constructor followed by a chain of setXxx(...) calls:

HttpRequest req = new HttpRequest();
req.setUrl(url);
req.setMethod("POST");
req.setTimeout(Duration.ofSeconds(30));

The object exists in a half-constructed state for the duration of the setup, fields can no longer be final, and any other thread or method that gets a reference mid-setup sees a broken object.

One mega-constructor. Accepting every field, and passing null or defaults for the ones you don’t care about:

new HttpRequest(url, "POST", null, body, null, 30000, 3, false, null);

Now the call site is unreadable. Nine positional arguments, half of them null, and any reader has to chase the constructor signature to figure out what each slot means. Reorder a parameter and the compiler can’t help you: null is null regardless of which optional field it was meant for.

So the Builder pattern exists because none of these three options are good enough once a class crosses the optional-fields threshold.

3. Writing one in Java

3.1. The fluent Builder

The Effective Java fluent Builder is what you’ll meet in essentially every modern Java codebase. The shape is consistent: an immutable outer class with all-final fields, a private constructor that takes the Builder, and a static inner Builder class with mutable fields and chained setters that all return this. A final build() call then snapshots the Builder into an immutable instance.

public final class HttpRequest {
    private final URI url;
    private final String method;
    private final Duration timeout;
    private final int retries;

    private HttpRequest(Builder b) {
        this.url = b.url;
        this.method = b.method;
        this.timeout = b.timeout;
        this.retries = b.retries;
    }

    public static Builder builder(URI url) { // ← this is the static entry point
        return new Builder(url);
    }

    public static final class Builder {
        private final URI url;
        private String method = "GET";
        private Duration timeout = Duration.ofSeconds(30);
        private int retries = 0;

        private Builder(URI url) {
            this.url = url;
        }

        public Builder method(String method) {
            this.method = method;
            return this;
        }

        public Builder timeout(Duration timeout) {
            this.timeout = timeout;
            return this;
        }

        public Builder retries(int retries) {
            this.retries = retries;
            return this;
        }

        public HttpRequest build() {
            return new HttpRequest(this);
        }
    }
}

Callers get a chainable, self-documenting API and an immutable result:

HttpRequest req = HttpRequest.builder(url)
    .method("POST")
    .timeout(Duration.ofSeconds(10))
    .retries(3)
    .build();

Caller-site readability is a real win here.

A few things worth noticing about the shape:

  • All required fields go through the static entry point, not the Builder’s setters. builder(url) takes the URI because every request needs one; the compiler refuses to let you forget it. Optional fields with sensible defaults stay on the Builder.
  • The outer constructor is private and takes the Builder, not individual fields. This keeps the field-list in one place; adding a new optional field is one new field on each side and one chained method, never a constructor signature change.
  • build() returns the immutable outer class, snapshotting the Builder’s state. The Builder itself is reusable; calling build() twice produces two distinct HttpRequest instances.

You can also add validation in build() (“timeout must be positive”, “retries must be >= 0”), and the rest of the codebase only ever sees valid HttpRequest instances. That’s the third quiet win of this pattern: construction is the only place invariants need to be checked, because no setters exist on the finished object.

3.2. The GoF Builder

The Gang of Four version solves a slightly different problem. Instead of “this class has too many optional fields,” it says:

The same construction sequence should be able to produce different concrete products.

Building an HTML document and a Markdown document follows the same logical sequence (title → sections → footer) but produces structurally different output.

The pattern adds a fourth role on top of the three you’ve already seen: a Director that knows the construction sequence, an abstract Builder with the construction steps, concrete Builders that implement them per product family, and the Products themselves.

public interface ReportBuilder {
    void addTitle(String title);
    void addSection(String heading, String body);
    void addFooter(String footer);
    Report build();
}

public final class HtmlReportBuilder implements ReportBuilder {
    private final StringBuilder html = new StringBuilder("<html><body>");

    @Override
    public void addTitle(String t) {
        html.append("<h1>").append(t).append("</h1>");
    }

    @Override
    public void addSection(String h, String b) {
        html.append("<h2>").append(h).append("</h2><p>").append(b).append("</p>");
    }

    @Override
    public void addFooter(String f) {
        html.append("<footer>").append(f).append("</footer>");
    }

    @Override
    public Report build() {
        return new HtmlReport(html.append("</body></html>").toString());
    }
}

public final class MarkdownReportBuilder implements ReportBuilder { 
	/* same shape, # / ## / --- output */ 
}

The Director knows nothing about concrete products. It only knows the order of construction:

public final class ReportDirector {
    public Report assembleQuarterlyReport(ReportBuilder b) {
        b.addTitle("Q3 Results");
        b.addSection("Revenue", "...");
        b.addSection("Costs", "...");
        b.addFooter("Confidential");
        return b.build();
    }
}

Hand the Director a HtmlReportBuilder and you get an HTML report; hand it a MarkdownReportBuilder and you get a Markdown one. Same sequence, different output. The Director is the part that makes this pattern distinct from §3.1; without it, you have a fluent Builder with a slightly awkward step-method API.

You’ll see this shape in document generators, parser frameworks (where the “construction sequence” is parsing input and the “product” is an AST), and language tooling more broadly. In ordinary application code, the fluent Builder from §3.1 covers nearly every real use case. The GoF version pays for its extra layer only when the sequence itself is the thing worth abstracting.

ReportDirectorknows the construction sequenceReportBuilderabstract steps: addTitle / addSection / addFooter / buildHtmlReportBuilderMarkdownReportBuilderHtmlReportMarkdownReportdrivesimplemented bybuild()build()same construction sequence, different productsReportDirectorknows the construction sequenceReportBuilderabstract steps: addTitle / addSection / addFooter / buildHtmlReportBuilderMarkdownReportBuilderHtmlReportMarkdownReportdrivesimplemented bybuild()build()same construction sequence, different products

4. Beyond hand-written builders

Writing the §3.1 Builder by hand is a lot of code for what’s structurally a very repetitive pattern. Modern Java offers two ways to cut down on the boilerplate, attacking the problem from different angles.

4.1. Lombok’s @Builder

In codebases that already pull in Lombok, the @Builder annotation generates the entire fluent-Builder scaffolding at compile time. The HttpRequest from §3.1 collapses to:

@Builder
public final class HttpRequest {
    private final URI url;
    private final String method;
    private final Duration timeout;
    private final int retries;
}

That’s it. Lombok generates the static builder() factory, the inner Builder class, the chained setters, and build(). Defaults are declared per-field with @Builder.Default:

@Builder.Default private final String method = "GET";
@Builder.Default private final Duration timeout = Duration.ofSeconds(30);

The tradeoff: you save dozens of lines per class, but you lose some of the structural guarantees the hand-written version gives you. Vanilla @Builder doesn’t enforce required fields. There’s no equivalent of the §3.1 trick where builder(URI url) makes the URL non-optional at the type level. If a field must be set, you have to either validate after the fact or step outside the annotation. Lombok itself is a tradeoff too: it’s a compile-time code generator, which means new contributors need the Lombok IDE plugin to navigate the generated members, and some bytecode tooling finds the synthetic methods surprising.

In codebases that already use Lombok, @Builder is almost always the right answer. But in codebases that don’t, pulling Lombok in just for builders is probably not worth it IMO.

4.2. Java records

Java records (stable in Java 16+) attack the problem from the opposite direction: instead of generating a builder, they remove the need for one in cases where you didn’t really need Builder semantics to begin with.

public record Coordinate(double latitude, double longitude) {}

That single line gives you an immutable two-field carrier with an auto-generated constructor, accessors, equals, hashCode, and toString. If your “Builder candidate” is really just a bag of all-required fields with no defaults, a record can be the right tool for you.

Where records and Builder meet in practice is when you want both: an immutable record-style data class with a builder for ergonomic construction of objects that do have optional fields. That combination is perfectly legal, and increasingly common in modern Java:

public record HttpRequest(URI url, String method, Duration timeout, int retries) {
    public static Builder builder(URI url) {
        return new Builder(url);
    }

    public static final class Builder {
        // ...same shape as §3.1.
        // build() calls the canonical record constructor.
    }
}

You get the record’s auto-generated equals/hashCode/toString/accessors plus the Builder’s named-and-defaulted construction. For new code in Java 16+ (immutable, non-trivial field count, some optional), this combination is hard to beat.

5. When not to use it

Builders are easy to over-apply for the same reason factories are: the cost of adding one is low, and “we might add fields later” feels like prudent forethought. A few smells worth watching for:

  • The class has only a few fields, and none of them are optional. A constructor or a static factory method is the right tool. A Builder for an all-required two-field carrier is paperwork that produces no benefit; a record is the right answer in that case, not a Builder.
  • The resulting object isn’t immutable. Half the Builder’s payoff is that the finished object’s invariants are guaranteed at build() and can’t be broken later. If the class has public setters and gets mutated after construction, you’ve reintroduced the JavaBeans problem from §2 with extra ceremony.
  • The Builder is speculative. “We might add optional fields later” is one of the most common reasons developers reach for the pattern, and one of the worst. YAGNI applies — when a class actually needs a Builder, the field count and the call-site pain will make it obvious. Refactoring from constructor to Builder later is a mechanical change.
  • The Builder ends up bigger than the class it builds. A 40-line Builder for a 15-line class is a sign that the pattern is mismatched. Either the class is too small for Builder to earn its place, or the Builder is doing something it shouldn’t — validation that belongs in a separate validator, multi-object orchestration, configuration loading.

The honest rule of thumb: a Builder pays for itself when the class has many fields, most of them are optional, and the resulting object is meant to be immutable. Hit all three and the boilerplate is buying you something real. Miss any one of them and there’s a simpler tool that fits better.