Intent
Builder separates the construction of a complex object from its representation, so that the same construction process can produce different representations — and, in its most common modern form, so you can assemble an object with many optional parts safely and readably.
That second clause is what you actually use it for most of the time. The Gang of Four definition emphasizes “same process, different representations” (the Director angle). In day-to-day code and in LLD interviews, Builder is reached for to solve a much more mundane pain: constructors that have too many parameters, many of them optional.
The Problem: the Telescoping Constructor
Imagine an HttpRequest. A request has a method and a URL that are required, plus a long tail of optional things: headers, query params, a body, a timeout, retry policy, whether to follow redirects, TLS verification, an auth token. Most callers set two or three of these. A few set ten.
How do you express that with constructors?
Option A — telescoping constructors. You write one constructor per combination of parameters you anticipate:
HttpRequest(method, url)
HttpRequest(method, url, headers)
HttpRequest(method, url, headers, body)
HttpRequest(method, url, headers, body, timeout)
HttpRequest(method, url, headers, body, timeout, retries, followRedirects, verifyTls, authToken)
This is the telescoping constructor anti-pattern, and it fails on every axis:
- Unreadable call sites.
new HttpRequest("POST", url, headers, body, 30, 3, true, false, token)— quick, whichbooleanisfollowRedirectsand which isverifyTls? You cannot tell without opening the constructor. - Combinatorial explosion of intent. With
nindependently-settable optional params there are2^ndistinct combinations a caller might want. You obviously can’t anticipate them all, so you guess at the “common” ones and callers contort to fit. (Note: you can’t even write all2^nas overloads — overloads disambiguate by arity/type, so when optionals share types you realistically get onlyn+1arity-distinct constructors. The point is the blowup of meaningful configurations, not the overload count.) - Wrong-order bugs are silent. Two adjacent
booleans or twoints (timeout vs retries) can be swapped and still compile. The bug surfaces in production. - You pass junk for what you don’t care about.
new HttpRequest("GET", url, null, null, 0, 0, true, true, null)— thenulls and0s are noise that hide the one thing that mattered.
Option B — a no-arg constructor plus setters (the JavaBean pattern).
const r = new HttpRequest();
r.setMethod("POST");
r.setUrl(url);
r.setBody(body);
This reads better, but it trades one disease for another:
- The object is mutable and temporarily invalid. Between
newand the last setter, the request exists in a half-built, inconsistent state. If another thread (or a stray code path) reads it mid-build, it gets garbage. - No immutability. You can never make the fields
final/readonly, so you lose all the guarantees immutability buys: safe sharing across threads, safe use as a map key, “if it constructed, it’s valid forever.” - No “construction complete” signal. Nothing forces required fields to be set. You can forget
setUrland won’t find out until something dereferences it.
The Builder pattern gives you the readability of named setters and the safety of an immutable, fully-validated, one-shot object — by moving the mutable scaffolding into a separate builder object and only producing the real object at the end.
Builder is fundamentally about deferring the moment of construction. You collect parameters into a mutable scratchpad (the builder), validate the whole set once, and emit a single immutable product via build(). The product itself never has setters.
Structure
┌─────────────────────┐ ┌──────────────────────────────┐
│ «Director» │ │ «Builder» │
│ RequestDirector │────────▶│ (interface, optional) │
├─────────────────────┤ uses ├──────────────────────────────┤
│ - builder: Builder │ │ + method(m): Builder │
├─────────────────────┤ │ + header(k,v): Builder │
│ + jsonPost(url,body)│ │ + body(b): Builder │
│ + construct() │ │ + build(): Product │
└─────────────────────┘ └───────────────▲──────────────┘
│ implements
┌────────────────┴──────────────┐
│ «ConcreteBuilder» │
│ HttpRequestBuilder │
├───────────────────────────────┤
│ - method, url, headers, ... │ (mutable scratch state)
├───────────────────────────────┤
│ + method(m): this │
│ + header(k,v): this │
│ + build(): HttpRequest ──────┼──┐ creates
└───────────────────────────────┘ │
▼
┌──────────────────────────────┐
│ «Product» │
│ HttpRequest │
├──────────────────────────────┤
│ + readonly method │
│ + readonly url │
│ + readonly headers │ (immutable, validated;
└──────────────────────────────┘ see per-language notes)
The Director is optional — and in most real code it’s absent. The thing every Builder lesson hand-waves: you only need a Director when you have a fixed construction recipe you want to reuse. We’ll return to this.
Participants
| Participant | Role |
|---|---|
| Product | The complex object being built (HttpRequest). Ideally immutable; has no public setters. |
| Builder | Declares the step-wise build interface (method(), header(), … build()). Often just the concrete class; the abstract interface exists only when you have multiple representations. |
| ConcreteBuilder | Holds the mutable scratch state, implements each step, validates, and assembles the Product in build(). |
| Director (optional) | Encapsulates a reusable construction sequence — calls builder steps in a fixed order to produce a known configuration. Knows the recipe, not the parts. |
| Client | Picks a builder, (optionally) hands it to a Director, drives the steps, and retrieves the Product. |
A Real Example: an HttpRequest Builder
This is the canonical “good” use of Builder — far more representative than the textbook Pizza. Note the design choices that matter in an interview:
- The product is immutable: fields are
readonly/frozen/const, and collections are defensively copied so the builder can’t reach in and mutate them after the fact. - The builder’s setter methods return the builder to enable a fluent chain.
build()validates the whole object once and is the intended way to make anHttpRequest.- Construction is sealed as tightly as each language allows — and that ceiling differs by language, which is itself an interview-worthy nuance (covered below).
How tightly can you actually seal construction? (be honest in an interview)
This is where weaker candidates over-claim. The three languages do not give you the same guarantees:
| Concern | TypeScript | Python | C++ |
|---|---|---|---|
| Force construction through builder | Module boundary + non-exported product / #private ctor token; not perfectly sealable | Convention (_) only; no hard private | friend + private ctor — genuinely sealed |
| Header immutability at runtime | Object.freeze (must do it explicitly; ReadonlyMap is compile-time only) | MappingProxyType (runtime) | const member (runtime) |
Builder reusable after build()? | Yes (copies) | Yes (copies) | No — single-use (moves out); see note |
A frequent and wrong claim: “readonly/ReadonlyMap in TypeScript makes the object immutable at runtime.” It does not — those are erased at compile time. (req.headers as Map<...>).set("evil","1") mutates a ReadonlyMap at runtime. To get runtime immutability in TS you must Object.freeze a plain object (or use a genuinely immutable structure). Python’s MappingProxyType and C++‘s const member enforce at runtime; TS does not unless you act.
type Method = "GET" | "POST" | "PUT" | "DELETE";
// A module-private token. Because it is NOT exported, no code outside this
// module can produce one, so no outside code can call the product ctor.
// This is as close as TS gets to a sealed constructor; we state honestly that
// TS cannot enforce it across the whole program the way C++ 'friend' can.
const BUILD_TOKEN = Symbol("HttpRequest.build");
export class HttpRequest {
readonly method: Method;
readonly url: string;
// A frozen plain object: runtime-immutable, unlike ReadonlyMap.
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
readonly timeoutMs: number;
readonly retries: number;
// Constructor demands the module-private token. External callers cannot
// forge it, so 'new HttpRequest(...)' from outside this module fails.
constructor(token: typeof BUILD_TOKEN, b: HttpRequestBuilder) {
if (token !== BUILD_TOKEN) {
throw new Error("Use HttpRequest.builder(...).build()");
}
this.method = b.snapshotMethod();
this.url = b.snapshotUrl();
// Defensive copy INTO a plain object, then freeze for runtime immutability.
this.headers = Object.freeze({ ...b.snapshotHeaders() });
this.body = b.snapshotBody();
this.timeoutMs = b.snapshotTimeout();
this.retries = b.snapshotRetries();
}
static builder(method: Method, url: string): HttpRequestBuilder {
return new HttpRequestBuilder(BUILD_TOKEN, method, url);
}
}
export class HttpRequestBuilder {
// Genuinely private scratch state (#fields): the underscore convention is
// NOT load-bearing here. Only this class can read/write them.
#method: Method;
#url: string;
#headers: Record<string, string> = {};
#body?: string;
#timeoutMs = 30_000;
#retries = 0;
#token: typeof BUILD_TOKEN;
constructor(token: typeof BUILD_TOKEN, method: Method, url: string) {
this.#token = token;
this.#method = method;
this.#url = url;
}
header(key: string, value: string): this { this.#headers[key] = value; return this; }
body(b: string): this { this.#body = b; return this; }
timeout(ms: number): this { this.#timeoutMs = ms; return this; }
retries(n: number): this { this.#retries = n; return this; }
// Snapshot accessors let the product copy state without exposing setters.
snapshotMethod(): Method { return this.#method; }
snapshotUrl(): string { return this.#url; }
snapshotHeaders(): Record<string, string> { return { ...this.#headers }; }
snapshotBody(): string | undefined { return this.#body; }
snapshotTimeout(): number { return this.#timeoutMs; }
snapshotRetries(): number { return this.#retries; }
build(): HttpRequest {
if (!this.#url.startsWith("http")) throw new Error("invalid url");
if (this.#timeoutMs <= 0) throw new Error("timeout must be > 0");
if (this.#body && this.#method === "GET")
throw new Error("GET cannot have a body");
return new HttpRequest(this.#token, this);
}
}
// Fluent, self-documenting call site. The builder COPIES its state into the
// product, so it remains reusable afterwards (unlike the C++ move-based one).
const req = HttpRequest.builder("POST", "https://api.x.com/v1/users")
.header("Authorization", "Bearer abc")
.header("Content-Type", "application/json")
.body(JSON.stringify({ name: "Ada" }))
.timeout(5_000)
.retries(3)
.build();The fluent style depends on each step returning the builder. In TypeScript return this; in Python return self. In C++ here the setters are rvalue-ref-qualified (&&) and return std::move(*this) so the chain stays an rvalue and the rvalue-qualified build() is callable — return an rvalue reference (HttpRequestBuilder&&), never by value, which would copy the scratch state on every step. State the return type explicitly so chaining type-checks.
The three “equivalent” builders differ in one behavior that bites people: the C++ builder is single-use. Its build() moves url_, headers_, and body_ out of the builder, so a second build() would see an empty url_ and throw “invalid url”. Making build() (and the setters) &&-qualified makes that misuse a compile error on a named builder, which is the idiomatic fix — but note the cost: because the setters are now &&-qualified, the normal fluent chain only works on the temporary returned by builder(). To chain off a named builder you must std::move it first. The TS and Python builders copy their state, so they stay reusable and have no such restriction. If you want a reusable C++ builder, drop the && qualifiers and copy (rather than move) in build().
Validation belongs in build(), not in the steps
A common mistake is validating in each setter. But many constraints are cross-field (e.g. “a GET may not have a body”, “if retries > 0 then retryBackoff must be set”). You cannot check those until all fields are present. Validate the whole object once, in build(), and throw before the immutable product is ever created. This gets you as close as the language allows to the invariant “if an HttpRequest exists, it was validated.”
Be precise about the strength of that invariant. In C++ (sealed ctor via friend) it’s airtight: the only path to an HttpRequest runs build()’s checks. In TypeScript the module-private token makes external construction fail, but you should say plainly that TS can’t prove this program-wide the way C++/Java can. In Python it’s convention plus the build() discipline — there is no hard private. Claiming “guaranteed valid” uniformly across all three is exactly the kind of over-statement an SSE interviewer will poke.
Director vs Builder — the part interviews probe
The Builder knows how to make each part. The Director knows the recipe — the fixed sequence of steps for a particular, named configuration. The Director exists to reuse a construction sequence across call sites without duplicating it.
When would you actually add one? Suppose three services all need “a standard internal JSON POST”: same auth header, same content-type, same 5s timeout, same 3 retries. Instead of repeating that chain everywhere, encapsulate the recipe:
class RequestDirector {
// The Director owns the *recipe*, not the parts.
jsonPost(url: string, payload: object, token: string): HttpRequest {
return HttpRequest.builder("POST", url)
.header("Authorization", `Bearer ${token}`)
.header("Content-Type", "application/json")
.body(JSON.stringify(payload))
.timeout(5_000)
.retries(3)
.build();
}
healthCheck(url: string): HttpRequest {
return HttpRequest.builder("GET", url)
.timeout(1_000)
.retries(0)
.build();
}
}
const director = new RequestDirector();
const create = director.jsonPost(
"https://api.x.com/v1/users",
{ name: "Ada" },
"abc",
);
const ping = director.healthCheck("https://api.x.com/healthz");Notice the Director never touches headers or body directly — it only sequences builder calls. Swap the builder for a LoggingHttpRequestBuilder (same interface) and the same jsonPost recipe now produces logged requests: this is the literal GoF “same process, different representation.”
When to use — and when NOT to
Use Builder when:
- A constructor has many parameters, several optional, and call sites are getting unreadable or error-prone.
- You want the product to be immutable but assembled incrementally.
- Construction requires cross-field validation that can only run once everything is set.
- You have one construction recipe reused in many places (add a Director).
- Different representations share a construction process (the classic GoF case — e.g. a document builder emitting HTML vs PDF).
Do NOT reach for Builder when:
- The object has 2–3 fields, all required. A plain constructor is clearer; a builder is ceremony.
new Point(x, y)does not want a builder. - All you need is named/optional arguments and your language already has them. In Python, keyword arguments with defaults (or a
@dataclass) often replace a builder entirely. In TypeScript, a single options object (new HttpRequest({ method, url, timeoutMs })) is lighter weight and frequently the right answer — reach for a full builder mainly when you also need fluent chaining, staged/required-field enforcement, or multiple representations.
Over-engineering smell: a builder whose build() does nothing but copy each field straight into the product with no validation, no defaults, and no cross-field logic, for an object that has three required fields. You’ve added a whole class and a fluent API to dodge a two-argument constructor. In an interview, say out loud why the builder earns its keep (optionality + immutability + validation); if it doesn’t, prefer a constructor or an options object.
Relationship to SOLID and sibling patterns
SOLID:
- SRP — The builder takes on the responsibility of assembly and validation, so the product class isn’t bloated with construction logic and overloads. Two clear responsibilities, two classes.
- OCP — New optional parameters are added as new builder methods with sensible defaults; existing call sites don’t change. With telescoping constructors, adding a parameter forces a new overload and risks breaking call resolution.
- DIP / LSP — When the abstract
Builderinterface exists, a Director depends on the abstraction, and any conformingConcreteBuilderis substitutable (LSP), letting you vary the representation.
Confused with these siblings:
| Pattern | How it differs from Builder |
|---|---|
| Factory Method / Abstract Factory | A factory returns a product in one call and hides which concrete class you get. Builder constructs step by step and is about how a single complex object is assembled, not which subclass. Use a factory when the variation is “which type”; a builder when the variation is “how many parts / in what configuration.” |
| Fluent interface | A syntactic style (method chaining via returning this). Builder often uses a fluent interface, but a fluent API need not build anything (e.g. a query DSL), and a Builder need not be fluent (it could take a config struct). Don’t conflate the syntax with the pattern. |
| Prototype | Creates new objects by cloning an existing instance. Builder assembles from parts. Sometimes combined: clone a prototype, then a builder tweaks fields. |
| Composite | Composite is about the runtime tree structure of part-whole objects; Builder is about constructing such an object. A builder is a natural way to assemble a Composite, but they answer different questions (structure vs construction). |
In an interview, the move that signals seniority is not naming the pattern — it’s the trade-off framing: “I’d use a builder here because there are 4 optional fields plus a cross-field rule, and I want the result immutable; if it were just optional fields I’d reach for an options object/kwargs first. I’d add a Director only if I find the same construction recipe duplicated across services.” And be honest about the immutability ceiling per language — that nuance is what separates SSE-level answers from rote pattern recall.