Builder Pattern: Constructing Complex Objects Clearly

java java21 scala scala2 scala3 kotlin design-patterns creational-patterns builder-pattern

Imagine your production HTTP client needs host, port, separate connect/read timeouts, retry strategy with backoff, default headers, API versioning, compression, and circuit-breaker tuning. A giant constructor quickly becomes unreadable, and every optional parameter makes call sites harder to understand.

That is exactly where the Builder pattern helps: keep required fields explicit, set optional fields fluently, and validate everything in one place before creating the final object.

The Problem: Telescoping Constructors and Confusing Calls

Without a builder, constructor calls become fragile:

new HttpClientConfig(
    "api.example.com", 443, 500, 2000, true, 3,
    List.of(100, 200, 500), Map.of("Accept", "application/json"),
    50, "v1", true
);

This creates three common problems:

  1. Poor readability: it is hard to remember what each argument means.
  2. Easy mistakes: swapping timeout and maxRetries still compiles if types match.
  3. Scattered validation: invalid states can sneak into different constructors.

Key Concepts

Concept What it means Why it matters
Required fields Values needed to build a valid object (host, port) Prevents half-built configurations
Optional fields Values with safe defaults (timeouts, retries, headers, version, compression) Keeps call sites concise
Fluent API Chained method calls on builder Improves readability
Central validation Validation inside build() Ensures one source of truth
Cross-field rules Validate relationships (e.g. read timeout ≥ connect timeout) Prevents invalid runtime configs

The Solution: Builder Across JVM Languages

Below is the same HttpClientConfig builder idea in Java 21, Kotlin, Scala 2, and Scala 3.

public final class HttpClientConfig {
    public static Builder builder(String host, int port) {
        return new Builder(host, port);
    }
    public static final class Builder {
        private int timeoutSeconds = 30;
        private boolean useSsl = true;
        public Builder timeoutSeconds(int value) { timeoutSeconds = value; return this; }
        public Builder useSsl(boolean value) { useSsl = value; return this; }
        public HttpClientConfig build() {
            if (timeoutSeconds <= 0) throw new IllegalArgumentException("Timeout must be positive");
            return new HttpClientConfig(this);
        }
    }
}

View in repository

data class HttpClientConfig(
    val host: String,
    val port: Int,
    val timeoutSeconds: Int = 30
)
class HttpClientConfigBuilder private constructor(private val host: String, private val port: Int) {
    private var timeoutSeconds: Int = 30
    fun timeoutSeconds(value: Int): HttpClientConfigBuilder { timeoutSeconds = value; return this }
    fun build(): HttpClientConfig {
        require(timeoutSeconds > 0) { "Timeout must be positive" }
        return HttpClientConfig(host = host, port = port, timeoutSeconds = timeoutSeconds)
    }
}

View in repository

final case class HttpClientConfig(host: String, port: Int, timeoutSeconds: Int = 30)
object HttpClientConfigBuilder {
  def builder(host: String, port: Int): HttpClientConfigBuilder = new HttpClientConfigBuilder(host, port)
}
final class HttpClientConfigBuilder private (host: String, port: Int, timeoutSeconds: Int = 30) {
  def timeoutSeconds(value: Int): HttpClientConfigBuilder = new HttpClientConfigBuilder(host, port, value)
  def build(): HttpClientConfig = {
    if (timeoutSeconds <= 0) throw new IllegalArgumentException("Timeout must be positive")
    HttpClientConfig(host, port, timeoutSeconds)
  }
}

View in repository

final case class HttpClientConfig(host: String, port: Int, timeoutSeconds: Int = 30)
object HttpClientConfigBuilder:
  def builder(host: String, port: Int): HttpClientConfigBuilder = HttpClientConfigBuilder(host, port)
final case class HttpClientConfigBuilder private (host: String, port: Int, timeoutSeconds: Int = 30):
  def withTimeoutSeconds(value: Int): HttpClientConfigBuilder = copy(timeoutSeconds = value)
  def build(): HttpClientConfig =
    if timeoutSeconds <= 0 then throw new IllegalArgumentException("Timeout must be positive")
    HttpClientConfig(host, port, timeoutSeconds)

View in repository

Comparison: Java 21 vs Scala 2 vs Scala 3 vs Kotlin

Language Builder style Defaults style Validation style
Java 21 Nested mutable fluent builder Fields in builder class Throw in build()
Scala 2 Immutable fluent builder returning new instances Default params in case class + builder defaults Throw in build()
Scala 3 Case-class builder with fluent with... methods Default params + copy ergonomics Throw in build()
Kotlin Mutable fluent builder + data class target Data class defaults and builder defaults require(...) in build()

Testing the Builder

Builder tests should verify:

  1. defaults are applied correctly;
  2. custom values override defaults;
  3. cross-field validation rules hold;
  4. invalid input fails fast with useful errors.
@Test
void shouldBuildWithDefaults() {
    HttpClientConfig config = HttpClientConfig.builder("api.example.com", 443).build();
    assertEquals(500, config.connectTimeoutMs());
}
@Test
void shouldRejectInvalidPort() {
    assertThrows(IllegalArgumentException.class, () -> HttpClientConfig.builder("api.example.com", 70000).build());
}

View full test file

@Test
fun shouldBuildWithDefaults() {
    val config = HttpClientConfigBuilder.builder("api.example.com", 443).build()
    assertEquals(500, config.connectTimeoutMs)
}
@Test
fun shouldRejectInvalidPort() {
    assertThrows(IllegalArgumentException::class.java) { HttpClientConfigBuilder.builder("api.example.com", 70000).build() }
}

View full test file

test("Builder should create config with defaults") {
  val config = HttpClientConfigBuilder.builder("api.example.com", 443).build()
  config.connectTimeoutMs shouldBe 500
}
test("Builder should reject invalid port") {
  the[IllegalArgumentException] thrownBy HttpClientConfigBuilder.builder("api.example.com", 70000).build()
}

View full test file

test("Builder should create config with defaults") {
  val config = HttpClientConfigBuilder.builder("api.example.com", 443).build()
  config.connectTimeoutMs shouldBe 500
}
test("Builder should reject invalid port") {
  the[IllegalArgumentException] thrownBy HttpClientConfigBuilder.builder("api.example.com", 70000).build()
}

View full test file

When to Use the Builder Pattern

Use Builder when:

  1. objects have many optional parameters;
  2. you need readable, self-documenting construction;
  3. validation should happen once, at object creation.

Prefer simple constructors or data class defaults when the object has only a few fields and no complex validation.

Where Builder Is Most Common in Real Projects

You will see Builder used heavily in production code where objects have many options and strict invariants:

  1. HTTP and SDK clients (OkHttpClient.Builder, AWS SDK builders, Elasticsearch clients).
  2. Database connection and pool configs (timeouts, retries, TLS, failover).
  3. Messaging and event publisher configs (batch sizes, delivery guarantees, backpressure).
  4. Domain commands/events where required fields and optional metadata must be explicit.
  5. Test fixtures for complex object setup without noisy constructors.

This is exactly why Builder is so valuable: it balances readability, safe defaults, and validation while still making call sites pleasant to read.

Interview Q&A: Builder Pattern in Practice

When do you use the Builder pattern?
You use a builder when a class has many optional settings, a lot of constructor arguments, or rules that must be checked before creation. It is especially useful for configuration objects, HTTP clients, or domain commands where the object is easier to read when you set each field clearly. The builder keeps the construction step readable without forcing callers to remember a long parameter list.
Why is a Builder easier to read than a long constructor?
A long constructor is easy to get wrong because the arguments are just values in a row. A builder makes each option obvious: you can see `timeout()`, `retries()`, and `tls()` as named steps instead of guessing which number is which. This makes the code easier to maintain and much less likely to cause mistakes when someone adds a new field later.
How is Builder different from default parameters or records?
Default parameters and records are great when the object is simple and the number of fields is small. Builder shines when the object becomes more complex, when some values are required and others are optional, or when you want to validate the final object before it is created. In other words, builder is often about readability and safety, not just syntax convenience.
What is a real-world example of a Builder?
A web client configuration is a good example. You may need a base URL, timeout, retry count, TLS setting, and a list of headers. Calling a builder like `clientBuilder.withTimeout(30_000).withRetries(3).withTls(true)` is much easier to follow than a constructor with ten positional arguments. Many HTTP libraries and SDKs use this pattern for exactly that reason.
Can Builder make validation easier?
Yes. A builder is a natural place to validate data before the final object is created. You can check required values, reject impossible combinations, and fail early with an obvious message. That is much cleaner than creating an object in a half-valid state and discovering the problem later during runtime. Good builders help enforce the rules of the domain.

Code Samples

All examples in this post are available in the repository:

Implementation files:

Test files:


This post is part of the Design Patterns in JVM Languages - Your Guide to the Top 10. Next related posts: Prototype Pattern: Cloning for Success and Adapter Pattern: Making Incompatible Payment APIs Work Together.