Look at this constructor.
That is the moment a constructor stops being a constructor and starts being a tax. Six positional arguments, two of which are nested option objects, and at the call site there is no way to tell which 100 was supposed to be the limit and which the offset without checking the signature. The Builder pattern exists for exactly this shape.
My stance: Builder is a sharp tool with a narrow legitimate range. It earns its keep when an object has many configurable fields, especially when some of them are mutually constrained or have meaningful defaults. It does NOT earn its keep when you are reaching for it because your language lacks named arguments, or because the type already has a small number of fields. Most cases of "we should use Builder" are actually "we should use a config object".
The canonical Builder shape
A Builder is a separate class whose only job is to assemble a target object. It has a method per field (returning this for chaining) and a build() method that produces the final object. The target's constructor is private or package-scoped so callers cannot bypass the Builder.
Four properties of that snippet are the actual win:
- Every step has a name. Reading the call site,
from('users')says what'users'means; the constructor version had no such hint. - Defaults are declared in one place. A new caller that does not care about pagination still gets sensible defaults, and the defaults are visible by reading the Builder's field initializers.
build()is the single place to enforce invariants. Thelimit < 0check happens once, no matter how many call sites construct aQueryRequest. The constructor by itself could have done this too, but most teams scatter the check across factory methods or the call site.- The target object is immutable.
readonlyfields, no setters. Oncebuild()returns, nothing mutates. That immutability is much harder to maintain when a constructor with eight parameters is called from many places, because each caller is tempted to mutate after construction.
When I genuinely use Builder
Three cases that earn it in my work:
- Cross-cutting builders (queries, requests, configurations). A
QueryRequestBuilder, aHttpRequestBuilder, aWebDriverOptionsBuilder. The set of fields is large, defaults matter, and most call sites only set a handful. The Builder makes "set what differs from default" the natural mode. - Test data factories. This is the one where Builder pays off most reliably.
aUser().withEmail('x@y').withReferralCode('K123').build()is dramatically better than constructing test users with positional arguments, and the test reads like a sentence. - Stepwise objects with invariants between steps. A
PolicyBuilderthat requiresrequireRole('admin')to be called beforeallowAction('delete')can model that as a method that is only available afterrequireRole, using TypeScript's branded types or Java's stage interfaces. The Builder is the only structure that lets you encode "some method calls only make sense after others" at the type level.
When Builder is overkill
The most common misuse: reaching for Builder when a config object would do.
In TypeScript and Python and Kotlin and Swift, named/keyword arguments do most of what Builder does for a flat record. The reasons to step up to Builder are real but specific: defaults that depend on other fields, validation that needs the full set of inputs, methods that should only be callable after other methods, or a builder that is itself reused (you build a base config and clone-and-modify for variants).
In Java, where named arguments do not exist, Builder shows up more often because it has to. In modern JS/TS, half the supposed Builders should have been TypeScript object literals.
The second misuse: Builder for a class with three fields. The ceremony is not free. A target class plus a builder class plus the chaining boilerplate is forty lines for what could have been a record-typed parameter object. Every additional layer of indirection has to pull its weight at the call site, and three-field classes do not generate enough complexity to justify it.
The third misuse: builders that mutate the target after construction. Some teams write a Builder that holds a reference to a partially-constructed target and mutates it as steps are called. This is wrong. The whole point of the Builder is that the target is immutable; the assembly is mutable, but it lives in the builder, not in the target. If your target has setters, you have an OOP class with verbose construction, not a Builder.
The director, and why I almost never use one
The original GoF Builder includes a Director class: an external object whose job is to call a sequence of Builder methods to produce common variants of the target. The shape is something like "the Director knows how to build a BasicUser, an AdminUser, and a ReadOnlyUser given any concrete Builder". I have never shipped a Director in production code. Modern code achieves the same goal with named factory functions: buildAdminUser(builder) is a function that takes a builder and returns one configured for the admin case. There is no separate class, no abstract Director interface, and the function is testable on its own.
The one case where a Director might still earn its keep is when there are several Builder implementations (e.g. one for an SQL backend, one for a NoSQL backend) and the same construction recipe needs to work against any of them. That is genuinely rare in application code and more common in framework code. If you find yourself needing it, you likely already know.
A useful trick: type-state for required fields
The Builder's biggest weakness is that build() may be called too early. Let me write a Builder that requires a from(...) call before build(), and forbids build() until then:
The build method's type signature requires S to extend Required. Without calling from(...) first, TypeScript refuses to compile build(). That is a compile-time invariant that the constructor form cannot offer. The same idea, with stage interfaces, is achievable in Java; it is awkward but possible.
I use this trick in shared internal libraries where a missing required field would otherwise crash deep in the call chain at runtime. For one-off builders inside a service, the runtime check in build() is enough.
A pattern Java taught me but TS doesn't always need
Java's Builder is everywhere because Java does not have named or default arguments and does not have records (the record class helped some, but the pattern stayed). When I write Kotlin, Swift, or TypeScript, I lean on the language: a data class with default values gives me 80% of Builder's benefits with no boilerplate. I do not feel guilty using Builder where it earns its keep, but I also do not import Builder muscle memory into a language that already has the better tool.
In TypeScript specifically, my heuristic is: if I can describe the construction with an object literal in a single screen, I use the literal. If construction has logic (validation depending on multiple fields, or a stepwise contract), I write the Builder. The cutoff is whether the construction has a non-trivial build() body.
A real failure mode: builders that mutate their own output
A bug I hit on a project a few years ago. The Builder cached the partially-built target as a field, and build() returned that field directly. The first call worked. The second call to build() on the same Builder returned the same object instance. A test that did aUser().build() twice ended up with two references to one user, and mutating one mutated the other.
The fix is to copy on build():
This is not paranoia. Sharing object references between builds is a real source of action-at-a-distance bugs, and the cost of a shallow copy is negligible compared to the cost of debugging shared-state mutations. The rule I tell teams: every build() returns a fresh object that does not share mutable state with the Builder. If your Builder has nested objects (arrays, dictionaries, sub-records), copy them too.
Test data builders are the underrated cousin
The place I see the biggest ROI from Builder is in tests, and it is the case least mentioned in pattern textbooks. A aUser() function that returns a Builder with sane defaults (random email, verified, no admin role, balance zero) lets every test set only the fields it cares about:
Replace the equivalent constructor calls and the test files shrink by twenty percent and read like specifications. The defaults centralize in one place; when a new required field is added to User, you update aUser() once instead of every test file. This is genuinely the most reliable productivity win I have gotten from a design pattern in a decade. If your team writes a lot of unit tests against domain objects, build the test-data builders first.
A line I use to decide
When I am about to write a Builder, I ask myself one question: am I building this for a constructor with too many parameters, or for an object whose construction has real logic. If the answer is the first, I check whether named arguments or a config object solve it more cheaply. If the answer is the second (defaults that depend on other fields, validation that needs all the inputs, a sequence of steps with invariants), the Builder is earning its weight. The rest is style: chainable methods, a single build() at the end, and an immutable target. Get those three right and the pattern carries you for years.
