Engineering Atlas › Low-Level Design

Builder pattern

13 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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 Client

Each 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

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

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

  1. Some objects have many optional parts and rules that relate them, so they cannot be validated one field at a time.
  2. Validation must happen once, after all parts are known, so construction is split into "collect" and "build".
  3. An object that passed validation must stay valid, so the product is immutable and does not share state with the builder.
  4. Sharing builders between callers causes aliasing, so builders are single-use or persistent.
  5. 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 error

Worked 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

OptionStrengthsWeaknessesChoose when
Plain constructorSimplest; one placeUnreadable past 4 to 5 parameters; no named arguments in some languagesFew fields, no cross-field rules
Keyword args, options object, config structReadable call sites; little codeValidation still needs a home (__post_init__, Validate())Most configuration objects in Python, TS and Go
Go functional optionsDefaults hidden; easy to add options without breaking callersHarder to inspect or serialise the config; one closure per optionPublic Go library APIs with many optional settings
Mutable fluent builderFamiliar; cheapAliasing when reused; easy to build twice from shared stateLocal, single-use construction in Java or C#
Persistent (immutable) builderSafe to branch from a base; easy to reason aboutCopies at each stepQuery builders and templates derived from a shared base

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. A Python codebase has a UserBuilder with eight setters and a build() that copies them into User. 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.

  3. In Go, a := base.Where(x) and b := base.Where(y) sometimes make a contain y. 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, append allocates a new array and the bug disappears.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →