Builder pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- A builder assembles a complex object step by step and hands it over only when it is complete and valid. The caller describes what it wants;
build()checks the whole description and produces the result. - It earns its keep when there are many optional parts, rules that span several fields ("retries need an idempotency key"), or several outputs from one description (a query builder producing SQL text plus bound parameters).
- In languages with keyword arguments and object literals, most builders are ceremony. Python's dataclasses with defaults, TypeScript options objects and Go config structs or functional options cover the common case with less code.
- A builder should be single-use or immutable. A mutable builder shared between two callers is an aliasing bug waiting to happen; in Go the same bug hides in slices that share a backing array.
- Agents write Java-style builders everywhere, including in Python and TypeScript. Review asks: what does
build()validate that a constructor could not?
How it works
The problem it solves
An HTTP client has a base URL, a per-attempt timeout, an overall deadline, a retry count, a backoff policy, default headers, a proxy, TLS settings and a user agent. A constructor with nine positional parameters is unreadable at the call site (Client("https://api", 2, 10, 3, None, {}, None, None, "svc/1.4")), and the usual workaround, a ladder of overloaded constructors with three, five and seven parameters, multiplies code without making any of it safer.
The deeper problem is that some fields constrain each other. Retries on a non-idempotent request need an idempotency key. The overall deadline must be at least the per-attempt timeout. A proxy and a Unix socket path cannot both be set. Setters cannot enforce these rules because each setter sees one field at a time; the rule can only be checked once the whole description is known.
The shape
A builder collects choices and validates them once:
client = (ClientBuilder("https://api.example.com")
.timeout(seconds=2)
.deadline(seconds=10)
.retries(3, backoff="exponential")
.header("User-Agent", "billing/1.4")
.build()) # validates, then returns an immutable ClientEach call returns a builder, which gives the readable chain. build() is the single place where cross-field rules live, and it either returns a fully valid object or fails with a message that names the rule. The product is immutable, so nothing can make it invalid afterwards. The Gang of Four version adds a director that runs a fixed sequence of builder steps, so the same steps can drive different builders (one builds a document tree, another renders plain text). In practice the director is usually just a function.
When the language already gives you this
- Python: a frozen dataclass with keyword-only fields and defaults reads as well as a builder at the call site, and
__post_init__is where cross-field validation goes.dataclasses.replace(cfg, timeout=5)gives you "copy with one change". - TypeScript: an options object (
new Client({ baseUrl, timeout: 2 })) with an interface type and defaults applied in the constructor. The compiler checks field names and types; the constructor checks the rules. - Go: a config struct with a
Validate()method, or functional options (NewClient(url, WithTimeout(2*time.Second), WithRetries(3))), the style Rob Pike described and Dave Cheney popularised. Each option is a function that modifies the config; the constructor applies them in order and then validates.
Reach for a real builder class when construction is genuinely incremental (code adds parts in a loop or across functions), when one description produces several representations, or when the language lacks named arguments (Java, C++).
When not to use it
- Three fields, no cross-field rules. A constructor is clearer. A builder adds a second class to keep in sync with the first.
- A builder that only mirrors setters. If
build()does nothing but copy fields, it validates nothing and the pattern is pure cost. - Mutable products. If the result has public setters anyway, the guarantee that
build()established is gone the moment it returns.
Reviewing agent-written code
Asked for a "configurable client", an agent trained on a lot of Java will often produce a builder class with one setter per field, a build() that copies them across, and a product class with the same fields again: three copies of the same list, no validation, in a language with keyword arguments. Ask for a dataclass or options object instead. The opposite mistake also appears: a fluent builder whose methods mutate self and return it, then a "base" builder reused in two places so the second caller sees the first caller's headers. Look for builders stored in module-level variables or fields, and ask either for a fresh builder per use or for methods that return a copy. A useful review question for any builder in a diff: "Which test fails if I delete build() and use the constructor directly?" If no test does, the builder has not earned its place.
The same shape elsewhere
Builders are the multi-step consistency problem shape at object scale: accumulate changes, validate all of them together, then commit atomically or not at all. A database transaction does this for rows, terraform plan then apply does it for infrastructure, and a Kubernetes admission controller validates a whole object before it is persisted. In each case the point is that intermediate states are never visible to anyone else.
Down the stack
Layer 1The contract
build() returns an object that satisfies every invariant, or returns or raises an error that names the first violated rule; it never returns a partially valid product. The product is immutable or at least does not share mutable state with the builder, so later builder calls cannot change an object already built. A builder is either single-use or persistent (each step returns a new builder), and that choice is part of its documented contract. Builders are not safe for concurrent use; they are local, short-lived objects.
Layer 2The mechanism
Protocol Buffers' generated Java code is the canonical example: Message.newBuilder() returns a mutable builder, setters return the builder, build() checks required fields (in proto2) and throws if any are missing, and buildPartial() skips the check for callers who know better. toBuilder() copies an immutable message back into a builder for "copy with changes". Go's strings.Builder is a builder for bytes: it appends into a []byte and String() returns a string that shares that memory without copying, which is only safe because the builder refuses to be copied (it stores a pointer to itself and panics in copyCheck if a copy is used). Java's StringBuilder grows its array by roughly doubling, so appends are amortised constant time.
Functional options in Go are a slice of func(*config) values. The constructor starts from defaults, loops over the options, and validates once at the end. Persistent builders, where each step returns a new value, are what the Practice implements. In Go they hit a classic trap: a builder struct copied by value still shares the backing array of any slice field. If base has three conditions in a slice with capacity four, base.Where(x) and base.Where(y) both append into slot four of the same array, and the first result silently changes to contain y. The fix is to copy the slice before appending.
Layer 3The machine
A builder costs one extra allocation for the builder itself plus whatever intermediate storage it keeps. On the JVM and in Go, escape analysis often removes that: a builder that does not escape the function is allocated on the stack or scalar-replaced, so the only heap allocation is the product. A persistent builder copies its slices or lists at each step, so a chain of k steps over n parts is O(k x n) copying; for a query with ten conditions that is a few hundred bytes and sub-microsecond. Functional options that capture arguments allocate a closure each, around 16 to 32 bytes, plus the variadic slice; at construction time that is noise, inside a hot loop it is not. In V8, a builder that adds properties to the product in different orders on different paths creates objects with different hidden classes, so code that reads them becomes polymorphic; assigning every field in the constructor, in one order, keeps one shape and monomorphic property access. CPython keyword arguments build a dict per call, roughly 100 ns, which is again irrelevant for objects built once at startup.
Why it's built this way
- Some objects have many optional parts and rules that relate them, so they cannot be validated one field at a time.
- Validation must happen once, after all parts are known, so construction is split into "collect" and "build".
- An object that passed validation must stay valid, so the product is immutable and does not share state with the builder.
- Sharing builders between callers causes aliasing, so builders are single-use or persistent.
- Languages with named arguments and literals already cover simple cases, so the pattern is worth its extra class only where the rules or the incremental assembly are real.
Diagram
sequenceDiagram
participant C as Caller
participant B as QueryBuilder
participant Q as Query
C->>B: new("orders")
C->>B: select("id", "total")
C->>B: where("status", "=", "paid")
C->>B: orderBy("total", desc)
C->>B: limit(20)
C->>B: build()
B->>B: validate identifiers, operators, limit
B-->>Q: create immutable SQL + params
B-->>C: Query or first errorWorked example
A data team exposes a reporting API that turns filter requests into SQL. The first version concatenated strings: "WHERE status = '" + status + "'". A penetration test found injection in two endpoints within an hour. The second version used a query builder with three rules in build(): identifiers must match [A-Za-z_][A-Za-z0-9_]*, operators must come from a fixed list of six, and every value becomes a ? placeholder in a separate parameter list that the database driver binds. Values never touch the SQL text, so injection through values is impossible by construction.
The builder is persistent. The API builds base = Query("orders").where("tenant_id", "=", t) once per request and derives three queries from it (the page, the total count and a CSV export). In the first draft the builder mutated itself, and the CSV export inherited the page's LIMIT 50, so exports silently returned 50 rows. Making each step return a new builder fixed it. Cost check: a typical query has 4 conditions and 1 ordering. Building it copies about 10 small list entries across 6 steps, under 2 microseconds in Python against a 5 to 50 millisecond database round trip.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Plain constructor | Simplest; one place | Unreadable past 4 to 5 parameters; no named arguments in some languages | Few fields, no cross-field rules |
| Keyword args, options object, config struct | Readable call sites; little code | Validation still needs a home (__post_init__, Validate()) | Most configuration objects in Python, TS and Go |
| Go functional options | Defaults hidden; easy to add options without breaking callers | Harder to inspect or serialise the config; one closure per option | Public Go library APIs with many optional settings |
| Mutable fluent builder | Familiar; cheap | Aliasing when reused; easy to build twice from shared state | Local, single-use construction in Java or C# |
| Persistent (immutable) builder | Safe to branch from a base; easy to reason about | Copies at each step | Query builders and templates derived from a shared base |
Pitfalls in production
- Validation spread across setters, so cross-field rules are never checked. Fix: setters store,
build()validates everything, with tests per rule. - A shared mutable builder (in a field, a module variable or a test fixture) leaking state between uses. Fix: create one per use, or make each step return a copy.
- Go slice aliasing in value-type builders. Fix: copy slices before appending (
append(slices.Clip(s), x)or an explicit copy), and test branching from a base with spare capacity. - Products with setters, so callers bypass the checks after
build(). Fix: immutable product; offertoBuilder()orreplace()for "copy with changes". - Builders that build strings for another language (SQL, shell, HTML) by concatenating values. Fix: keep values out of the text; use placeholders and let the driver bind them.
- Builder classes generated for every data type "for consistency". Fix: delete the ones that validate nothing.
Leadership lens
- Ask what invariants
build()enforces. If the answer is none, prefer the language's native construction and save the extra class. - For public APIs, choose the construction style for evolvability: options objects and functional options let you add settings without breaking callers; positional constructors do not.
- Query and command builders are a security boundary. Require that values are always parameters and that identifiers are checked against an allowlist, and make that a review rule for human and agent code alike.
- Watch for the same configuration defined three times (builder, product, docs). Generate or derive what you can, or the copies drift.
Check yourself
Why can't setters alone enforce "deadline must be at least the per-attempt timeout"?
Show answer
Each setter sees one field, and callers may set them in either order; the rule can only be checked once both are known, which is what
build()is for.A Python codebase has a
UserBuilderwith eight setters and abuild()that copies them intoUser. What would you suggest?Show answer
Replace it with a frozen dataclass with keyword fields and defaults, and move any real validation into
__post_init__; the builder adds code without adding guarantees.In Go,
a := base.Where(x)andb := base.Where(y)sometimes makeacontainy. Why only sometimes?Show answer
Both appends share the backing array when it has spare capacity, so the second overwrites the first; when it is full,
appendallocates a new array and the bug disappears.Why does a query builder keep values out of the SQL text?
Show answer
Bound parameters are sent separately and never parsed as SQL, so a value cannot change the query's structure, which rules out injection through values.
Watch
- Functional options for friendly APIs (Dave Cheney, dotGo 2014): the Go answer to builders, and why it evolves better than config structs for public APIs.
- 10 Design Patterns Explained in 10 Minutes (Fireship): a fast tour that places builder next to singleton and factory in a JavaScript setting.
Free resources
- The Builder Pattern (Brandon Rhodes, python-patterns.guide): when Python needs a builder and when keyword arguments are enough.
- Self-referential functions and the design of options (Rob Pike): the original functional options post.
- Functional options for friendly APIs (Dave Cheney): the written version of the talk, comparing every alternative.
- Fluent Interface (Martin Fowler): where method chaining helps readability and what it costs.
- Java generated code: builders (Protocol Buffers docs): a production builder with
build(),buildPartial()andtoBuilder(). - strings.Builder source (Go): a small builder that avoids a final copy and panics if copied.