Design Patterns in Java

Builder Pattern

15 min Lesson 4 of 13

Builder Pattern

The Builder pattern separates the construction of a complex object from its representation, allowing the same construction process to produce different results. In modern Java it is the canonical solution for building immutable value objects that have many optional fields — the situations where telescoping constructors become unreadable and setters prevent immutability.

Why Not Just Use Constructors or Setters?

Imagine a HttpRequest that has a required URL, a required method, and eight optional fields (headers, body, timeout, follow-redirects, auth, proxy, cache policy, retry count). Two common but flawed approaches are:

  • Telescoping constructors — you write one constructor per combination. With eight optional fields you end up with dozens, and call sites like new HttpRequest(url, method, null, null, 30, true, null, null, 3) are impossible to read.
  • JavaBean style (mutable setters) — the object starts in an inconsistent state, it cannot be made final/immutable, and thread-safety requires external synchronisation.

The Builder pattern solves both problems: a fluent API makes call sites self-documenting, and the target object is assembled only once — at build() time — so it can be fully immutable.

The Classic Static Inner Builder

The idiomatic Java form places a public static final class Builder inside the target class. The target class has a private constructor that accepts only the builder.

public final class HttpRequest { // Required fields private final String url; private final String method; // Optional fields — sane defaults private final int timeoutMs; private final int maxRetries; private final boolean followRedirects; private final String body; private final String authToken; // Private: only the Builder can call this private HttpRequest(Builder b) { this.url = b.url; this.method = b.method; this.timeoutMs = b.timeoutMs; this.maxRetries = b.maxRetries; this.followRedirects = b.followRedirects; this.body = b.body; this.authToken = b.authToken; } // Accessors only — no setters public String getUrl() { return url; } public String getMethod() { return method; } public int getTimeoutMs() { return timeoutMs; } public int getMaxRetries() { return maxRetries; } public boolean isFollowRedirects() { return followRedirects; } public String getBody() { return body; } public String getAuthToken() { return authToken; } @Override public String toString() { return method + " " + url + " [timeout=" + timeoutMs + "ms, retries=" + maxRetries + "]"; } // ---- Builder ---- public static final class Builder { // Required — injected via constructor private final String url; private final String method; // Optional — defaults set here private int timeoutMs = 5_000; private int maxRetries = 0; private boolean followRedirects = true; private String body = null; private String authToken = null; public Builder(String url, String method) { if (url == null || url.isBlank()) throw new IllegalArgumentException("url required"); if (method == null || method.isBlank()) throw new IllegalArgumentException("method required"); this.url = url; this.method = method.toUpperCase(); } public Builder timeoutMs(int ms) { this.timeoutMs = ms; return this; } public Builder maxRetries(int n) { this.maxRetries = n; return this; } public Builder followRedirects(boolean f) { this.followRedirects = f; return this; } public Builder body(String body) { this.body = body; return this; } public Builder authToken(String token) { this.authToken = token; return this; } public HttpRequest build() { // Cross-field validation lives here, not in the target class if ("GET".equals(method) && body != null) { throw new IllegalStateException("GET requests must not have a body"); } return new HttpRequest(this); } } }

A call site reads like a sentence:

HttpRequest req = new HttpRequest.Builder("https://api.example.com/users", "POST") .authToken("Bearer eyJhbG...") .body("{\"name\":\"Alice\"}") .timeoutMs(10_000) .maxRetries(3) .build();
Required vs optional fields: pass required parameters to the Builder constructor so the compiler enforces them. Optional parameters get default values and fluent setters. This is clearer than making every field optional and validating completeness in build().

Validation in build()

The build() method is the right place for cross-field invariants. Single-field validation (null checks, range checks) belongs in each fluent setter so the error is raised close to the bad call. For instance:

public Builder maxRetries(int n) { if (n < 0) throw new IllegalArgumentException("maxRetries must be >= 0"); this.maxRetries = n; return this; }
Fail fast, fail close: throw IllegalArgumentException in the setter the moment you see bad data. If you defer all validation to build() the stack trace points there, not at the actual bad call, and debugging takes longer.

Copy Builders — Safe Immutable Updates

Because the target object is immutable you cannot modify it after construction. A copy builder (also called a withX pattern) lets you derive a new object from an existing one while changing only specific fields — the same idea as Java records' with expressions:

// Add a static factory that pre-populates a Builder from an existing instance public static Builder from(HttpRequest source) { return new Builder(source.url, source.method) .timeoutMs(source.timeoutMs) .maxRetries(source.maxRetries) .followRedirects(source.followRedirects) .body(source.body) .authToken(source.authToken); }
// Usage: derive a new request with a different timeout HttpRequest retried = HttpRequest.from(original) .timeoutMs(30_000) .maxRetries(5) .build();

Builder vs Java Records

Java 16+ records give you compact immutable data carriers for simple cases. Use a Builder when:

  • There are many optional fields (records have a single canonical constructor — all fields required).
  • You need cross-field validation logic in build().
  • The object is part of a fluent API (query builders, HTTP clients, test fixtures).
  • You want to support copy builders cleanly.

For a 3-field data holder with no optional fields and no validation, a record is simpler and preferable.

Lombok @Builder — When to Reach for It

In production codebases Lombok's @Builder annotation generates the inner Builder at compile time, eliminating boilerplate. The trade-off: the generated builder has no required-field enforcement and no custom validation unless you add @Builder.ObtainVia and manual build() overrides. Use it for straightforward DTO / value objects; hand-roll when you need strict invariants.

Thread safety: the Builder itself is not thread-safe. Never share a partially-built Builder across threads. The built object is safe to share because it is immutable — but construction must stay on one thread.

Real-World Usage in the JDK and Ecosystem

The pattern is everywhere in modern Java:

  • StringBuilder / StringJoiner — accumulate parts, produce a String.
  • HttpClient.newBuilder() / HttpRequest.newBuilder() — JDK 11+ HTTP client.
  • ProcessBuilder — configure and launch OS processes.
  • Spring's MockMvcRequestBuilders, Hibernate's CriteriaBuilder, Guava's ImmutableList.builder().

Summary

The Builder pattern is the professional answer to complex object construction in Java. By placing required fields in the Builder constructor, providing fluent optional-field setters, centralising validation in build(), and keeping the target class fully immutable, you get readable call sites, compile-time safety, and objects that are trivially safe to share across threads. The copy-builder extension makes immutable updates ergonomic. In the next lesson we move to behavioural patterns, starting with Strategy.