Cruxly › Low-level design › More Design Patterns
Builder
construction with many optional, interdependent parameters
Open the interactive lessonForcing problem: a class needs a constructor with many optional parameters, some of which only make sense combined with others, and a plain constructor either explodes into overloads or accepts nulls for everything unused.
The violation: telescoping constructors
class HttpRequest {
HttpRequest(String url, String method, Map<String,String> headers,
String body, int timeoutMs, boolean followRedirects,
int maxRedirects, boolean verifySsl) { ... }
// a "convenience" overload for callers who don't need everything
HttpRequest(String url, String method) {
this(url, method, Map.of(), null, 5000, true, 5, true); // guessing defaults here
}
}
Every new optional parameter doubles the plausible overload combinations — this is called telescoping constructors, and it is recognisable by the symptom: most call sites pass null, 0, or a repeated default for parameters they don't care about, and a reader at the call site cannot tell which positional argument is which without checking the signature.
new HttpRequest("https://api.example.com", "POST", null, "{}", 0, true, 0, true);
// what is the 5th argument? the 7th? nobody can tell without looking it up
The fix, and why it is not just moving the problem
class HttpRequest {
private final String url, method, body;
private final Map<String,String> headers;
private final int timeoutMs, maxRedirects;
private final boolean followRedirects, verifySsl;
private HttpRequest(Builder b) { // private: only the Builder constructs one
this.url = b.url; this.method = b.method; this.body = b.body;
this.headers = Map.copyOf(b.headers);
this.timeoutMs = b.timeoutMs; this.maxRedirects = b.maxRedirects;
this.followRedirects = b.followRedirects; this.verifySsl = b.verifySsl;
}
static class Builder {
private final String url, method; // required: taken in the Builder's own constructor
private Map<String,String> headers = new HashMap<>();
private String body = null;
private int timeoutMs = 5000; // sensible defaults, named once, here
private int maxRedirects = 5;
private boolean followRedirects = true;
private boolean verifySsl = true;
Builder(String url, String method) { this.url = url; this.method = method; }
Builder header(String k, String v) { headers.put(k, v); return this; }
Builder body(String b) { this.body = b; return this; }
Builder timeoutMs(int ms) { this.timeoutMs = ms; return this; }
Builder noRedirects() { this.followRedirects = false; return this; }
HttpRequest build() {
if (method.equals("POST") && body == null)
throw new IllegalStateException("POST requires a body"); // validate HERE
return new HttpRequest(this);
}
}
}
HttpRequest req = new HttpRequest.Builder("https://api.example.com", "POST")
.header("Content-Type", "application/json")
.body("{\"id\": 42}")
.timeoutMs(2000)
.build();
This is not just "moving the parameters into a different class." Three real properties come from it: call sites are self-documenting (.timeoutMs(2000) reads correctly with no signature lookup), defaults live in exactly one place instead of being repeated across overloads, and cross-field validation happens in build(), at the one moment the object is known to be fully specified — "POST requires a body" cannot be checked meaningfully mid-construction, only once every field has been set.
The mechanism: why chaining setters return this
The target object stays immutable; only the Builder is mutable. This split matters: HttpRequest has no setters and cannot be changed after construction, which is what makes it safe to pass around and cache. The Builder is the one object allowed to be in a half-specified state, and it exists for exactly as long as construction takes.
When Builder is the wrong answer
Two or three optional parameters do not need a Builder. A constructor with two parameters and one overload is not telescoping — it is fine. Reaching for Builder pre-emptively on a simple class adds a second class and a .build() call for no real benefit; the same "don't add a seam nobody asked for" discipline from Open/Closed elsewhere in this track applies here too.
A class whose fields are genuinely all required has no construction problem to solve. If every field must always be set, a single constructor that takes all of them is more honest than a Builder that merely delays the same requirement to a build()-time check — the Builder is buying you nothing if there is no optionality to manage.
:::warn The over-applied case A Builder generated by an IDE or annotation processor for every data class in a codebase, regardless of parameter count, is decoration. The test is the same one used throughout this track for every pattern: name the problem first — "many optional, interdependent parameters" — and if that problem does not exist, neither does the justification for the pattern. :::
How to say it in the interview
"The tell is a constructor with five or more parameters where most calls only set two or three of them — that is not a constructor problem, it is a construction problem, and Builder is the name for separating the two."
Checklist
- Recognised telescoping constructors as the forcing problem, not the pattern name
- Kept the target object immutable and made only the Builder mutable
- Validated cross-field constraints inside build(), not per-setter
- Made each setter return this so calls chain
- Rejected Builder for a class with two or three simple optional parameters
- Rejected Builder for a class whose fields are all required