Separate the construction of a complex object from its representation, so the same process can build different results.
01
The problem Builder solves
The problem
You need an HTTP request object. It has a URL and a method — easy. Then headers. Then a timeout. Then retries, a body, query parameters, whether to follow redirects, and an idempotency key.
Now the constructor is new HttpRequest(url, "POST", headers, 30, 3, body, null, true, null) and nobody reading that call site can tell you what 30, 3 and true mean without opening the class. Worse, most callers want two of those nine things, so you write six overloads — the telescoping constructor — and every new option doubles the pile.
The setter alternative is no better: new HttpRequest() followed by eight assignments means the object exists in an invalid, half-built state that some other thread or an early return can observe.
02
How the Builder pattern works
Give construction its own object, and make it read like a sentence:
A builder collects the pieces one call at a time. Each call names its argument, so .timeout(30) replaces a bare 30.
Each step returns the builder, so calls chain. Order stops mattering and you only mention what you care about.
A final build() validates everything and returns the finished object — immutable, and never seen half-assembled.
The GoF book also describes a Director, which drives a builder through a fixed recipe so the same steps can produce different representations. In modern code it's usually skipped — a static factory like HttpRequest.json(url) that returns a preconfigured builder covers the same ground with less machinery.
Participants. The Builder holds mutable, partially-complete state. The Product is created once, complete, at build(). The client never holds a half-configured request, which is the guarantee that setters can't give you.
03
See it: build a request piece by piece
Add and remove options. Watch the expression at the bottom — that's the code you'd write, and it only ever mentions what you actually chose.
▶ Try it — chain the steps
An interactive builder: adding options such as headers, a timeout or retries appends a named step to a fluent call chain, and the resulting expression only mentions the options you chose.
04
Builder pattern code examples
A fluent, validating builder for an immutable request object.
public final class HttpRequest {
private final String url, method;
private final Map<String, String> headers;
private final Duration timeout;
private final int retries;
// Only the builder can call this, so a half-built request cannot exist.
private HttpRequest(Builder b) {
this.url = b.url;
this.method = b.method;
this.headers = Map.copyOf(b.headers); // defensive copy: truly immutable
this.timeout = b.timeout;
this.retries = b.retries;
}
public static Builder post(String url) { return new Builder(url, "POST"); }
public static final class Builder {
private final String url, method;
private final Map<String, String> headers = new LinkedHashMap<>();
private Duration timeout = Duration.ofSeconds(10); // sensible defaults
private int retries = 0;
private Builder(String url, String method) { this.url = url; this.method = method; }
public Builder header(String k, String v) { headers.put(k, v); return this; }
public Builder timeout(Duration d) { this.timeout = d; return this; }
public Builder retries(int n) { this.retries = n; return this; }
public HttpRequest build() {
// One place to enforce cross-field rules the constructor couldn't.
if (retries > 0 && !"GET".equals(method) && !headers.containsKey("Idempotency-Key"))
throw new IllegalStateException("retrying a non-GET needs an idempotency key");
return new HttpRequest(this);
}
}
}
// HttpRequest.post("/v1/orders").header("Content-Type", "application/json")
// .timeout(Duration.ofSeconds(30)).build();
from dataclasses import dataclass, field, replace
from datetime import timedelta
# Python usually doesn't need a builder class: keyword arguments already name
# every parameter at the call site, and defaults kill the telescoping overloads.
#
# HttpRequest(url="/v1/orders", method="POST", timeout=timedelta(seconds=30))
#
# Reach for a builder when steps must ACCUMULATE (repeated .header() calls) or
# when there are cross-field rules to validate once at the end.
@dataclass(frozen=True)
class HttpRequest:
url: str
method: str = "GET"
headers: tuple[tuple[str, str], ...] = ()
timeout: timedelta = timedelta(seconds=10)
retries: int = 0
class HttpRequestBuilder:
def __init__(self, url: str, method: str = "GET") -> None:
self._req = HttpRequest(url=url, method=method)
def header(self, k: str, v: str) -> "HttpRequestBuilder":
self._req = replace(self._req, headers=self._req.headers + ((k, v),))
return self
def timeout(self, seconds: int) -> "HttpRequestBuilder":
self._req = replace(self._req, timeout=timedelta(seconds=seconds))
return self
def build(self) -> HttpRequest:
if self._req.retries and self._req.method != "GET":
if not any(k == "Idempotency-Key" for k, _ in self._req.headers):
raise ValueError("retrying a non-GET needs an idempotency key")
return self._req
class HttpRequest {
public:
class Builder;
// Accessors only — every field is const, set once at construction.
const std::string& url() const { return url_; }
private:
friend class Builder;
HttpRequest(std::string url, std::string method,
std::map<std::string, std::string> headers,
std::chrono::seconds timeout, int retries)
: url_(std::move(url)), method_(std::move(method)),
headers_(std::move(headers)), timeout_(timeout), retries_(retries) {}
const std::string url_, method_;
const std::map<std::string, std::string> headers_;
const std::chrono::seconds timeout_;
const int retries_;
};
class HttpRequest::Builder {
public:
explicit Builder(std::string url, std::string method = "GET")
: url_(std::move(url)), method_(std::move(method)) {}
// Returning a reference (not a copy) keeps the chain allocation-free.
Builder& header(std::string k, std::string v) {
headers_.emplace(std::move(k), std::move(v));
return *this;
}
Builder& timeout(std::chrono::seconds s) { timeout_ = s; return *this; }
Builder& retries(int n) { retries_ = n; return *this; }
HttpRequest build() && { // && : build() consumes the builder, so it can
return HttpRequest(std::move(url_), std::move(method_), // move, not copy
std::move(headers_), timeout_, retries_);
}
private:
std::string url_, method_;
std::map<std::string, std::string> headers_;
std::chrono::seconds timeout_{10};
int retries_ = 0;
};
public sealed class HttpRequest
{
public string Url { get; }
public string Method { get; }
public IReadOnlyDictionary<string, string> Headers { get; }
public TimeSpan Timeout { get; }
public int Retries { get; }
private HttpRequest(Builder b)
{
Url = b.UrlValue; Method = b.MethodValue;
Headers = new Dictionary<string, string>(b.HeaderValues);
Timeout = b.TimeoutValue; Retries = b.RetryValue;
}
public static Builder Post(string url) => new(url, "POST");
public sealed class Builder
{
internal string UrlValue { get; }
internal string MethodValue { get; }
internal Dictionary<string, string> HeaderValues { get; } = new();
internal TimeSpan TimeoutValue { get; private set; } = TimeSpan.FromSeconds(10);
internal int RetryValue { get; private set; }
internal Builder(string url, string method) => (UrlValue, MethodValue) = (url, method);
public Builder Header(string k, string v) { HeaderValues[k] = v; return this; }
public Builder Timeout(TimeSpan t) { TimeoutValue = t; return this; }
public Builder Retries(int n) { RetryValue = n; return this; }
public HttpRequest Build() => new(this);
}
}
// C# often gets there with less: a record with `init` properties plus
// `with` expressions gives named, optional, immutable construction for free.
// Prefer the builder when you need accumulation or end-of-build validation.
class HttpRequestBuilder {
#req = { method: 'GET', headers: {}, timeoutMs: 10_000, retries: 0 }
constructor(url, method = 'GET') {
this.#req.url = url
this.#req.method = method
}
header(k, v) { this.#req.headers[k] = v; return this }
timeout(ms) { this.#req.timeoutMs = ms; return this }
retries(n) { this.#req.retries = n; return this }
build() {
const r = this.#req
if (r.retries > 0 && r.method !== 'GET' && !r.headers['Idempotency-Key']) {
throw new Error('retrying a non-GET needs an idempotency key')
}
// Freeze so the finished request can't be edited after validation.
return Object.freeze({ ...r, headers: Object.freeze({ ...r.headers }) })
}
}
export const post = url => new HttpRequestBuilder(url, 'POST')
// The plain-object alternative is extremely common in JS and perfectly good:
// request({ url, method: 'POST', timeoutMs: 30_000 })
// The builder earns its place when steps accumulate or validation must run once.
package httpx
import "time"
type Request struct {
url, method string
headers map[string]string
timeout time.Duration
retries int
}
// Go's idiomatic answer is functional options rather than a builder type: same
// named, optional, order-free construction, but the product is built in one
// call so there is no half-built builder to pass around by mistake.
type Option func(*Request)
func Header(k, v string) Option {
return func(r *Request) { r.headers[k] = v }
}
func Timeout(d time.Duration) Option { return func(r *Request) { r.timeout = d } }
func Retries(n int) Option { return func(r *Request) { r.retries = n } }
func NewRequest(url, method string, opts ...Option) (*Request, error) {
r := &Request{url: url, method: method, headers: map[string]string{}, timeout: 10 * time.Second}
for _, opt := range opts {
opt(r)
}
if r.retries > 0 && r.method != "GET" && r.headers["Idempotency-Key"] == "" {
return nil, errors.New("retrying a non-GET needs an idempotency key")
}
return r, nil
}
// req, err := httpx.NewRequest("/v1/orders", "POST",
// httpx.Header("Content-Type", "application/json"),
// httpx.Timeout(30*time.Second))
interface HttpRequest {
readonly url: string
readonly method: string
readonly headers: Readonly<Record<string, string>>
readonly timeoutMs: number
readonly retries: number
}
class HttpRequestBuilder {
private req: Omit<HttpRequest, 'headers'> & { headers: Record<string, string> }
constructor(url: string, method = 'GET') {
this.req = { url, method, headers: {}, timeoutMs: 10_000, retries: 0 }
}
// Returning `this` types the chain, so autocomplete works at every step.
header(k: string, v: string): this { this.req.headers[k] = v; return this }
timeout(ms: number): this { this.req.timeoutMs = ms; return this }
retries(n: number): this { this.req.retries = n; return this }
build(): HttpRequest {
if (this.req.retries > 0 && this.req.method !== 'GET' && !this.req.headers['Idempotency-Key']) {
throw new Error('retrying a non-GET needs an idempotency key')
}
return Object.freeze({ ...this.req, headers: Object.freeze({ ...this.req.headers }) })
}
}
// TypeScript can go further than most languages here: a "staged builder" that
// returns a different interface from each step can make build() unavailable
// until the required fields are set — turning a runtime throw into a type error.
Read across the tabs: Java, C++ and C# need the builder class because their constructors can't take named optional arguments. Python has keyword arguments, C# has init + with, and Go has functional options — so in those languages a builder only pays for itself when steps accumulate (many .header() calls) or when validation must happen once at the end. TypeScript is the interesting one: it can encode "you may not call build() yet" in the type system.
05
How to implement Builder
Make the product's fields final/readonly and its constructor private to the builder — that is what makes the finished object trustworthy.
Give the builder one mutable field per product field, with sensible defaults so callers only mention what differs.
Have each step return the builder itself so calls chain. Name steps after the concept (.timeout(30)), never after the type.
Put cross-field validation in build(), where every value is finally known. This is the thing a pile of setters cannot do.
Return a defensive copy of any collection you were handed, or the caller keeps a reference into your "immutable" object.
Offer static entry points for common shapes — HttpRequest.post(url) — instead of a Director class.
06
When to use Builder — and when not to
Use it when a constructor has grown past roughly four parameters, when several of them are optional, when adjacent parameters share a type (so (30, 3) can be silently swapped), or when the object should be immutable but is fiddly to assemble.
Where it goes wrong
Builders for three-field objects. You've doubled the class count to avoid a constructor that was already readable. Four parameters is a reasonable threshold; two is not.
A builder that never validates. If build() just copies fields across, it is a more verbose set of setters. The end-of-build check is most of the value.
Reusing a builder after build(). Unless you deliberately snapshot, a second build() can hand out an object that shares mutable state with the first. Either copy defensively or make build() consume the builder, as the C++ tab does with &&.
Writing one in a language that doesn't need it. In Python or Go, keyword arguments and functional options already give you named optional construction.
07
Quick check
🧠 Quick check
What can a Builder do that a no-arg constructor plus a set of setters cannot?
Setters can do optional parameters and can chain by returning this. What they can't do is keep the product immutable: with setters, the object exists in a half-built state that another thread — or an early return — can observe, and there's no single moment where all fields are known, so cross-field rules like "retries require an idempotency key" have nowhere honest to live.
In the wild
Javajava.net.http.HttpRequest.newBuilder() in the JDK, plus StringBuilder and OkHttp's Request.Builder.
C#WebApplication.CreateBuilder(args) — the whole ASP.NET Core startup is a builder, and StringBuilder is the oldest example in the framework.
PythonQuery builders such as SQLAlchemy's select(...).where(...).order_by(...), where each step returns a new statement object.
Gostrings.Builder in the standard library, and the functional-options style used across grpc-go and the AWS SDK.
TypeScriptPrisma and Knex query builders — chained, typed steps producing one executable query.
C++std::ostringstream accumulating output, and the fluent builders in Protocol Buffers' generated C++ API.
When a constructor has grown past about four parameters, when several are optional, when neighbouring parameters share a type and could be swapped without a compile error, or when you want the object to be immutable but it takes several steps to assemble. Below that bar, a plain constructor is clearer and a builder is just more code.
What is the difference between Builder and Factory?
A factory answers "which class should I create?" and returns the object immediately. A builder answers "how do I assemble this one complicated object?" and returns nothing until you call build(). They compose happily: a factory can return a preconfigured builder.
Do I need a Director class?
Almost never in modern code. The Director exists in the GoF version to drive a builder through a fixed sequence so the same steps can produce different representations. In practice a static factory that returns a preconfigured builder — HttpRequest.json(url) — is easier to read and to discover.
Is the Builder pattern thread-safe?
The builder itself is mutable and generally is not — share one across threads and you'll get interleaved steps. The product is the thread-safe part, provided you made it genuinely immutable, including defensive copies of any collections. The normal usage is one builder per thread, per call.
Finish the handbook, earn the certificate
All 23 Gang-of-Four patterns, each with a simulator you can click and code in seven languages. Free, no signup.