Skip to content

Builder

Constructing objects with many parts without telescoping constructors, half-built states, or a boolean nobody can read at the call site.

Beginner17 minUpdated 8 August 2026

Builder is the creational pattern with the widest gap between "when people use it" and "when it earns its keep". It is frequently applied to objects with three fields, and frequently missing from the object with fourteen that genuinely needed it.

The problem is not "many parameters". It is a constructor call nobody can read, and an object that can exist in an invalid state while you are still filling it in.

// What does this mean?
const conn = new Connection('db.internal', 5432, true, false, 30000, true, null, 3);

Every argument after the port is a guess for the reader. Swap two booleans and it still compiles, still runs, and quietly disables TLS.

Two problems, two different fixes

Conceptual diagramtoo many unnamed argumentsparts arrive over time, or need validation togethernoyesWhat is wrong withthe constructor?Named argumentsor an options objectBuilderIs the object immutableand are combinations invalid?Stop here — options object is enoughStep builder if orderor required fields matter
Diagram, as text
  • What is wrong with the constructor? leads to Named arguments or an options object (too many unnamed arguments)
  • What is wrong with the constructor? leads to Builder (parts arrive over time, or need validation together)
  • Named arguments or an options object leads to Is the object immutable and are combinations invalid?
  • Is the object immutable and are combinations invalid? leads to Stop here — options object is enough (no)
  • Is the object immutable and are combinations invalid? leads to Builder (yes)
  • Builder leads to Step builder if order or required fields matter

If the only problem is readability, you do not need Builder. An options object solves it in one line, and most modern languages have it built in.

const conn = new Connection({
  host: 'db.internal',
  port: 5432,
  tls: true,
  poolSize: 3,
  timeoutMs: 30_000,
});

Every argument now names itself, order stops mattering, and defaults live in one place. Reaching for a Builder here adds a class to solve a problem the language already solved.

Builder earns its place when construction is a process, not a single call: parts arrive from different places, the whole must be validated together, and the finished object should be immutable.

What Builder actually provides

class QueryBuilder {
  #table = null;
  #wheres = [];
  #limit = null;

  from(table) { this.#table = table; return this; }
  where(clause, value) { this.#wheres.push([clause, value]); return this; }
  limit(n) { this.#limit = n; return this; }

  build() {
    if (!this.#table) throw new Error('query has no table');
    if (this.#limit !== null && this.#limit <= 0) throw new Error('limit must be positive');
    return new Query(this.#table, [...this.#wheres], this.#limit);   // frozen, valid
  }
}

const query = new QueryBuilder().from('orders').where('status = ?', 'paid').limit(50).build();

Three properties, and only the third is unique to Builder:

  • Each part is named at the point it is supplied. An options object gives you this too.
  • Parts can be added conditionally and from different places. if (userWantsRecent) q.where('created_at > ?', cutoff) — awkward with a single literal, natural here.
  • Validation happens once, over the whole, at build(). The result is either a complete valid object or an exception. There is no partially-constructed Query for anyone else to observe.

That last point is the argument. The mutable, invalid, half-filled state exists only inside the builder, and the builder is discarded. The object the rest of the system sees was never invalid.

When required fields must be enforced

A plain builder can only complain at build(). If a missing table should be impossible rather than merely detected, the step builder encodes the requirement in the types: from() returns a different type that has where() and build(), and the starting type has only from().

interface QueryStart { from(table: string): QueryOptions; }
interface QueryOptions {
  where(clause: string, value: unknown): QueryOptions;
  limit(n: number): QueryOptions;
  build(): Query;
}

Now builder.build() without a table does not throw — it does not compile. This is worth doing for widely used library APIs and rarely worth doing inside one application, where the runtime check is enough and the type gymnastics cost more than they save.

Builder in test code

The most valuable use of Builder in most codebases is not production construction at all. It is the test data builder:

const order = anOrder().withStatus('paid').withItems(3).build();

Every test states only what it cares about, and the builder supplies valid defaults for everything else. When Order gains a required field, one file changes rather than two hundred tests — which is the actual reason test suites rot as a domain model grows.

Check your understanding

Common mistakes

A Builder for a small value object. Two always-required fields need a constructor, and a builder makes an invalid intermediate state possible where none existed.

Handing out the builder's own collections. The built object then mutates from a distance. Copy on the way out.

Validating field by field instead of at build(). The point is that the whole is checked once — cross-field rules ("limit requires an order by") cannot be checked any other way.

A reusable builder with no reset. Calling build() twice returns two objects sharing accumulated state. Either forbid reuse or reset explicitly.

Builders that expose getters. Once other code can read half-built state, the encapsulation that justified the pattern is gone.

Not using a builder for test data. This is where it pays best in most codebases, and it is the use people skip.

Practice

An HTTP client is constructed as `new HttpClient(baseUrl, timeout, retries, backoffMs, followRedirects, verifyTls, proxyUrl, userAgent, maxBodyBytes)`. Design a better construction API and justify each decision, including where you would deliberately not use a Builder.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Sort the nine parameters by whether they are required, related to each other, or independent.

Show a strong answer

First, sort the parameters — the fix follows from the sorting, not from picking a pattern.

  • Required and identifying: baseUrl. There is no sensible default.
  • A related group: retries, backoffMs and arguably timeout are one policy. They are meaningless apart — three retries with no backoff is a different thing from three with exponential backoff, and setting one without the others is almost always a mistake.
  • A related group: verifyTls and proxyUrl are transport/security concerns that a reviewer should see together. verifyTls: false deserves to be conspicuous.
  • Independent knobs: followRedirects, userAgent, maxBodyBytes.

What I would build:

new HttpClient('https://api.example.internal', {
  retry: RetryPolicy.exponential({ attempts: 3, baseMs: 200 }),
  transport: { verifyTls: true },
  followRedirects: true,
  userAgent: 'orders-service/2.1',
});
  • baseUrl stays positional because it is required and it is what the object is. Burying it in an options bag alongside optional flags hides the one thing that must be present.
  • The retry group becomes its own type, not three keys. This is the important move: RetryPolicy.exponential(...) makes the invalid partial states — attempts with no backoff — unrepresentable, and it gives the policy somewhere to live and be tested on its own.
  • The rest become an options object, because their problem was readability and nothing more.

Where I would deliberately not use a Builder: here, for the client itself. Construction is a single call at start-up with everything known at once, the object is immutable from birth, and there is no cross-field validation a constructor cannot do. A builder would add a type and permit a half-configured client that currently cannot exist.

Where I would use one: the request, not the client. client.get('/orders').query('status', 'paid').header('X-Trace', id).send() is construction over time, assembled conditionally, validated at send — the actual shape Builder fits. That is the useful distinction: the client is configuration, the request is a process.

And in tests: aClient().withRetries(0).build(), so a hundred tests do not break when a tenth option is added.

A Builder is reused: `const b = new ReportBuilder().withTitle('Q1'); const a = b.build(); b.withSection('x'); const c = b.build();` A colleague reports that `a` now has a section it should not have. Diagnose it, then propose two fixes with different trade-offs.

Write your own answer first, on paper or in a note. Then compare.

Show a hint

Ask what build() handed out.

Show a strong answer

The diagnosis: build() passed the builder's own section collection to the Report by reference rather than copying it. a and the builder therefore point at the same list, so mutating the builder afterwards mutates a. a was correct when it was created and became wrong later, which is why this is reported as a mystery rather than as a construction bug — the stack trace at the point of the incorrect read contains nothing about the builder.

Fix one — copy on the way out.

build() { return new Report(this.#title, [...this.#sections]); }
  • Trade-off: smallest possible change, fixes the reported symptom completely, and keeps the reuse pattern the colleague is relying on. It costs an allocation per build, which is irrelevant here.
  • What it does not fix: the builder is still reusable, so the second call producing a different report from the first remains a subtle behaviour. That may be intended — accumulating a base configuration and building variants is a legitimate use — but nothing in the API says so.

Fix two — make the builder single-use.

build() {
  if (this.#built) throw new Error('builder already used');
  this.#built = true;
  return new Report(this.#title, Object.freeze([...this.#sections]));
}
  • Trade-off: removes the whole class of aliasing and staleness confusion, at the cost of breaking any existing code that deliberately reuses a builder as a template. It converts a silent wrong answer into a loud failure, which is the right direction.
  • Migration: if reuse is genuinely wanted, add an explicit copy() that returns a fresh builder with the same accumulated state. That expresses the intent instead of relying on a side effect.

Which I would ship: both — copy on the way out immediately, because it fixes live incorrect data with no behaviour change; then single-use plus copy() as a follow-up, since it stops the next variant of this. I would also freeze the returned collection, so the mirror-image bug — a caller mutating the report's sections — fails loudly instead of quietly.

Key takeaways

  • Readability alone does not justify Builder. Named arguments or an options object solve that without a new type.
  • Builder earns its place when construction is a process: parts arrive over time, from different places, and the whole must be validated together.
  • Its distinctive contribution is that the invalid half-built state exists only inside the builder, which is then discarded.
  • build() must copy anything it hands out, or the "immutable" result mutates from a distance.
  • A step builder moves required-field checks to compile time — worth it for public library APIs, rarely inside one application.
  • Test data builders are where Builder pays best: tests state only what they care about, and adding a required field changes one file.
  • Never build a value object with two always-required fields — you would be creating an invalid state that the constructor already prevented.

Further reading

  • Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
  • Joshua Bloch, Effective Java, third edition (Addison-Wesley, 2018) — the builder for constructors with many parameters.
  • Nat Pryce and Steve Freeman, Growing Object-Oriented Software, Guided by Tests (Addison-Wesley, 2009) — test data builders.
  • Martin Fowler, Domain-Specific Languages (Addison-Wesley, 2010) — method chaining and fluent interfaces.
  • Scott Wlaschin, Domain Modeling Made Functional (Pragmatic Bookshelf, 2018).

Check yourself

3 questions in this lesson. Answer each once to complete it.

Practice this

Next lessonPrototype
Lessons in this track