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:
- Poor readability: it is hard to remember what each argument means.
- Easy mistakes: swapping
timeoutandmaxRetriesstill compiles if types match. - 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);
}
}
}
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)
}
}
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)
}
}
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)
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:
- defaults are applied correctly;
- custom values override defaults;
- cross-field validation rules hold;
- 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());
}
@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() }
}
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()
}
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()
}
When to Use the Builder Pattern
Use Builder when:
- objects have many optional parameters;
- you need readable, self-documenting construction;
- 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:
- HTTP and SDK clients (
OkHttpClient.Builder, AWS SDK builders, Elasticsearch clients). - Database connection and pool configs (timeouts, retries, TLS, failover).
- Messaging and event publisher configs (batch sizes, delivery guarantees, backpressure).
- Domain commands/events where required fields and optional metadata must be explicit.
- 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.
Code Samples
All examples in this post are available in the repository:
Implementation files:
- Java 21: HttpClientConfig.java
- Kotlin: HttpClientConfig.kt
- Scala 2: HttpClientConfig.scala
- Scala 3: HttpClientConfig.scala
Test files:
- Java 21: HttpClientConfigTest.java
- Kotlin: HttpClientConfigTest.kt
- Scala 2: HttpClientConfigTest.scala
- Scala 3: HttpClientConfigTest.scala
This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.