Skip to content

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

ParticipantRole
HttpRequestProduct — the immutable complex object being constructed.
HttpRequest.BuilderBuilder — accumulates construction parameters through fluent setters and validates them in build().
HttpRequestDirectorDirector — 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 String arguments (e.g., url and method) 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=GET implies body=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

BenefitExplanation
Readable call sitesNamed setters make each argument's purpose obvious without IDE assistance.
Single validation pointbuild() enforces all invariants in one place, preventing partially constructed objects from escaping.
ImmutabilityThe product is fully constructed before it is returned; fields can be final.
Optional parameters without overloadsAny subset of optional parameters can be set; no combinatorial constructor explosion.
ExtensibilityAdding 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-offExplanation
VerbosityMore code than a constructor. Justified only when the number of parameters or the validation complexity warrants it.
Stale builder stateA 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 fieldsIf 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

DimensionBuilderFactory Method
Primary concernHow a complex object is assembled step by stepWhich class to instantiate
Product complexityMulti-parameter, often immutable, complex structureTypically simpler; complexity is in selecting the right subtype
Call siteProduct.builder().a(...).b(...).build()factory.create(type)
Variation axisConfiguration options for one productDifferent 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.

  • Factory Method: Complementary pattern — a factory method often returns a builder (as in HttpRequest.builder() or S3Client.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 Builder or Director interface 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.