Builder Pattern
Concept
Builder separates the construction of a complex object from its representation, letting the same step-by-step construction process produce different final representations. In practice, most modern code reaches for a narrower, more idiomatic slice of the original GoF pattern: a fluent builder object exposes chained setter-style methods (.setName("x").setTimeout(30).setRetries(3)) that accumulate configuration, then a final .build() method validates the accumulated state and constructs the actual immutable target object in one step. This solves a specific problem that plain constructors handle badly: an object with many optional parameters (some mutually dependent, some with sensible defaults) forces either a "telescoping constructor" — overloaded constructors for every combination of optional args — or a single constructor with a dozen positional parameters where call sites become unreadable and error-prone (easy to swap two same-typed arguments by position).
The GoF book's original formulation is broader — a Director orchestrates a sequence of build steps against a Builder interface, and swapping which concrete Builder the director uses produces structurally different objects from the identical build sequence (e.g. an HTMLBuilder and a PDFBuilder fed the same "build a report" step sequence produce a web page or a PDF respectively). This Director-driven form shows up less often in modern application code than the fluent single-object builder, but is the shape to reach for when the actual sequence of construction steps — not just the final field values — needs to vary independently of what's being built.
Tradeoffs
| Approach | Benefit | Cost |
|---|---|---|
Builder (fluent, incrementally configured, validated at .build()) |
Readable call sites (Config.builder().timeout(30).retries(3).build()), optional parameters with defaults handled naturally, validation centralized in one place |
An extra class to write and maintain per builder; the object being built usually needs to be mutable/incomplete during accumulation unless the builder uses a separate mutable intermediate representation |
| Telescoping constructors (a constructor overload per parameter combination) | No extra class; works in languages without named/keyword arguments | Combinatorial explosion of overloads as optional parameters grow; call sites with several same-typed positional args are a common source of swapped-argument bugs |
| Named/keyword arguments with defaults (Python, Kotlin, TypeScript object literals) | No builder class needed at all — the language feature does the same job directly | Doesn't help when construction needs multi-step validation across several parameters together, or when the same configuration needs to be built up incrementally across multiple call sites before use |
Plain mutable object + public setters, used directly (no .build() step) |
Simplest option, no extra abstraction | Object can be used in a partially-configured, invalid state at any point — no single moment where "construction is complete and validated" is enforced |
Languages with real named/keyword arguments (Python, Kotlin) get much of Builder's readability benefit for free and don't need the pattern for simple cases; Builder earns its keep specifically when there's cross-field validation to centralize, or when the target object should end up immutable but needs incremental assembly first.
When to use / when not to
- Use for objects with many optional/defaultable fields, especially where some combinations are invalid together and should be caught at construction (e.g.
retries > 0requiresbackoffStrategyto be set) — HTTP client configuration, test-fixture/object-mother construction in test suites, request objects for third-party SDKs. - Especially valuable when the target object should be immutable once built — the builder holds the mutable, in-progress state, and
.build()is the one moment a fully-validated immutable instance is produced. - Don't reach for it in languages with native named/keyword arguments and no cross-field validation need — a constructor or factory function with defaults does the same job with less code.
- Avoid it for objects with 2-3 straightforward required fields — a builder for
Point(x, y)is pure ceremony with no problem to solve.
Common pitfall
Letting the builder's .build() method skip real validation and just wire the accumulated fields into the target object's constructor, which turns the builder into ceremony without its actual payoff — the whole point of centralizing validation in .build() is catching invalid combinations (a negative timeout, a retry count set without a backoff strategy) at the one place construction completes, rather than deferring that discovery to whenever the invalid object is first used deep in unrelated code. A builder that doesn't validate is strictly worse than a plain constructor: it adds indirection without adding the safety that justified the indirection in the first place.
Engineering Lens
Builder is worth flagging in review less for its own mechanics and more for what its absence signals: a constructor or factory function that's grown past 5-6 optional parameters, especially several of the same type, is a concrete, low-risk refactor target — introducing a builder there doesn't change behavior, only call-site readability and the ability to add validation later without another signature change. The pattern's second-order value is in test code specifically: a builder (often called an "object mother" or "test data builder" in that context) with sensible defaults for every field lets each test override only the 1-2 fields it actually cares about, which keeps test fixtures readable as a schema grows over years rather than every test needing updates whenever a new required field is added.
Related
- Event Sourcing and CQRS — unrelated mechanically, but both separate "how something is constructed/written" from "what the final read-shape looks like"