Escaping telescoping constructors with a fluent, method-chaining builder API — built through an HttpRequest example with required and optional fields.
Published September 22, 2026
class HttpRequest {
HttpRequest(String url) { ... }
HttpRequest(String url, String method) { ... }
HttpRequest(String url, String method, Map<String,String> headers) { ... }
HttpRequest(String url, String method, Map<String,String> headers, String body) { ... }
HttpRequest(String url, String method, Map<String,String> headers, String body, int timeoutMs) { ... }
}
Each new optional field means another constructor overload, and callers with several optional parameters set have to pass null/defaults through ones they don't care about, in a fixed, easy-to-mix-up positional order — new HttpRequest(url, null, null, body, 5000) gives no clue at the call site which value is which.
class HttpRequest {
private final String url; // required
private final String method; // required
private final Map<String,String> headers;
private final String body;
private final int timeoutMs;
private HttpRequest(Builder b) {
this.url = b.url;
this.method = b.method;
this.headers = b.headers;
this.body = b.body;
this.timeoutMs = b.timeoutMs;
}
static class Builder {
private final String url; // required — passed to Builder's constructor
private final String method; // required
private Map<String,String> headers = new HashMap<>();
private String body = null;
private int timeoutMs = 30_000; // sensible default
Builder(String url, String method) { // required fields enforced here
this.url = url;
this.method = method;
}
Builder header(String key, String value) { headers.put(key, value); return this; }
Builder body(String body) { this.body = body; return this; }
Builder timeoutMs(int ms) { this.timeoutMs = ms; return this; }
HttpRequest build() { return new HttpRequest(this); }
}
}
thisHttpRequest req = new HttpRequest.Builder("https://api.example.com/users", "POST")
.header("Content-Type", "application/json")
.body("{\"name\":\"Alice\"}")
.timeoutMs(5000)
.build();
Each setter-style method returns this (the builder instance), which is exactly what allows the calls to chain into one fluent expression instead of a sequence of separate statements. This reads almost like a sentence describing the object being built — every optional field is named explicitly at the call site, unlike a long positional constructor call.
The design decision that makes this actually safe, not just readable: url and method are required, so they're passed into the Builder's own constructor — there's no way to call build() without having supplied them, since there's no way to construct the Builder itself without them. Everything genuinely optional (headers, body, timeoutMs) gets its own chainable setter with a sensible default. This split is what prevents a builder from just becoming "a telescoping constructor with extra steps" — required-ness is enforced by the type system's constructor requirement, not by convention or a runtime null-check in build().
Q: Why make the outer class's constructor private and only reachable through the Builder?
A: It forces every caller through the builder's required-field constructor and its build() step — there's no back door that lets a caller construct an HttpRequest directly and skip validation or default-filling that build() might otherwise perform.
Q: How is this different from just using a mutable setter-based POJO (new HttpRequest(); req.setUrl(...); req.setMethod(...);)?
A: Two differences that matter: the final object can be genuinely immutable (the Builder holds the mutable state during construction; the built object's fields are final), and required fields are enforced at compile time via the Builder's constructor — a plain setter-based POJO gives no compile-time guarantee that setUrl() was ever called before the object gets used.
Q: When would a Builder be overkill?
A: For an object with two or three fields, all effectively required — a normal constructor is clearer and the Builder's extra ceremony (a nested class, chained setters, a build() call) doesn't earn its cost. Builder pays off specifically when there's a meaningful mix of required and optional fields, typically four or more total.
Q: Is Lombok's @Builder annotation the same pattern?
A: Functionally yes — it generates this exact structure (a static nested Builder class with chained setters and a build() method) at compile time, which is why Lombok's @Builder is so commonly reached for once a team recognizes they're about to hand-write this pattern for the third time.