Builder¶
Intent¶
Separate the assembly of a complex object from the object itself. Parts arrive one step at a time, in any order, from whichever code knows them; build turns the collected parts into a complete, validated, immutable product. The builder is the object that is allowed to be half-finished, so the product never has to be.
When to use and when not to¶
Use it when
- A few required parts, many optional ones that interact: an end time or a duration, a recurrence that needs a start. A nine-parameter constructor hides those rules;
buildstates them once. - Parts arrive from different places or at different times: the form knows the title, the availability service knows the slot, the room service knows the room. The builder travels between them unfinished.
- The product should be immutable and never invalid: the builder absorbs the mutation, and the frozen
Meetingcan be shared, hashed and cached without a lock. - Every validation failure should be reported at once, not one per round trip.
Leave it out when
- Every part is known at one call site: keyword-only arguments with defaults are the builder, with a
TypeErrorfor a missing name. - The object is mutable anyway and each field validates alone: setters are the builder.
- Three required fields and nothing optional: a builder for
Point(x, y, z)is ceremony the interviewer will notice. - What varies is which class to build, not which parts: that is Factory Method.
Structure¶
Two classes and a recipe: the builder collects parts and validates at build; the product is a frozen value that re-checks the same rules.
classDiagram
class MeetingBuilder {
-_title: str
-_organizer: str
-_start: datetime
-_end: datetime
-_duration: timedelta
-_attendees: Set~str~
-_room: str
-_recurrence: Recurrence
+starting_at(start: datetime) MeetingBuilder
+ending_at(end: datetime) MeetingBuilder
+lasting(minutes: int) MeetingBuilder
+invite(attendees: str) MeetingBuilder
+in_room(room: str) MeetingBuilder
+repeating(recurrence: Recurrence) MeetingBuilder
+build() Meeting
}
class Meeting {
+title: str
+organizer: str
+start: datetime
+end: datetime
+attendees: FrozenSet~str~
+room: str
+recurrence: Recurrence
+duration: timedelta
+moved_to(room: str) Meeting
}
class Recurrence {
<<enumeration>>
NONE
DAILY
WEEKLY
}
MeetingBuilder ..> Meeting : builds
Meeting --> Recurrence
MeetingBuilder --> Recurrence
MeetingBuilder is the only mutable object in the picture, and the dotted arrow is the whole pattern: build is the one place a Meeting is assembled from steps. daily_standup, the Gang of Four Director, is a function that drives the builder through a fixed recipe, so it has no box.
Canonical example in Python¶
The invariants and the product come first (code/patterns/builder.py, tested by code/patterns/tests/test_builder.py):
class Recurrence(StrEnum):
NONE = "none"
DAILY = "daily"
WEEKLY = "weekly"
def meeting_problems(
title: str,
organizer: str,
start: datetime | None,
end: datetime | None,
attendees: frozenset[str],
) -> list[str]:
"""Every invariant in one place, shared by the product and the builder.
Returns *all* violations rather than raising on the first, so a caller can fix
a form in one round trip instead of five.
"""
problems: list[str] = []
if not title.strip():
problems.append("title is required")
if not organizer.strip():
problems.append("organizer is required")
if start is None:
problems.append("a start time is required")
if end is None:
problems.append("an end time or a duration is required")
if start is not None and end is not None:
if start.tzinfo is None or end.tzinfo is None:
problems.append("start and end must be timezone-aware")
elif end <= start:
problems.append("end must be after start")
elif end - start > MAX_DURATION:
problems.append(f"a meeting cannot last more than {MAX_DURATION_HOURS} hours")
if organizer not in attendees:
problems.append("the organizer must attend")
return problems
@dataclass(frozen=True, slots=True)
class Meeting:
"""The product: immutable and never invalid, because ``__post_init__`` enforces the invariants.
Every way in, the builder, keyword arguments or ``replace``, ends here.
"""
title: str
organizer: str
start: datetime
end: datetime
attendees: frozenset[str] = frozenset()
room: str | None = None
recurrence: Recurrence = Recurrence.NONE
def __post_init__(self) -> None:
problems = meeting_problems(
self.title, self.organizer, self.start, self.end, self.attendees
)
if problems:
raise ValidationError("; ".join(problems))
@property
def duration(self) -> timedelta:
return self.end - self.start
def moved_to(self, room: str) -> Meeting:
"""A with-er: ``replace`` copies, changes one field and re-runs the validation."""
return replace(self, room=room)
Three decisions to say out loud:
- The rules live in one function.
meeting_problemsservesMeeting.__post_init__andMeetingBuilder.build, so nobody can construct aMeetingaround the builder. It returns a list instead of raising, which is what letsbuildreport every problem in one message. - The product is frozen. Hashable and safe to share across threads; the only way to change one is
moved_to, which isdataclasses.replaceunder a domain name, validated again. - Timezone-aware or rejected. The value type refuses a naive
datetimeonce, so no service has to check.
The builder and a director:
class MeetingBuilder:
"""The object that is allowed to be incomplete.
Required parts go in the constructor, so a builder never lacks what it could
have been given up front. Optional parts arrive through steps that return
``self``, from whichever part of the program knows them. ``build`` derives the
end from a duration, adds the organizer to the attendees, validates everything
at once and returns a ``Meeting``. A builder is single-use and single-threaded:
one per construction, then discarded, so it needs no lock.
"""
def __init__(self, title: str, organizer: str) -> None:
self._title = title
self._organizer = organizer
self._start: datetime | None = None
self._end: datetime | None = None
self._duration: timedelta | None = None
self._attendees: set[str] = set()
self._room: str | None = None
self._recurrence = Recurrence.NONE
def starting_at(self, start: datetime) -> Self:
self._start = start
return self
def ending_at(self, end: datetime) -> Self:
self._end, self._duration = end, None
return self
def lasting(self, minutes: int) -> Self:
self._duration, self._end = timedelta(minutes=minutes), None
return self
def invite(self, *attendees: str) -> Self:
self._attendees.update(attendees)
return self
def in_room(self, room: str) -> Self:
self._room = room
return self
def repeating(self, recurrence: Recurrence) -> Self:
self._recurrence = recurrence
return self
def build(self) -> Meeting:
start, end = self._start, self._end
if start is not None and end is None and self._duration is not None:
end = start + self._duration
attendees = frozenset(self._attendees | {self._organizer})
problems = meeting_problems(self._title, self._organizer, start, end, attendees)
if problems:
raise ValidationError("; ".join(problems))
assert start is not None and end is not None # proven by the check above
return Meeting(
self._title, self._organizer, start, end, attendees, self._room, self._recurrence
)
def daily_standup(
organizer: str, team: Iterable[str], first_day: datetime, room: str | None = None
) -> Meeting:
"""The Director: a recipe that drives the builder. A recipe is a function, so it is one."""
builder = (
MeetingBuilder("Daily stand-up", organizer)
.starting_at(first_day)
.lasting(STANDUP_MINUTES)
.invite(*team)
.repeating(Recurrence.DAILY)
)
if room is not None:
builder.in_room(room)
return builder.build()
- Required in
__init__, optional in steps.MeetingBuilder("Design review", "ana")cannot exist without a title and an organizer, sobuildnever fails over a part the caller had from the start. Steps returnself, typedSelf, and do not validate:lasting(0)is accepted now and rejected atbuild, because a half-built object is allowed to be wrong. buildderives, then checks. The end comes from the duration and the organizer joins the attendees beforemeeting_problemsruns, so the errors talk about the product. The last ofending_atandlastingwins. A builder serves one construction and is discarded, never shared between threads, so it needs no lock.- The Director is
daily_standup. The Gang of Four made it a class that drives a builder through a fixed sequence; a fixed sequence of calls is a function, and a second recipe is a second function.
Running python -m patterns.builder prints:
--- fluent steps, then one validation ---
Design review: 09:00-09:45 in Board room, ['ana', 'ben', 'chen']
--- the builder travels half-built: the slot finder supplies the start later ---
1:1: 11:00-11:30
--- every problem at once, not just the first ---
rejected: title is required; a meeting cannot last more than 8 hours
--- the director: a recipe is a function ---
Daily stand-up: daily, 15 min, 3 attendees
--- Pythonic: keyword-only arguments, then replace() for changes ---
moved from Tue 09:00 to Wed 09:00; original still Tue 09:00
rejected: end must be after start
Pythonic variant¶
When every part is known at one call site, the signature is the builder:
def schedule(
*,
title: str,
organizer: str,
start: datetime,
minutes: int,
attendees: Iterable[str] = (),
room: str | None = None,
) -> Meeting:
"""Keyword-only arguments with defaults.
When every part is known at one call site, the signature is the builder: names
instead of steps, defaults instead of optional setters, and a ``TypeError`` for
a positional call or a missing name.
"""
end = start + timedelta(minutes=minutes)
return Meeting(title, organizer, start, end, frozenset(attendees) | {organizer}, room)
def reschedule(meeting: Meeting, start: datetime) -> Meeting:
"""``dataclasses.replace`` is the builder for frozen values: copy, change, re-validate.
``__post_init__`` runs again on the copy, so a change that breaks an invariant
fails exactly the way a bad ``build`` does, and the original is untouched.
"""
return replace(meeting, start=start, end=start + meeting.duration)
- Keyword-only arguments (the bare
*) are named steps, defaults are optional steps, and Python raises theTypeErrorfor a missing name at the call, not atbuild. The call reads like the chain and costs no class. dataclasses.replaceis the builder for a value that already exists: copy, change a field, re-run__post_init__.reschedulemoves the start and keeps the duration; the original is untouched, so a rejected change cannot corrupt a meeting other code holds.__post_init__on the product is the part you keep even when you drop the builder class: validation once, in the type, not in every caller.
| Reach for | When |
|---|---|
| Keyword-only arguments with defaults | All parts known at one call site; the rules fit in __post_init__ |
A frozen dataclass plus replace |
The object exists and you want a changed, re-validated copy |
| A builder class | Parts arrive from several places or over time; cross-field rules; every error reported at once |
| A director function | The same recipe is built in more than one place |
Draw the builder, then say: "in Python I would start with keyword-only arguments and promote to a builder when the parts start arriving from different places."
Real-world usage¶
argparse.ArgumentParseris configured in steps (add_argumentin any order,add_subparsers,set_defaults) and built byparse_args, which applies types, choices and required-ness at the end and returns aNamespace; missing options are reported together, a bad value one at a time.xml.etree.ElementTree.TreeBuilder:start,dataandendassemble a tree andclosehands back the root; the parser driving it is the Director.- String building:
parts.append(...)followed by"".join(parts), orio.StringIO, is the idiom Java needsStringBuilderfor. - Generative query APIs: SQLAlchemy's
select(...).where(...).order_by(...)and Django'sQuerySet.filter(...)return a new statement from every step and run nothing until execution, which is thereplaceform at scale.
Related patterns and confusions¶
| Looks like Builder | How to tell them apart |
|---|---|
| Factory Method | One call, one key, whole object: create("sms"). Builder is many calls, many parts, checked at the end. A factory may use a builder inside; a builder chooses parts, never classes. |
| Abstract Factory | Returns several simple objects that must match; Builder returns one complex object assembled from parts. |
| Prototype | Also yields a configured object, by copying a finished one and changing a field. replace is where the two meet: Prototype when a good instance exists, Builder when one must be assembled. |
| Fluent interface | A style, not a pattern. select().where() is fluent and immutable, Path / "x" is fluent and not a builder at all. Say "fluent" about the calls and "builder" about the build. |
| Telescoping constructors | The problem, not a pattern: Meeting(title, org, start, None, 30, None, None, "daily"). Keyword arguments cure it, which is why Builder is rarer in Python than in Java. |
| Template Method | The Director fixes the order of construction steps; Template Method fixes the order of algorithm steps. |
Where it appears in LLD problems¶
- Design a meeting scheduler and calendar — this module's
MeetingBuilder: title and organizer up front, attendees, slot, room and recurrence from three services, one validation before the calendar accepts it. - Design a hotel management system — a reservation assembled from guest, room type, dates and add-ons; check-out after check-in and guests within capacity are checked once, before inventory is touched.
- Design a logging framework — a logger configuration built in steps (level, handlers, formatter, filters) and validated before the first record, the shape
logging.config.dictConfigexposes as data. - Design a notification service (LLD) — a request assembled from template, recipients, channel and priority; there the checks live in pipeline stages rather than in
build, which is where they go once they must be reordered.
Interview tips¶
Interview tip
Lead with the invariant, not the pattern: "a meeting needs an end or a duration, the organizer must attend, and those parts come from three services, so a MeetingBuilder collects them and build validates once and returns a frozen Meeting." Then add the Python sentence: keyword-only arguments would do if one call site knew everything, and replace handles changes after the fact.
Common mistake
Validating in the steps instead of in build. A builder that rejects lasting(0) on the spot cannot accept an ending_at later, and one that checks "organizer must attend" inside invite forces callers into a fixed order, which is the problem the pattern removes. Collect, check once, report every failure in one error. Runner-up: a mutable product, which turns build into a setter with extra ceremony.
Related¶
- Factory Method — one key, one call, when the choice is the class
- Prototype — copy a finished object instead of assembling one
- Design a meeting scheduler and calendar — the builder in its home problem
- Design a hotel management system — reservations validated once
- Design a logging framework — logger configuration as a builder
- Design a notification service (LLD) — notifications validated before dispatch
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Builder
- Python documentation:
argparse - Python documentation:
dataclasses.replace