The builder pattern is a creational design pattern that constructs a complex object step by step. Instead of one large constructor with many parameters, a builder exposes methods for each part of the product, accumulates configuration, and returns the finished object from a final build() call. The client controls which steps run and in what order, while the builder hides how fields are validated, defaulted, and wired together.
The problem it solves
Objects with many optional fields invite awkward construction. A constructor with ten parameters forces callers to pass null or placeholder values for parts they do not care about. Overloaded constructors multiply quickly, with one for required fields, another that adds a timeout, another that adds headers, and each new option means more overloads or another optional argument at the end of the list. That telescoping constructor style is hard to read and easy to get wrong (arguments swapped by position).
The builder pattern moves construction into a dedicated object. Each configuration step has a named method. Optional parts stay optional because the client simply skips those methods. Validation can run once in build(), so the product is either fully valid or construction fails with a clear error instead of half-initialized in the wild.
Structure
- Product, the complex object under construction (often immutable once built).
- Builder, declares steps for each part of the product and a
build()method that returns the product. - Concrete builder, implements the steps, holds partial state, and assembles the final product.
- Director (optional), encapsulates a fixed build sequence by calling builder methods in a defined order; useful when several products share the same recipe.
flowchart LR Client["Client"] -->|"calls step methods"| Builder["Builder"] Director["Director (optional)"] -->|"fixed recipe"| Builder Builder -->|"build()"| Product["Product"]
In code
An HTTP client builds requests with optional headers, body, and timeout. The builder accumulates settings fluently; build() validates and returns an immutable request.
// Product: immutable once built
class HttpRequest {
constructor({ method, url, headers, body, timeoutMs }) {
this.method = method;
this.url = url;
this.headers = headers;
this.body = body;
this.timeoutMs = timeoutMs;
}
}
// Concrete builder
class HttpRequestBuilder {
constructor() {
this.method = "GET";
this.url = "";
this.headers = {};
this.body = undefined;
this.timeoutMs = 30_000;
}
setMethod(method) {
this.method = method;
return this;
}
setUrl(url) {
this.url = url;
return this;
}
addHeader(name, value) {
this.headers[name] = value;
return this;
}
setBody(body) {
this.body = body;
return this;
}
setTimeoutMs(timeoutMs) {
this.timeoutMs = timeoutMs;
return this;
}
build() {
if (!this.url) {
throw new Error("url is required");
}
return new HttpRequest({
method: this.method,
url: this.url,
headers: { ...this.headers },
body: this.body,
timeoutMs: this.timeoutMs,
});
}
}
const request = new HttpRequestBuilder()
.setMethod("POST")
.setUrl("/api/users")
.addHeader("Content-Type", "application/json")
.setBody(JSON.stringify({ name: "Alice" }))
.build();// Product: immutable once built
interface HttpRequest {
readonly method: string;
readonly url: string;
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
readonly timeoutMs: number;
}
// Concrete builder
class HttpRequestBuilder {
private method = "GET";
private url = "";
private headers: Record<string, string> = {};
private body?: string;
private timeoutMs = 30_000;
setMethod(method: string): this {
this.method = method;
return this;
}
setUrl(url: string): this {
this.url = url;
return this;
}
addHeader(name: string, value: string): this {
this.headers[name] = value;
return this;
}
setBody(body: string): this {
this.body = body;
return this;
}
setTimeoutMs(timeoutMs: number): this {
this.timeoutMs = timeoutMs;
return this;
}
build(): HttpRequest {
if (!this.url) {
throw new Error("url is required");
}
return Object.freeze({
method: this.method,
url: this.url,
headers: { ...this.headers },
body: this.body,
timeoutMs: this.timeoutMs,
});
}
}
const request = new HttpRequestBuilder()
.setMethod("POST")
.setUrl("/api/users")
.addHeader("Content-Type", "application/json")
.setBody(JSON.stringify({ name: "Alice" }))
.build();// Product: immutable once built
public sealed record HttpRequest(
string Method,
string Url,
IReadOnlyDictionary<string, string> Headers,
string? Body,
int TimeoutMs);
// Concrete builder
public sealed class HttpRequestBuilder
{
private string _method = "GET";
private string _url = "";
private readonly Dictionary<string, string> _headers = new();
private string? _body;
private int _timeoutMs = 30_000;
public HttpRequestBuilder SetMethod(string method)
{
_method = method;
return this;
}
public HttpRequestBuilder SetUrl(string url)
{
_url = url;
return this;
}
public HttpRequestBuilder AddHeader(string name, string value)
{
_headers[name] = value;
return this;
}
public HttpRequestBuilder SetBody(string body)
{
_body = body;
return this;
}
public HttpRequestBuilder SetTimeoutMs(int timeoutMs)
{
_timeoutMs = timeoutMs;
return this;
}
public HttpRequest Build()
{
if (string.IsNullOrWhiteSpace(_url))
throw new InvalidOperationException("url is required");
return new HttpRequest(
_method,
_url,
new Dictionary<string, string>(_headers),
_body,
_timeoutMs);
}
}
var request = new HttpRequestBuilder()
.SetMethod("POST")
.SetUrl("/api/users")
.AddHeader("Content-Type", "application/json")
.SetBody("""{"name":"Alice"}""")
.Build();from dataclasses import dataclass, field
# Product: immutable once built
@dataclass(frozen=True)
class HttpRequest:
method: str
url: str
headers: dict[str, str] = field(default_factory=dict)
body: str | None = None
timeout_ms: int = 30_000
# Concrete builder
class HttpRequestBuilder:
def __init__(self) -> None:
self._method = "GET"
self._url = ""
self._headers: dict[str, str] = {}
self._body: str | None = None
self._timeout_ms = 30_000
def set_method(self, method: str) -> "HttpRequestBuilder":
self._method = method
return self
def set_url(self, url: str) -> "HttpRequestBuilder":
self._url = url
return self
def add_header(self, name: str, value: str) -> "HttpRequestBuilder":
self._headers[name] = value
return self
def set_body(self, body: str) -> "HttpRequestBuilder":
self._body = body
return self
def set_timeout_ms(self, timeout_ms: int) -> "HttpRequestBuilder":
self._timeout_ms = timeout_ms
return self
def build(self) -> HttpRequest:
if not self._url:
raise ValueError("url is required")
return HttpRequest(
method=self._method,
url=self._url,
headers=dict(self._headers),
body=self._body,
timeout_ms=self._timeout_ms,
)
request = (
HttpRequestBuilder()
.set_method("POST")
.set_url("/api/users")
.add_header("Content-Type", "application/json")
.set_body('{"name": "Alice"}')
.build()
)Fluent builders and the director
Most application builders use a fluent style: each setter returns this (or self) so calls chain on one line. That readability is why builders show up in APIs like UriBuilder, ORM query builders, and test fixtures.
A director is optional. It knows a fixed recipe, for example, “build a JSON POST with standard auth headers”, and drives any builder that implements the required steps. That separates what sequence to run from how each step is applied, which helps when several products share the same assembly order but differ in concrete types.
class ApiRequestDirector {
buildAuthenticatedPost(
builder: HttpRequestBuilder,
path: string,
token: string,
body: string,
): HttpRequest {
return builder
.setMethod("POST")
.setUrl(path)
.addHeader("Authorization", `Bearer ${token}`)
.addHeader("Content-Type", "application/json")
.setBody(body)
.build();
}
}
Relationship to SOLID
The builder pattern supports the SOLID principles in a few ways. Single responsibility means construction logic lives in the builder, not in the product or every call site. Open/closed means new optional parts arrive as new builder methods without changing existing callers. Dependency inversion means a director or factory can depend on a builder interface while concrete builders vary by platform or format.
The product itself should stay focused on behavior after construction, not on how it was assembled.
Builder vs alternatives
- Factory Method and Abstract Factory, return a ready-made product in one step, often choosing among families of related types. Use a factory when the client should not configure parts; use a builder when the same product type needs many optional combinations assembled in order.
- Telescoping constructors, many overloaded constructors or long parameter lists. Works for two or three variants; collapses once optional fields multiply.
- Object initializer / named arguments, languages with struct literals, keyword args, or
withexpressions can reduce builder boilerplate for simple cases. Builders still help when validation, defaults, or multi-step assembly are non-trivial. - Prototype, clones an existing object as a starting point; builder constructs from scratch (or from an empty template) through explicit steps.
- Static factory methods,
HttpRequest.post(url)style helpers cover common presets; a builder handles the long tail of custom combinations. The two compose well: factories for defaults, builders for full control.
Trade-offs
- More types and methods than a single constructor; for objects with two or three required fields, a builder is usually unnecessary.
- A mutable builder holding partial state is not thread-safe unless documented or isolated per build; freeze or copy at
build()time when the product must be immutable. - Without discipline, builders become dumping grounds for every field on the product; keep the product’s invariants in
build()and resist exposing setters on the product itself. - Best when construction has many optional parts, validation belongs at assembly time, or you want readable, self-documenting configuration at the call site.
