Builder
Constructing a complex object step by step with readable code.
01The intuition
The problem it solves
A sandwich shop app creates orders. An order has bread, up to five fillings, sauces, toasted or not, cut in halves or quarters, and a note for the kitchen. The first version is a constructor with every option.
class Sandwich:
def __init__(self, bread, fillings, sauces, toasted, cut, note, extra_cheese, gluten_free):
...
s = Sandwich("wheat", ["paneer", "lettuce"], ["mint"], True, "halves", "", False, False)
t = Sandwich("wheat", ["paneer"], [], False, "quarters", "no onion", True, False)
Reading the calls, which True is "toasted" and which is "extra cheese"? Swapping two booleans compiles fine and makes the wrong sandwich. Every optional field pushes another positional argument into every call. And the shop's rules, such as "at most five fillings" or "gluten-free bread cannot be toasted on the shared grill", have nowhere to live except a giant constructor.
A builder is a helper object that collects the pieces of a complex thing one readable step at a time, checks the rules, and then produces the finished object in one go.
Build it one piece at a time
Step 1: make the finished object simple and immutable. The sandwich itself only stores; it does not negotiate.
from dataclasses import dataclass
@dataclass(frozen=True)
class Sandwich:
bread: str
fillings: tuple[str, ...]
sauces: tuple[str, ...]
toasted: bool
cut: str
note: str
Step 2: add a builder with one method per decision. Each method returns the builder, so calls chain and read like the order being spoken.
class SandwichBuilder:
def __init__(self, bread: str):
self._bread, self._fillings, self._sauces = bread, [], []
self._toasted, self._cut, self._note = False, "halves", ""
def add(self, filling: str) -> "SandwichBuilder":
self._fillings.append(filling); return self
def sauce(self, name: str) -> "SandwichBuilder":
self._sauces.append(name); return self
def toasted(self) -> "SandwichBuilder":
self._toasted = True; return self
def cut_in(self, cut: str) -> "SandwichBuilder":
self._cut = cut; return self
def note(self, text: str) -> "SandwichBuilder":
self._note = text; return self
Step 3: put the rules in build. The builder is the only place that can produce a Sandwich, so it is the only place the rules need to be.
class SandwichBuilder:
# ... methods from step 2 ...
def build(self) -> Sandwich:
if len(self._fillings) > 5:
raise ValueError("at most five fillings")
if self._bread == "gluten-free" and self._toasted:
raise ValueError("gluten-free bread is not toasted on the shared grill")
return Sandwich(self._bread, tuple(self._fillings), tuple(self._sauces),
self._toasted, self._cut, self._note)
order = (SandwichBuilder("wheat").add("paneer").add("lettuce")
.sauce("mint").toasted().build())
Step 4: name common recipes. A director is just a function that drives the builder in a fixed order; "the usual" becomes one call.
def classic_paneer() -> Sandwich:
return SandwichBuilder("wheat").add("paneer").add("onion").sauce("mint").toasted().build()
| Step | How an order reads | Where "max five fillings" lives | Risk of swapped booleans |
|---|---|---|---|
| Start | 8 positional arguments | nowhere | high |
| 1 and 2 | named steps | nowhere yet | none |
| 3 and 4 | named steps, recipes | build() | none |
Why it works
The builder separates collecting from being. While collecting, the object may be incomplete and inconsistent; that is fine because it is a builder, not a sandwich. The moment build runs, all rules are checked and an immutable, always-valid object comes out. The invariant is "a Sandwich that exists is a valid Sandwich". Readers get named steps instead of positional arguments, which removes a whole class of silent bugs.
Where it breaks: an object with three fields and no rules does not need a builder; keyword arguments and a dataclass do the same job in one line. Also beware a builder that is reused after build; either reset its state or document that it is single-use.
The trade-off you must say out loud
A builder is a second class that mirrors the first, roughly doubling the lines for that type. It pays off when there are many optional parts, when construction has rules, or when the same object is assembled in several steps across a program. Say which of those applies. The follow-up is often "why not keyword arguments", and the honest answer in Python is that for a flat object with no cross-field rules, keyword arguments win; the builder earns its place when build has something to check.
Where it shows up in the 100 Google problems
- Design a coffee machine: a drink is built step by step, size, shots, milk, syrup, and
buildrejects combinations the machine cannot make before any ingredient is used. - Design an online shopping system: an order is assembled from cart lines, address, payment and coupon, then validated once into an immutable order.
- Design an HTTP router with path parameters: routes are declared with a chained builder (
route("/users/{id}").get(handler).auth()) and compiled into the matching trie at build time. - Design a notification service: a message builder collects recipient, template, variables and priority, and refuses to build a message with unfilled placeholders.