Appearance
Builder
GoF Design Patterns — Creational · See also: Factory Method · Singleton
Introduction
Builder is a creational design pattern that separates the construction of a complex object from its representation. Instead of a single constructor with many parameters, construction proceeds through a series of small, named steps on a builder object, culminating in a build() call that produces the final immutable product. Builder eliminates the telescoping constructor anti-pattern, makes illegal states unrepresentable, and produces call sites that read like natural language.
Intent
Separate the construction of a complex object from its representation so that the same construction process can create different representations.
Structure
Participants
| Participant | Role |
|---|---|
HttpRequest | Product — the immutable complex object being constructed. |
HttpRequest.Builder | Builder — accumulates construction parameters through fluent setters and validates them in build(). |
HttpRequestDirector | Director — encapsulates preset construction sequences; optional but useful for reusing standard configurations. |
Anti-Pattern — Telescoping Constructor
The telescoping constructor anti-pattern occurs when every optional parameter combination needs its own constructor overload. With six parameters, it becomes unreadable and error-prone.
java
// ANTI-PATTERN: Telescoping constructor — which int is which?
public class HttpRequest {
private final String url;
private final String method;
private final Map<String, String> headers;
private final String body;
private final int timeoutMs;
private final boolean followRedirects;
// Minimal constructor
public HttpRequest(String url, String method) {
this(url, method, Map.of(), null, 5000, true);
}
// With timeout
public HttpRequest(String url, String method, int timeoutMs) {
this(url, method, Map.of(), null, timeoutMs, true);
}
// With body
public HttpRequest(String url, String method, String body) {
this(url, method, Map.of(), body, 5000, true);
}
// Full constructor — which argument is which without an IDE tooltip?
public HttpRequest(String url, String method,
Map<String, String> headers, String body,
int timeoutMs, boolean followRedirects) {
this.url = url;
this.method = method;
this.headers = headers;
this.body = body;
this.timeoutMs = timeoutMs;
this.followRedirects = followRedirects;
}
}
// Call site — impossible to understand without reading the constructor signature
HttpRequest request = new HttpRequest(
"https://api.example.com/orders",
"POST",
Map.of("Content-Type", "application/json"),
"{\"orderId\":\"42\"}",
3000,
false // What does false mean here? followRedirects? something else?
);Problems with this design:
- Swapping two
Stringarguments (e.g.,urlandmethod) is a silent bug the compiler cannot catch. - Adding a seventh parameter (
retryCount) requires a new constructor overload and forces every existing call site to be reviewed. - There is no obvious place to put parameter validation — it either clutters the constructor or is omitted entirely.
- Optional parameters with defaults are expressed as additional overloads, causing combinatorial explosion.
Correct Implementation
Replace the telescoping constructors with a static nested Builder class. The product is constructed immutably through the builder, and build() is the single point of validation.
The product — immutable, all-field constructor is private
java
// HttpRequest.java — immutable product with a fluent builder
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public final class HttpRequest {
private final String url;
private final String method;
private final Map<String, String> headers;
private final String body;
private final int timeoutMs;
private final boolean followRedirects;
// Private — only the Builder can call this
private HttpRequest(Builder builder) {
this.url = builder.url;
this.method = builder.method;
this.headers = Collections.unmodifiableMap(new HashMap<>(builder.headers));
this.body = builder.body;
this.timeoutMs = builder.timeoutMs;
this.followRedirects = builder.followRedirects;
}
// Factory method to obtain a builder — makes the call site more readable
public static Builder builder() {
return new Builder();
}
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 boolean isFollowRedirects() { return followRedirects; }
@Override
public String toString() {
return String.format("HttpRequest{method=%s, url=%s, timeout=%dms, body=%s}",
method, url, timeoutMs, body);
}
// -------------------------------------------------------------------------
// Static nested Builder
// -------------------------------------------------------------------------
public static final class Builder {
// Required parameters — no defaults
private String url;
private String method;
// Optional parameters — sensible defaults
private Map<String, String> headers = new HashMap<>();
private String body = null;
private int timeoutMs = 5_000;
private boolean followRedirects = true;
private Builder() {}
public Builder url(String url) {
this.url = Objects.requireNonNull(url, "url must not be null");
return this;
}
public Builder method(String method) {
this.method = Objects.requireNonNull(method, "method must not be null");
return this;
}
public Builder header(String name, String value) {
Objects.requireNonNull(name, "header name must not be null");
Objects.requireNonNull(value, "header value must not be null");
this.headers.put(name, value);
return this;
}
public Builder body(String body) {
this.body = body;
return this;
}
public Builder timeoutMs(int timeoutMs) {
if (timeoutMs <= 0) {
throw new IllegalArgumentException("timeoutMs must be positive, got: " + timeoutMs);
}
this.timeoutMs = timeoutMs;
return this;
}
public Builder followRedirects(boolean followRedirects) {
this.followRedirects = followRedirects;
return this;
}
// Single validation point — called once at the end of construction
public HttpRequest build() {
if (url == null || url.isBlank()) {
throw new IllegalStateException("url is required");
}
if (method == null || method.isBlank()) {
throw new IllegalStateException("method is required");
}
if (body != null && method.equalsIgnoreCase("GET")) {
throw new IllegalStateException("GET requests must not have a body");
}
return new HttpRequest(this);
}
}
}Call sites — self-documenting
java
// Minimal GET request
HttpRequest getRequest = HttpRequest.builder()
.url("https://api.example.com/orders/42")
.method("GET")
.build();
// Full POST request — each parameter is named, order does not matter
HttpRequest postRequest = HttpRequest.builder()
.url("https://api.example.com/orders")
.method("POST")
.header("Content-Type", "application/json")
.header("X-Correlation-ID", "abc-123")
.body("{\"productId\":\"prod-7\",\"quantity\":3}")
.timeoutMs(3_000)
.followRedirects(false)
.build();
// Runtime validation catches misconfiguration early
try {
HttpRequest badRequest = HttpRequest.builder()
.url("https://api.example.com/products")
.method("GET")
.body("{\"should\":\"not be here\"}") // ← validation fails here
.build();
} catch (IllegalStateException e) {
System.err.println("Configuration error: " + e.getMessage());
}Interaction flow
Director — Encapsulating Preset Configurations
A Director class knows how to configure a Builder for a specific, frequently used construction sequence. Callers use the Director instead of reconfiguring the Builder from scratch each time.
java
// HttpRequestDirector.java — encapsulates standard request configurations
public class HttpRequestDirector {
private final String baseUrl;
private final String authToken;
public HttpRequestDirector(String baseUrl, String authToken) {
this.baseUrl = baseUrl;
this.authToken = authToken;
}
// Preset: authenticated JSON POST — used by many call sites
public HttpRequest buildJsonPostRequest(String path, String jsonBody) {
return HttpRequest.builder()
.url(baseUrl + path)
.method("POST")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + authToken)
.body(jsonBody)
.timeoutMs(10_000)
.followRedirects(false)
.build();
}
// Preset: lightweight health check — short timeout, no body
public HttpRequest buildHealthCheckRequest(String path) {
return HttpRequest.builder()
.url(baseUrl + path)
.method("GET")
.header("Accept", "application/json")
.timeoutMs(2_000)
.followRedirects(false)
.build();
}
// Preset: idempotent PUT with retry headers
public HttpRequest buildIdempotentPutRequest(String path,
String idempotencyKey,
String body) {
return HttpRequest.builder()
.url(baseUrl + path)
.method("PUT")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + authToken)
.header("Idempotency-Key", idempotencyKey)
.body(body)
.timeoutMs(15_000)
.build();
}
}
// Usage — client calls the Director, never configures the Builder manually
public class OrderApiClient {
private final HttpRequestDirector director;
public OrderApiClient(String apiBaseUrl, String authToken) {
this.director = new HttpRequestDirector(apiBaseUrl, authToken);
}
public void createOrder(String orderJson) {
HttpRequest request = director.buildJsonPostRequest("/orders", orderJson);
// dispatch request...
System.out.println("Dispatching: " + request);
}
public boolean isHealthy() {
HttpRequest request = director.buildHealthCheckRequest("/health");
// dispatch and check status...
return true;
}
}Real-World Examples
Java standard library
java
// StringBuilder — the canonical Builder example in the JDK
String result = new StringBuilder()
.append("Hello")
.append(", ")
.append("World")
.append("!")
.toString();
// HttpClient.newBuilder() — Builder for the JDK 11+ HTTP client
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
.version(java.net.http.HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(java.net.http.HttpClient.Redirect.NORMAL)
.build();AWS SDK for Java v2
Every AWS SDK v2 client uses a Builder. The SDK enforces that clients are immutable once built; all configuration is expressed through the builder chain:
java
// S3Client — region, credentials, endpoint override all set through the builder
S3Client s3 = S3Client.builder()
.region(Region.US_EAST_1)
.credentialsProvider(DefaultCredentialsProvider.create())
.build();
// DynamoDbClient with custom endpoint for local testing
DynamoDbClient dynamo = DynamoDbClient.builder()
.region(Region.US_EAST_1)
.endpointOverride(URI.create("http://localhost:8000"))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create("local", "local")))
.build();
// SqsClient with custom HTTP client settings via builder composition
SdkHttpClient httpClient = ApacheHttpClient.builder()
.maxConnections(100)
.connectionTimeout(Duration.ofSeconds(5))
.socketTimeout(Duration.ofSeconds(30))
.build();
SqsClient sqs = SqsClient.builder()
.region(Region.EU_WEST_1)
.httpClient(httpClient)
.build();
// Sending a message also uses Builder
sqs.sendMessage(SendMessageRequest.builder()
.queueUrl("https://sqs.eu-west-1.amazonaws.com/123456789012/OrderEvents")
.messageBody("{\"orderId\":\"42\",\"status\":\"CONFIRMED\"}")
.messageGroupId("orders")
.build());Test data builders (Object Mother variant)
Builder is invaluable in tests for creating domain objects with readable, intention-revealing defaults:
java
// OrderBuilder.java — test data builder with safe defaults
public class OrderBuilder {
private String orderId = UUID.randomUUID().toString();
private String customerId = "test-customer-1";
private String status = "CREATED";
private BigDecimal total = new BigDecimal("99.99");
private String productId = "prod-default";
public OrderBuilder orderId(String orderId) {
this.orderId = orderId;
return this;
}
public OrderBuilder customerId(String customerId) {
this.customerId = customerId;
return this;
}
public OrderBuilder status(String status) {
this.status = status;
return this;
}
public OrderBuilder total(BigDecimal total) {
this.total = total;
return this;
}
public Order build() {
return new Order(orderId, customerId, status, total, productId);
}
}
// Tests read like specifications, not data setup
class OrderServiceTest {
@Test
void shouldRefundConfirmedOrdersOnly() {
Order confirmedOrder = new OrderBuilder()
.customerId("vip-customer")
.status("CONFIRMED")
.total(new BigDecimal("250.00"))
.build();
Order pendingOrder = new OrderBuilder()
.status("CREATED")
.build();
assertTrue(refundService.canRefund(confirmedOrder));
assertFalse(refundService.canRefund(pendingOrder));
}
}When to Use
- A class has more than three or four constructor parameters, especially when several are optional.
- Some parameters have sensible defaults and most call sites only need to customize one or two.
- You need to enforce a validation contract that spans multiple parameters (e.g.,
method=GETimpliesbody=null). - You want to produce immutable objects without sacrificing readable construction syntax.
- A Director can capture frequently used construction sequences that would otherwise be copy-pasted across the codebase.
When Not to Use
- The object has only one or two mandatory parameters with no optional variants. A simple constructor is clearer.
- The "object" is a simple value type better expressed as a Java
record. Records give you compact construction without a builder.
Consequences
Benefits
| Benefit | Explanation |
|---|---|
| Readable call sites | Named setters make each argument's purpose obvious without IDE assistance. |
| Single validation point | build() enforces all invariants in one place, preventing partially constructed objects from escaping. |
| Immutability | The product is fully constructed before it is returned; fields can be final. |
| Optional parameters without overloads | Any subset of optional parameters can be set; no combinatorial constructor explosion. |
| Extensibility | Adding a new parameter to the builder is a non-breaking change — existing call sites that don't set the new parameter receive the default. |
Trade-offs
| Trade-off | Explanation |
|---|---|
| Verbosity | More code than a constructor. Justified only when the number of parameters or the validation complexity warrants it. |
| Stale builder state | A Builder instance is mutable; calling build() twice on the same builder produces two products from the same accumulated state. Make builder instances local, or reset state after build(). |
| No compile-time enforcement of required fields | If url is required but not set, the error appears at runtime in build(), not at compile time. Annotation processors (e.g., Lombok @NonNull) can catch this earlier. |
Builder vs. Factory Method
| Dimension | Builder | Factory Method |
|---|---|---|
| Primary concern | How a complex object is assembled step by step | Which class to instantiate |
| Product complexity | Multi-parameter, often immutable, complex structure | Typically simpler; complexity is in selecting the right subtype |
| Call site | Product.builder().a(...).b(...).build() | factory.create(type) |
| Variation axis | Configuration options for one product | Different product types sharing an interface |
Use Builder when you have a single complex object with many configuration options. Use Factory Method when the key decision is which class to instantiate.
Related Concepts
- Factory Method: Complementary pattern — a factory method often returns a builder (as in
HttpRequest.builder()orS3Client.builder()), combining both patterns. - Singleton: A builder that always returns the same pre-configured instance is an implementation of Singleton; connection pool clients built once at startup are a common example.
- Dependency Inversion Principle: Inject the
BuilderorDirectorinterface rather than the concrete builder to allow different construction strategies to be swapped in tests. - Clean Architecture: Directors belong in the application or infrastructure layer; the product (e.g.,
HttpRequest) is a pure value object that can live in the domain layer with no external dependencies.