Open any HTTP client wrapper in a mature codebase and you will find a constructor that looks like this: Request("POST", url, None, body, 5, 3, False). Seven positional arguments. Quick, which one is the timeout, which one is the retry count, and what does that False turn off? You cannot tell without opening the class, and neither can the reviewer who approved the pull request.
This is the problem Builder is famous for solving. Refactoring Guru calls it the telescoping constructor: one big constructor, most parameters unused in any given call, and a new overload every time someone needs one more option. The textbook answer is a Builder class. My claim is narrower and, I think, more useful: in 2026 about half of the Builders people write are fixing a problem their language already solved, and the other half are solving a problem that no language feature touches. Learn to tell them apart and you will write fewer classes and better ones.
The problem, reproduced
Here is the constructor that causes the trouble:
class HttpRequest:
def __init__(self, method, url, headers=None, body=None, timeout=30,
retries=0, verify_tls=True, follow_redirects=True):
...
r = HttpRequest("POST", "https://api.example.com/orders", None, '{"id":1}', 5, 3, False)
I ran this. It works, and it is a trap. To set verify_tls to False you must also pass headers, body, timeout and retries, in order, even if you only care about one of them. Swap two integers and nothing fails: timeout=3, retries=5 is valid Python and a very different request. The type checker cannot help, because both arguments are ints.
That is a readability problem and a correctness problem. Keep both in mind, because they have different fixes.
Fix one: let the language do it
If your language has named arguments, the readability problem is gone, and you do not need a Builder for it. Python has had keyword arguments forever, and since 3.10 you can force callers to use them. The dataclasses documentation describes kw_only=True, which makes every field keyword-only in the generated __init__, and frozen=True, which makes instances read-only.
@dataclass(kw_only=True, frozen=True)
class Request:
method: str
url: str
timeout: float = 30
retries: int = 0
Request("GET", "x")
# TypeError: Request.__init__() takes 1 positional argument but 3 were given
Request(method="GET", url="x", retries=2)
That is the whole fix. Twelve fields, defaults for the optional ones, every call site self-documenting, and the object is immutable after construction. No second class. Kotlin, Swift, C# and TypeScript option objects land in the same place. If this is your situation, a Builder is ceremony, and I would reject it in review.
Java is the language where the pattern grew up, because Java has no named arguments. Effective Java's advice to consider a builder when faced with many constructor parameters is sound for Java, and tools like Lombok's @Builder generate the whole class from annotations: a builder type, one chaining setter per field, a build() method, and optionally toBuilder() to start from an existing instance. Notice what that implies. When a library can generate the Builder from a field list, the Builder carries no design information. It is plumbing, and plumbing is the language's job.
Fix two: what the language cannot do
Named arguments tell you which value goes where. They cannot tell you whether the combination makes sense. That is the case where a Builder still earns its keep.
Consider two rules that live between fields, not inside one:
A GET request must not carry a body.
Retrying a POST is only safe if the request has an idempotency key.
With a keyword-only dataclass you can enforce these in __post_init__, and for two rules that is the right call. But watch what happens as construction gets more involved. Headers accumulate from several places: auth middleware adds one, the JSON helper adds another, the caller adds a third. Setting the body implies a content type. You want to assemble the thing in stages, and you want exactly one place that decides whether the finished result is legal.
That is a Builder:
@dataclass(frozen=True)
class Request:
method: str
url: str
headers: tuple
body: str | None
timeout: float
retries: int
class RequestBuilder:
def __init__(self, method, url):
self._method, self._url = method, url
self._headers, self._body = [], None
self._timeout, self._retries = 30.0, 0
def header(self, k, v):
self._headers.append((k, v)); return self
def json(self, text):
self._body = text
return self.header("Content-Type", "application/json")
def timeout(self, s): self._timeout = s; return self
def retries(self, n): self._retries = n; return self
def build(self):
if self._method in ("GET", "HEAD") and self._body is not None:
raise ValueError(f"{self._method} must not carry a body")
has_key = any(k == "Idempotency-Key" for k, _ in self._headers)
if self._retries > 0 and self._method == "POST" and not has_key:
raise ValueError("retrying POST requires an Idempotency-Key header")
return Request(self._method, self._url, tuple(self._headers),
self._body, self._timeout, self._retries)
I ran it. The happy path:
req = (RequestBuilder("POST", "https://api.example.com/orders")
.json('{"id":1}')
.header("Idempotency-Key", "abc")
.retries(3).timeout(5)
.build())
produces a Request with both headers in order. The two bad paths fail at build() with exactly the messages above: GET must not carry a body and retrying POST requires an Idempotency-Key header. And assigning req.timeout = 1 afterwards raises FrozenInstanceError.
Look at what the split buys. The mutable, half-finished thing lives in the builder. The finished thing is immutable and has passed validation, so every function downstream can trust a Request without re-checking it. This is the property Refactoring Guru points at when it says a builder "doesn't expose an unfinished product to other code". The invalid state cannot be passed around, because it cannot be built.
That, not the fluent chaining, is the point. Chaining is syntax sugar. Deferring validation to one gate and handing out only immutable results is the design.
Where Builders run in production
Once you know what to look for, the pattern is everywhere, and in each case the reason is the same: the product is assembled from pieces that arrive at different times, and legality is a property of the whole.
Query builders. A SQL builder collects conditions as application code decides which filters apply, then emits the string and the parameters together. Here is a minimal one I ran:
q = Query("orders").where("status = %s", "paid")
if user_filter:
q.where("user_id = %s", user_filter)
q.where("total > %s", 100).limit(20)
q.build()
# ('SELECT * FROM orders WHERE status = %s AND total > %s LIMIT 20', ('paid', 100))
Without the builder you concatenate strings with AND in the right places, which is where injection bugs and stray WHERE AND errors come from. The builder keeps values out of the SQL text by construction. The conditions are your own code's fragments and the values travel separately as parameters.
HTTP requests, test data and configs. Every serious HTTP client exposes a request builder for the reason above. Test suites use "test data builders" so that anOrder().withStatus("paid").build() creates a valid order with sensible defaults and only the fields the test cares about spelled out. Infrastructure SDKs build large nested configs (a cluster, a pipeline, an IAM policy) in steps because the final shape depends on many independent decisions.
The Director, rarely. The textbook pattern includes a Director, a class that knows the order of build steps so you can reuse a recipe. In application code you almost never need it. A plain function that takes a builder and calls the steps does the same job. If you find yourself writing RequestDirector, stop and write a function.
Go took a different road
Go has no named arguments and no overloads, so you would expect Builders everywhere. The community went another way. Rob Pike described the idea in Self-referential functions and the design of options: an option is a function that modifies the object, and the constructor accepts any number of them.
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithRetries(n int) Option { return func(s *Server) { s.retries = n } }
func NewServer(addr string, opts ...Option) (*Server, error) {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, o := range opts {
o(s)
}
if s.retries > 0 && s.timeout == 0 {
return nil, errors.New("retries require a timeout")
}
return s, nil
}
I did not compile this one, so treat it as a sketch of the shape rather than tested code. Pike's original post goes further and has options return their own inverse so you can set a value temporarily and restore it with defer, which is a nice trick the classic Builder cannot do.
The structure is the same as the Python Builder. Pieces accumulate, then one function owns the final check. Functional options are a Builder wearing a language-appropriate costume, and that is a good sign: the idea survives translation, only the syntax changes.
The yes, but
The strongest objection is that all of this is more code than a constructor, and Refactoring Guru says as much: the pattern needs several new classes and raises overall complexity. That is true. A Builder for a class with four fields and no cross-field rules is pure cost. You write a second class, you keep it in sync with the first, and a new field now requires edits in two places. I have seen Builders where build() just forwarded every field to the constructor and checked nothing. That is a constructor with extra steps.
There is a second, subtler cost. Builders are mutable, and mutable objects get reused. I ran this and it bit immediately:
b = RequestBuilder("GET", "u").header("X-A", "1")
r1 = b.build()
b.header("X-B", "2")
r2 = b.build()
# len(r1.headers) == 1, len(r2.headers) == 2
That works because I copied the list into a tuple in build(). If build() had passed the internal list straight through, r1 would have grown a header after the fact and your "immutable" object would not be. Copy mutable state on the way out, every time.
So the honest rule is this. Do not write a Builder because a class has many fields. Write one because the object has rules that span fields, or because it is assembled in stages by code that does not know about each other.
What to do on Monday
Search your codebase for constructors with more than five parameters and for *Builder classes. For each constructor, ask whether named arguments plus defaults would fix it. If yes, switch to keyword-only parameters and stop. For each Builder, read its build(). If it validates something that spans fields, keep it and make sure the product is immutable and the internal state is copied out. If it only forwards arguments, delete it and use the language feature it was imitating.
Then add one test per cross-field rule that checks the failure message, because those messages are what the on-call engineer will read.

