Build one object in steps, check it once, and hand back something that can't change. In Python and JavaScript you often don't need it, and this lesson says when.
Book a ride in any taxi app. You must give a pickup and a drop. You may add up to three stops on the way, a note for the driver ("Gate 2, ring twice"), and a time if the ride is for later. Two required values, three optional ones, and some rules that span them: a ride needs two different places, three stops at most, a short note, and a time inside service hours.
That shape, a few required parts, several optional ones and rules to check before the thing exists, is what the Builder pattern is for. It is also one of the most over-used patterns, because many languages have a simpler answer. This lesson builds the ride request the long way, in four languages, then shows the shorter way and says when each one wins. The case studies do not use Builder by name: the hotel's Stay and the meeting rooms' Recurrence are small immutable values made with a plain constructor, and that is the right call for them. They show what the pattern is not needed for.
Ordering a custom pizza on a slip is a builder: you choose in any order, the kitchen checks the slip once, and a baked pizza can't be changed.
The first version: a long constructor#
The first version is one class with a constructor that takes everything, in order, and no checks:
# builder_v1.py: one constructor, five positional parameters, no checks
from typing import List, Optional
def hhmm(minutes: int) -> str:
return f"{minutes // 60:02d}:{minutes % 60:02d}"
class RideRequest:
def __init__(self, pickup: str, drop: str, stops: Optional[List[str]] = None, note: str = "", scheduled_at: Optional[int] = None) -> None:
self.pickup, self.drop = pickup, drop
self.stops = stops or [] # the caller's own list, not a copy
self.note, self.scheduled_at = note, scheduled_at
def describe(self) -> str:
text = f"{self.pickup} -> {self.drop}"
if self.stops:
text += " via " + ", ".join(self.stops)
if self.note:
text += f' | note "{self.note}"'
return text + (f" | at {hhmm(self.scheduled_at)}" if self.scheduled_at is not None else " | now")
print(RideRequest("Mall", "Airport").describe())
print(RideRequest("Mall", "Airport", ["Hotel"], "Gate 2", 1080).describe())
print(RideRequest("Mall", "Please call at the gate", ["Hotel"], "Airport", 200).describe()) # drop and note swapped, and 03:20 is outside service hours: both accepted// BuilderV1.java: telescoping constructors, five positional parameters, no checks
import java.util.List;
class BuilderV1 {
static String hhmm(int minutes) { return String.format("%02d:%02d", minutes / 60, minutes % 60); }
static final class RideRequest {
final String pickup, drop, note;
final List<String> stops; // the caller's own list, not a copy
final Integer scheduledAt;
RideRequest(String pickup, String drop) { this(pickup, drop, List.of()); }
RideRequest(String pickup, String drop, List<String> stops) { this(pickup, drop, stops, ""); }
RideRequest(String pickup, String drop, List<String> stops, String note) { this(pickup, drop, stops, note, null); }
RideRequest(String pickup, String drop, List<String> stops, String note, Integer scheduledAt) {
this.pickup = pickup;
this.drop = drop;
this.stops = stops;
this.note = note;
this.scheduledAt = scheduledAt;
}
String describe() {
StringBuilder text = new StringBuilder(pickup + " -> " + drop);
if (!stops.isEmpty()) text.append(" via ").append(String.join(", ", stops));
if (!note.isEmpty()) text.append(" | note \"").append(note).append("\"");
return text + (scheduledAt != null ? " | at " + hhmm(scheduledAt) : " | now");
}
}
public static void main(String[] args) {
System.out.println(new RideRequest("Mall", "Airport").describe());
System.out.println(new RideRequest("Mall", "Airport", List.of("Hotel"), "Gate 2", 1080).describe());
System.out.println(new RideRequest("Mall", "Please call at the gate", List.of("Hotel"), "Airport", 200).describe()); // drop and note swapped, and 03:20 is outside service hours: both accepted
}
}// builder_v1.cpp: one constructor with default arguments, five positional parameters, no checks
#include <cstdio>
#include <iostream>
#include <optional>
#include <string>
#include <utility>
#include <vector>
std::string hhmm(int minutes) {
char text[16];
std::snprintf(text, sizeof text, "%02d:%02d", minutes / 60, minutes % 60);
return text;
}
class RideRequest {
std::string pickup_, drop_, note_;
std::vector<std::string> stops_;
std::optional<int> scheduled_at_;
public:
RideRequest(std::string pickup, std::string drop, std::vector<std::string> stops = {}, std::string note = "",
std::optional<int> scheduled_at = std::nullopt)
: pickup_(std::move(pickup)), drop_(std::move(drop)), note_(std::move(note)), stops_(std::move(stops)), scheduled_at_(scheduled_at) {}
std::string describe() const {
std::string text = pickup_ + " -> " + drop_;
if (!stops_.empty()) {
text += " via ";
for (std::size_t i = 0; i < stops_.size(); ++i) text += (i ? ", " : "") + stops_[i];
}
if (!note_.empty()) text += " | note \"" + note_ + "\"";
return text + (scheduled_at_ ? " | at " + hhmm(*scheduled_at_) : std::string(" | now"));
}
};
int main() {
std::cout << RideRequest("Mall", "Airport").describe() << "\n";
std::cout << RideRequest("Mall", "Airport", {"Hotel"}, "Gate 2", 1080).describe() << "\n";
std::cout << RideRequest("Mall", "Please call at the gate", {"Hotel"}, "Airport", 200).describe() << "\n"; // drop and note swapped, and 03:20 is outside service hours: both accepted
}// builderV1.js: one constructor, five positional parameters, no checks
const hhmm = (minutes) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
class RideRequest {
constructor(pickup, drop, stops = [], note = "", scheduledAt = null) {
this.pickup = pickup;
this.drop = drop;
this.stops = stops; // the caller's own array, not a copy
this.note = note;
this.scheduledAt = scheduledAt;
}
describe() {
let text = `${this.pickup} -> ${this.drop}`;
if (this.stops.length > 0) text += ` via ${this.stops.join(", ")}`;
if (this.note) text += ` | note "${this.note}"`;
return text + (this.scheduledAt !== null ? ` | at ${hhmm(this.scheduledAt)}` : " | now");
}
}
console.log(new RideRequest("Mall", "Airport").describe());
console.log(new RideRequest("Mall", "Airport", ["Hotel"], "Gate 2", 1080).describe());
console.log(new RideRequest("Mall", "Please call at the gate", ["Hotel"], "Airport", 200).describe()); // drop and note swapped, and 03:20 is outside service hours: both acceptedMall -> Airport | now
Mall -> Airport via Hotel | note "Gate 2" | at 18:00
Mall -> Please call at the gate via Hotel | note "Airport" | at 03:20The first two lines are what the constructor was written for. The third is a real bug, and nothing rejects it: the destination and the note are both strings, so swapping them type-checks, and the request describes a ride to "Please call at the gate" at 03:20, 2 hours 40 minutes before service starts at 06:00. Minute 200 is 3 hours 20 minutes after midnight. The call site is also hard to read: what is the 1080?
- Calls are unreadable. Five positional values in a row tell the reader nothing. Add a sixth, a boolean "pet friendly", and
truemeans whatever you remember it meaning. - Swaps compile. Two parameters of the same type can be swapped with no error, as the third line shows.
- No place for rules. "Three stops at most" or "inside service hours" has no home, so it is checked in some callers and not others.
- Telescoping. Where a language has no default arguments, as Java does not, you write one constructor per combination (the book Effective Java calls this the telescoping constructor pattern), and the count grows with every optional field.
- Sharing. The request keeps the caller's own list. If the caller adds a stop afterwards, the request changes.
Which part is the problem?
The third line of the output is wrong, and no error was raised. Is the problem the long parameter list, the missing checks, or both? Which one would a builder fix, and could a constructor with named arguments fix it too?
One strong answer
Both, and they are different problems. The missing checks are a validation problem: rules that span fields need one place that runs every time, and a constructor can be that place. The long, readable-by-position list is a construction problem: callers need to say which value is which. A builder fixes the second by naming each step (.stop("Hotel"), .note("Gate 2")), and gives the first a natural home in build(). Keyword arguments fix the second without a new class, and the constructor can keep the checks. Keep that split in mind for the rest of the lesson.
The pattern#
The Design Patterns book (1994, Gamma, Helm, Johnson and Vlissides) states the intent of Builder as: "Separate the construction of a complex object from its representation so that the same construction process can create different representations."
That is the book's version: a director runs a fixed sequence of build steps against an abstract builder, and different concrete builders turn the same steps into different products (the book's example reads one document and builds plain text, TeX or an on-screen widget from it). In this lesson's terms, an airport_run(builder) director would call stop, note and at in a fixed order, and handing it a RideRequestBuilder would give a ride request while a FareQuoteBuilder would give a price quote from the same steps. The version most programmers mean today is a different one, popularised by Joshua Bloch in Effective Java: a fluent builder, an object you configure with chained calls and then ask for the finished, immutable product. It has the same shape (construction is separated from the finished object) and none of the director machinery. This lesson builds the fluent one, because it is what you will write and what interviewers draw.
| Role | What it does in the code |
|---|---|
| Product | RideRequest: immutable, valid by construction. In Java and C++ its constructor is private, so build() is the only way in |
| Builder | RideRequestBuilder: takes the required values in its constructor, offers one method per optional part, each returning the builder, and a final build() |
build() |
Checks every rule once, copies the builder's lists, and returns the product |
| Client | Chains the calls: builder("Mall", "Airport").stop("Hotel").note("Gate 2").at(1080).build() |
| Director (the book's version only) | Runs a fixed sequence of builder calls for a known recipe. Most fluent-builder code has none |
%% title: Builder: a builder collects, build() checks and returns the product
classDiagram
class RideRequestBuilder {
-pickup str
-drop str
-stops list
-note str
-scheduled_at int
+stop(place) RideRequestBuilder
+note(text) RideRequestBuilder
+at(minute) RideRequestBuilder
+build() RideRequest
}
class RideRequest {
+pickup str
+drop str
+stops tuple
+note str
+scheduled_at int
+describe() str
}
class Client
Client ..> RideRequestBuilder : chains calls
RideRequestBuilder ..> RideRequest : build() createsRead the diagram. The client talks to the builder, which only collects: its fields are private and mutable, and every method returns the builder so calls chain. build() is the single point that validates and creates the RideRequest, which has no setters. The product never exists half-built, and once it exists it cannot change.
A fluent builder only collects values. Its build method checks every rule once and returns an immutable product, so a half-built object never exists.
The code: a builder with validation and an immutable result#
The program below builds the ride request the long way. Three details carry the lesson: validation runs once, in build(); the product gets a copy of the builder's list; and the last lines reuse one builder to show why that copy matters.
# builder.py: a fluent builder; build() validates once and returns an immutable request
from dataclasses import dataclass
from typing import Callable, List, Optional, Tuple
OPEN, CLOSE = 360, 1320 # service hours 06:00 to 22:00, in minutes after midnight
def hhmm(minutes: int) -> str:
return f"{minutes // 60:02d}:{minutes % 60:02d}"
@dataclass(frozen=True)
class RideRequest:
pickup: str
drop: str
stops: Tuple[str, ...]
note: str
scheduled_at: Optional[int]
def describe(self) -> str:
text = f"{self.pickup} -> {self.drop}"
if self.stops:
text += " via " + ", ".join(self.stops)
if self.note:
text += f' | note "{self.note}"'
return text + (f" | at {hhmm(self.scheduled_at)}" if self.scheduled_at is not None else " | now")
class RideRequestBuilder:
def __init__(self, pickup: str, drop: str) -> None: # the required values go in the constructor
self._pickup, self._drop = pickup, drop
self._stops: List[str] = []
self._note = ""
self._scheduled_at: Optional[int] = None
def stop(self, place: str) -> "RideRequestBuilder":
self._stops.append(place)
return self
def note(self, text: str) -> "RideRequestBuilder":
self._note = text
return self
def at(self, minute: int) -> "RideRequestBuilder":
self._scheduled_at = minute
return self
def build(self) -> RideRequest: # every rule is checked here, once
if self._pickup == self._drop:
raise ValueError(f"pickup and drop are both {self._pickup}")
if len(self._stops) > 3:
raise ValueError(f"{len(self._stops)} stops, at most 3 allowed")
if len(self._note) > 40:
raise ValueError(f"note is {len(self._note)} characters, at most 40 allowed")
if self._scheduled_at is not None and not OPEN <= self._scheduled_at <= CLOSE:
raise ValueError(f"{hhmm(self._scheduled_at)} is outside service hours {hhmm(OPEN)}-{hhmm(CLOSE)}")
return RideRequest(self._pickup, self._drop, tuple(self._stops), self._note, self._scheduled_at) # tuple(...) is a copy
def attempt(make: Callable[[], RideRequest]) -> None:
try:
print("ok:", make().describe())
except ValueError as e:
print("error:", e)
attempt(lambda: RideRequestBuilder("Mall", "Airport").stop("Hotel").note("Gate 2").at(1080).build())
attempt(lambda: RideRequestBuilder("Mall", "Airport").build())
attempt(lambda: RideRequestBuilder("Mall", "Airport").stop("A").stop("B").stop("C").stop("D").build())
attempt(lambda: RideRequestBuilder("Mall", "Airport").note("x" * 45).build())
attempt(lambda: RideRequestBuilder("Mall", "Airport").at(210).build())
attempt(lambda: RideRequestBuilder("Mall", "Mall").build())
builder = RideRequestBuilder("Mall", "Airport").stop("Hotel")
first = builder.build()
builder.stop("Cafe") # keep using the same builder after build()
second = builder.build()
print(f"stops in first: {len(first.stops)}, stops in second: {len(second.stops)}")// Builder.java: a fluent builder; build() validates once and returns an immutable request
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
class BuilderDemo {
static final int OPEN = 360, CLOSE = 1320; // service hours 06:00 to 22:00, in minutes after midnight
static String hhmm(int minutes) { return String.format("%02d:%02d", minutes / 60, minutes % 60); }
static final class RideRequest {
final String pickup, drop, note;
final List<String> stops;
final Integer scheduledAt;
private RideRequest(String pickup, String drop, List<String> stops, String note, Integer scheduledAt) { // private: build() is the only way in
this.pickup = pickup;
this.drop = drop;
this.stops = stops;
this.note = note;
this.scheduledAt = scheduledAt;
}
String describe() {
StringBuilder text = new StringBuilder(pickup + " -> " + drop);
if (!stops.isEmpty()) text.append(" via ").append(String.join(", ", stops));
if (!note.isEmpty()) text.append(" | note \"").append(note).append("\"");
return text + (scheduledAt != null ? " | at " + hhmm(scheduledAt) : " | now");
}
static final class Builder {
private final String pickup, drop; // the required values go in the constructor
private final List<String> stops = new ArrayList<>();
private String note = "";
private Integer scheduledAt = null;
Builder(String pickup, String drop) {
this.pickup = pickup;
this.drop = drop;
}
Builder stop(String place) { stops.add(place); return this; }
Builder note(String text) { note = text; return this; }
Builder at(int minute) { scheduledAt = minute; return this; }
RideRequest build() { // every rule is checked here, once
if (pickup.equals(drop)) throw new IllegalArgumentException("pickup and drop are both " + pickup);
if (stops.size() > 3) throw new IllegalArgumentException(stops.size() + " stops, at most 3 allowed");
if (note.length() > 40) throw new IllegalArgumentException("note is " + note.length() + " characters, at most 40 allowed");
if (scheduledAt != null && (scheduledAt < OPEN || scheduledAt > CLOSE)) {
throw new IllegalArgumentException(hhmm(scheduledAt) + " is outside service hours " + hhmm(OPEN) + "-" + hhmm(CLOSE));
}
return new RideRequest(pickup, drop, List.copyOf(stops), note, scheduledAt); // List.copyOf is an unmodifiable copy
}
}
}
static void attempt(Supplier<RideRequest> make) {
try {
System.out.println("ok: " + make.get().describe());
} catch (IllegalArgumentException e) {
System.out.println("error: " + e.getMessage());
}
}
public static void main(String[] args) {
attempt(() -> new RideRequest.Builder("Mall", "Airport").stop("Hotel").note("Gate 2").at(1080).build());
attempt(() -> new RideRequest.Builder("Mall", "Airport").build());
attempt(() -> new RideRequest.Builder("Mall", "Airport").stop("A").stop("B").stop("C").stop("D").build());
attempt(() -> new RideRequest.Builder("Mall", "Airport").note("x".repeat(45)).build());
attempt(() -> new RideRequest.Builder("Mall", "Airport").at(210).build());
attempt(() -> new RideRequest.Builder("Mall", "Mall").build());
RideRequest.Builder builder = new RideRequest.Builder("Mall", "Airport").stop("Hotel");
RideRequest first = builder.build();
builder.stop("Cafe"); // keep using the same builder after build()
RideRequest second = builder.build();
System.out.println("stops in first: " + first.stops.size() + ", stops in second: " + second.stops.size());
}
}// builder.cpp: a fluent builder; build() validates once and returns an immutable request
#include <cstdio>
#include <functional>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
constexpr int OPEN = 360, CLOSE = 1320; // service hours 06:00 to 22:00, in minutes after midnight
std::string hhmm(int minutes) {
char text[16];
std::snprintf(text, sizeof text, "%02d:%02d", minutes / 60, minutes % 60);
return text;
}
class RideRequest {
const std::string pickup_, drop_, note_;
const std::vector<std::string> stops_;
const std::optional<int> scheduled_at_;
RideRequest(std::string pickup, std::string drop, std::vector<std::string> stops, std::string note, std::optional<int> scheduled_at)
: pickup_(std::move(pickup)), drop_(std::move(drop)), note_(std::move(note)), stops_(std::move(stops)), scheduled_at_(scheduled_at) {} // private: build() is the only way in
public:
class Builder {
std::string pickup_, drop_, note_; // the required values go in the constructor
std::vector<std::string> stops_;
std::optional<int> scheduled_at_;
public:
Builder(std::string pickup, std::string drop) : pickup_(std::move(pickup)), drop_(std::move(drop)) {}
Builder& stop(const std::string& place) { stops_.push_back(place); return *this; }
Builder& note(const std::string& text) { note_ = text; return *this; }
Builder& at(int minute) { scheduled_at_ = minute; return *this; }
RideRequest build() const { // every rule is checked here, once
if (pickup_ == drop_) throw std::invalid_argument("pickup and drop are both " + pickup_);
if (stops_.size() > 3) throw std::invalid_argument(std::to_string(stops_.size()) + " stops, at most 3 allowed");
if (note_.size() > 40) throw std::invalid_argument("note is " + std::to_string(note_.size()) + " characters, at most 40 allowed");
if (scheduled_at_ && (*scheduled_at_ < OPEN || *scheduled_at_ > CLOSE)) {
throw std::invalid_argument(hhmm(*scheduled_at_) + " is outside service hours " + hhmm(OPEN) + "-" + hhmm(CLOSE));
}
return RideRequest(pickup_, drop_, stops_, note_, scheduled_at_); // the vector is copied into the request
}
};
std::size_t stop_count() const { return stops_.size(); }
std::string describe() const {
std::string text = pickup_ + " -> " + drop_;
if (!stops_.empty()) {
text += " via ";
for (std::size_t i = 0; i < stops_.size(); ++i) text += (i ? ", " : "") + stops_[i];
}
if (!note_.empty()) text += " | note \"" + note_ + "\"";
return text + (scheduled_at_ ? " | at " + hhmm(*scheduled_at_) : std::string(" | now"));
}
};
void attempt(const std::function<RideRequest()>& make) {
try {
std::string line = make().describe(); // build first: if it throws, nothing has been printed yet
std::cout << "ok: " << line << "\n";
} catch (const std::invalid_argument& e) {
std::cout << "error: " << e.what() << "\n";
}
}
int main() {
attempt([] { return RideRequest::Builder("Mall", "Airport").stop("Hotel").note("Gate 2").at(1080).build(); });
attempt([] { return RideRequest::Builder("Mall", "Airport").build(); });
attempt([] { return RideRequest::Builder("Mall", "Airport").stop("A").stop("B").stop("C").stop("D").build(); });
attempt([] { return RideRequest::Builder("Mall", "Airport").note(std::string(45, 'x')).build(); });
attempt([] { return RideRequest::Builder("Mall", "Airport").at(210).build(); });
attempt([] { return RideRequest::Builder("Mall", "Mall").build(); });
RideRequest::Builder builder("Mall", "Airport");
builder.stop("Hotel");
RideRequest first = builder.build();
builder.stop("Cafe"); // keep using the same builder after build()
RideRequest second = builder.build();
std::cout << "stops in first: " << first.stop_count() << ", stops in second: " << second.stop_count() << "\n";
}// builder.js: a fluent builder; build() validates once and returns an immutable request
const OPEN = 360, CLOSE = 1320; // service hours 06:00 to 22:00, in minutes after midnight
const hhmm = (minutes) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
class RideRequest {
constructor(pickup, drop, stops, note, scheduledAt) {
this.pickup = pickup;
this.drop = drop;
this.stops = Object.freeze([...stops]); // a frozen copy
this.note = note;
this.scheduledAt = scheduledAt;
Object.freeze(this);
}
describe() {
let text = `${this.pickup} -> ${this.drop}`;
if (this.stops.length > 0) text += ` via ${this.stops.join(", ")}`;
if (this.note) text += ` | note "${this.note}"`;
return text + (this.scheduledAt !== null ? ` | at ${hhmm(this.scheduledAt)}` : " | now");
}
}
class RideRequestBuilder {
#pickup;
#drop; // the required values go in the constructor
#stops = [];
#note = "";
#scheduledAt = null;
constructor(pickup, drop) {
this.#pickup = pickup;
this.#drop = drop;
}
stop(place) { this.#stops.push(place); return this; }
note(text) { this.#note = text; return this; }
at(minute) { this.#scheduledAt = minute; return this; }
build() { // every rule is checked here, once
if (this.#pickup === this.#drop) throw new Error(`pickup and drop are both ${this.#pickup}`);
if (this.#stops.length > 3) throw new Error(`${this.#stops.length} stops, at most 3 allowed`);
if (this.#note.length > 40) throw new Error(`note is ${this.#note.length} characters, at most 40 allowed`);
if (this.#scheduledAt !== null && (this.#scheduledAt < OPEN || this.#scheduledAt > CLOSE)) {
throw new Error(`${hhmm(this.#scheduledAt)} is outside service hours ${hhmm(OPEN)}-${hhmm(CLOSE)}`);
}
return new RideRequest(this.#pickup, this.#drop, this.#stops, this.#note, this.#scheduledAt);
}
}
function attempt(make) {
try {
console.log("ok:", make().describe());
} catch (e) {
console.log("error:", e.message);
}
}
attempt(() => new RideRequestBuilder("Mall", "Airport").stop("Hotel").note("Gate 2").at(1080).build());
attempt(() => new RideRequestBuilder("Mall", "Airport").build());
attempt(() => new RideRequestBuilder("Mall", "Airport").stop("A").stop("B").stop("C").stop("D").build());
attempt(() => new RideRequestBuilder("Mall", "Airport").note("x".repeat(45)).build());
attempt(() => new RideRequestBuilder("Mall", "Airport").at(210).build());
attempt(() => new RideRequestBuilder("Mall", "Mall").build());
const builder = new RideRequestBuilder("Mall", "Airport").stop("Hotel");
const first = builder.build();
builder.stop("Cafe"); // keep using the same builder after build()
const second = builder.build();
console.log(`stops in first: ${first.stops.length}, stops in second: ${second.stops.length}`);ok: Mall -> Airport via Hotel | note "Gate 2" | at 18:00
ok: Mall -> Airport | now
error: 4 stops, at most 3 allowed
error: note is 45 characters, at most 40 allowed
error: 03:30 is outside service hours 06:00-22:00
error: pickup and drop are both Mall
stops in first: 1, stops in second: 2Read the output against the rules. Line 1 is valid: 18:00 is minute 1,080, inside 06:00 (360) to 22:00 (1,320). Line 3 has four stops, line 4 a 45-character note, line 5 a 03:30 time (minute 210), and line 6 the same pickup and drop: each is rejected by build() with a message that names the rule. The last line is the reuse test: one builder, built twice, with a stop added in between. The first request still has 1 stop and the second has 2, because build() gave each request its own copy of the list. Without the copy (tuple(...), List.copyOf, a vector copy, [...stops]), the first request would have grown to 2 stops, the same aliasing bug the first version had.
What each language brings:
| Language | What to know |
|---|---|
| Python | frozen=True makes the fields read-only, and a tuple makes the stops read-only too. Nothing stops code from calling RideRequest(...) directly and skipping build(), because Python has no private constructors. If the rules matter more than the pattern, put them in __post_init__ (the next section does) |
| Java | A private product constructor plus a nested Builder class means build() is the only way in. List.copyOf returns an unmodifiable copy. Java has no default or named arguments, so a builder (or a record with withers) is the standard answer to many optional parameters |
| C++ | A nested Builder can call the private constructor, and const members make the request immutable. Copy the vector in build(). C++20 designated initialisers and a plain options struct cover many cases a builder would in C++17 |
| JavaScript | Object.freeze is shallow, so freeze the array too, as above. JavaScript has no private constructors, so the same caveat as Python applies. #private fields keep the builder's state out of reach |
Build must give each request its own copy of the builder's list. Without it, adding a stop to a reused builder changes a request you already built.
Do you need a Builder? Keyword arguments#
Everything the builder did, except the chaining, can be done with the language's own argument syntax. In Python the constructor takes keyword arguments with defaults, in JavaScript an options object, in Java a record with "wither" methods, and in C++17 a small options struct. Here is the same ride request, with the same rules, in the shape each language prefers:
# no_builder.py: keyword arguments with defaults; the constructor validates, and replace() makes a changed copy
from dataclasses import dataclass, replace
from typing import Callable, Optional, Tuple
OPEN, CLOSE = 360, 1320 # service hours 06:00 to 22:00, in minutes after midnight
def hhmm(minutes: int) -> str:
return f"{minutes // 60:02d}:{minutes % 60:02d}"
@dataclass(frozen=True)
class RideRequest:
pickup: str
drop: str
stops: Tuple[str, ...] = ()
note: str = ""
scheduled_at: Optional[int] = None
def __post_init__(self) -> None: # every way of making a RideRequest goes through these checks
object.__setattr__(self, "stops", tuple(self.stops)) # a copy, so a caller's list can't change it later
if self.pickup == self.drop:
raise ValueError(f"pickup and drop are both {self.pickup}")
if len(self.stops) > 3:
raise ValueError(f"{len(self.stops)} stops, at most 3 allowed")
if len(self.note) > 40:
raise ValueError(f"note is {len(self.note)} characters, at most 40 allowed")
if self.scheduled_at is not None and not OPEN <= self.scheduled_at <= CLOSE:
raise ValueError(f"{hhmm(self.scheduled_at)} is outside service hours {hhmm(OPEN)}-{hhmm(CLOSE)}")
def describe(self) -> str:
text = f"{self.pickup} -> {self.drop}"
if self.stops:
text += " via " + ", ".join(self.stops)
if self.note:
text += f' | note "{self.note}"'
return text + (f" | at {hhmm(self.scheduled_at)}" if self.scheduled_at is not None else " | now")
def attempt(make: Callable[[], RideRequest]) -> None:
try:
print("ok:", make().describe())
except ValueError as e:
print("error:", e)
attempt(lambda: RideRequest("Mall", "Airport", stops=["Hotel"], note="Gate 2", scheduled_at=1080))
attempt(lambda: RideRequest("Mall", "Airport"))
attempt(lambda: RideRequest("Mall", "Airport", stops=["A", "B", "C", "D"]))
attempt(lambda: RideRequest("Mall", "Airport", note="x" * 45))
attempt(lambda: RideRequest("Mall", "Airport", scheduled_at=210))
attempt(lambda: RideRequest("Mall", "Mall"))
original = RideRequest("Mall", "Airport", stops=["Hotel"], note="Gate 2", scheduled_at=1080)
print("changed copy:", replace(original, note="Door 4").describe()) # replace() runs __post_init__ again
print("original:", original.describe())// NoBuilder.java: an immutable record validates in its constructor; withers return changed copies
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
class NoBuilder {
static final int OPEN = 360, CLOSE = 1320; // service hours 06:00 to 22:00, in minutes after midnight
static String hhmm(int minutes) { return String.format("%02d:%02d", minutes / 60, minutes % 60); }
record RideRequest(String pickup, String drop, List<String> stops, String note, Integer scheduledAt) {
RideRequest { // the compact constructor: every way of making a RideRequest goes through these checks
stops = List.copyOf(stops); // an unmodifiable copy
if (pickup.equals(drop)) throw new IllegalArgumentException("pickup and drop are both " + pickup);
if (stops.size() > 3) throw new IllegalArgumentException(stops.size() + " stops, at most 3 allowed");
if (note.length() > 40) throw new IllegalArgumentException("note is " + note.length() + " characters, at most 40 allowed");
if (scheduledAt != null && (scheduledAt < OPEN || scheduledAt > CLOSE)) {
throw new IllegalArgumentException(hhmm(scheduledAt) + " is outside service hours " + hhmm(OPEN) + "-" + hhmm(CLOSE));
}
}
static RideRequest between(String pickup, String drop) { return new RideRequest(pickup, drop, List.of(), "", null); }
RideRequest withStop(String place) { // a wither: a changed copy, checked again by the constructor
List<String> more = new ArrayList<>(stops);
more.add(place);
return new RideRequest(pickup, drop, more, note, scheduledAt);
}
RideRequest withNote(String text) { return new RideRequest(pickup, drop, stops, text, scheduledAt); }
RideRequest at(int minute) { return new RideRequest(pickup, drop, stops, note, minute); }
String describe() {
StringBuilder text = new StringBuilder(pickup + " -> " + drop);
if (!stops.isEmpty()) text.append(" via ").append(String.join(", ", stops));
if (!note.isEmpty()) text.append(" | note \"").append(note).append("\"");
return text + (scheduledAt != null ? " | at " + hhmm(scheduledAt) : " | now");
}
}
static void attempt(Supplier<RideRequest> make) {
try {
System.out.println("ok: " + make.get().describe());
} catch (IllegalArgumentException e) {
System.out.println("error: " + e.getMessage());
}
}
public static void main(String[] args) {
attempt(() -> RideRequest.between("Mall", "Airport").withStop("Hotel").withNote("Gate 2").at(1080));
attempt(() -> RideRequest.between("Mall", "Airport"));
attempt(() -> RideRequest.between("Mall", "Airport").withStop("A").withStop("B").withStop("C").withStop("D"));
attempt(() -> RideRequest.between("Mall", "Airport").withNote("x".repeat(45)));
attempt(() -> RideRequest.between("Mall", "Airport").at(210));
attempt(() -> RideRequest.between("Mall", "Mall"));
RideRequest original = RideRequest.between("Mall", "Airport").withStop("Hotel").withNote("Gate 2").at(1080);
System.out.println("changed copy: " + original.withNote("Door 4").describe());
System.out.println("original: " + original.describe());
}
}// no_builder.cpp: an options struct (C++17 has no named arguments); the constructor validates, with_note() makes a changed copy
#include <cstdio>
#include <functional>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
constexpr int OPEN = 360, CLOSE = 1320; // service hours 06:00 to 22:00, in minutes after midnight
std::string hhmm(int minutes) {
char text[16];
std::snprintf(text, sizeof text, "%02d:%02d", minutes / 60, minutes % 60);
return text;
}
struct RideOptions { // optional parts with defaults; set only the fields you need (C++20 adds designated initialisers for this)
std::vector<std::string> stops;
std::string note;
std::optional<int> scheduled_at;
};
class RideRequest {
const std::string pickup_, drop_;
const RideOptions options_; // held by value: a copy
public:
RideRequest(std::string pickup, std::string drop, RideOptions options = {})
: pickup_(std::move(pickup)), drop_(std::move(drop)), options_(std::move(options)) {
// every way of making a RideRequest goes through these checks
if (pickup_ == drop_) throw std::invalid_argument("pickup and drop are both " + pickup_);
if (options_.stops.size() > 3) throw std::invalid_argument(std::to_string(options_.stops.size()) + " stops, at most 3 allowed");
if (options_.note.size() > 40) throw std::invalid_argument("note is " + std::to_string(options_.note.size()) + " characters, at most 40 allowed");
if (options_.scheduled_at && (*options_.scheduled_at < OPEN || *options_.scheduled_at > CLOSE)) {
throw std::invalid_argument(hhmm(*options_.scheduled_at) + " is outside service hours " + hhmm(OPEN) + "-" + hhmm(CLOSE));
}
}
RideRequest with_note(const std::string& text) const { // a changed copy, checked again by the constructor
RideOptions changed = options_;
changed.note = text;
return RideRequest(pickup_, drop_, changed);
}
std::string describe() const {
std::string text = pickup_ + " -> " + drop_;
if (!options_.stops.empty()) {
text += " via ";
for (std::size_t i = 0; i < options_.stops.size(); ++i) text += (i ? ", " : "") + options_.stops[i];
}
if (!options_.note.empty()) text += " | note \"" + options_.note + "\"";
return text + (options_.scheduled_at ? " | at " + hhmm(*options_.scheduled_at) : std::string(" | now"));
}
};
void attempt(const std::function<RideRequest()>& make) {
try {
std::string line = make().describe(); // build first: if it throws, nothing has been printed yet
std::cout << "ok: " << line << "\n";
} catch (const std::invalid_argument& e) {
std::cout << "error: " << e.what() << "\n";
}
}
int main() {
RideOptions full;
full.stops = {"Hotel"};
full.note = "Gate 2";
full.scheduled_at = 1080;
attempt([&] { return RideRequest("Mall", "Airport", full); });
attempt([] { return RideRequest("Mall", "Airport"); });
attempt([] { RideOptions o; o.stops = {"A", "B", "C", "D"}; return RideRequest("Mall", "Airport", o); });
attempt([] { RideOptions o; o.note = std::string(45, 'x'); return RideRequest("Mall", "Airport", o); });
attempt([] { RideOptions o; o.scheduled_at = 210; return RideRequest("Mall", "Airport", o); });
attempt([] { return RideRequest("Mall", "Mall"); });
RideRequest original("Mall", "Airport", full);
std::cout << "changed copy: " << original.with_note("Door 4").describe() << "\n";
std::cout << "original: " << original.describe() << "\n";
}// noBuilder.js: an options object with defaults; the constructor validates, and withNote() makes a changed copy
const OPEN = 360, CLOSE = 1320; // service hours 06:00 to 22:00, in minutes after midnight
const hhmm = (minutes) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
class RideRequest {
constructor(pickup, drop, { stops = [], note = "", scheduledAt = null } = {}) {
// every way of making a RideRequest goes through these checks
if (pickup === drop) throw new Error(`pickup and drop are both ${pickup}`);
if (stops.length > 3) throw new Error(`${stops.length} stops, at most 3 allowed`);
if (note.length > 40) throw new Error(`note is ${note.length} characters, at most 40 allowed`);
if (scheduledAt !== null && (scheduledAt < OPEN || scheduledAt > CLOSE)) {
throw new Error(`${hhmm(scheduledAt)} is outside service hours ${hhmm(OPEN)}-${hhmm(CLOSE)}`);
}
this.pickup = pickup;
this.drop = drop;
this.stops = Object.freeze([...stops]); // a frozen copy
this.note = note;
this.scheduledAt = scheduledAt;
Object.freeze(this);
}
withNote(note) { // a changed copy, checked again by the constructor
return new RideRequest(this.pickup, this.drop, { stops: this.stops, note, scheduledAt: this.scheduledAt });
}
describe() {
let text = `${this.pickup} -> ${this.drop}`;
if (this.stops.length > 0) text += ` via ${this.stops.join(", ")}`;
if (this.note) text += ` | note "${this.note}"`;
return text + (this.scheduledAt !== null ? ` | at ${hhmm(this.scheduledAt)}` : " | now");
}
}
function attempt(make) {
try {
console.log("ok:", make().describe());
} catch (e) {
console.log("error:", e.message);
}
}
attempt(() => new RideRequest("Mall", "Airport", { stops: ["Hotel"], note: "Gate 2", scheduledAt: 1080 }));
attempt(() => new RideRequest("Mall", "Airport"));
attempt(() => new RideRequest("Mall", "Airport", { stops: ["A", "B", "C", "D"] }));
attempt(() => new RideRequest("Mall", "Airport", { note: "x".repeat(45) }));
attempt(() => new RideRequest("Mall", "Airport", { scheduledAt: 210 }));
attempt(() => new RideRequest("Mall", "Mall"));
const original = new RideRequest("Mall", "Airport", { stops: ["Hotel"], note: "Gate 2", scheduledAt: 1080 });
console.log("changed copy:", original.withNote("Door 4").describe());
console.log("original:", original.describe());ok: Mall -> Airport via Hotel | note "Gate 2" | at 18:00
ok: Mall -> Airport | now
error: 4 stops, at most 3 allowed
error: note is 45 characters, at most 40 allowed
error: 03:30 is outside service hours 06:00-22:00
error: pickup and drop are both Mall
changed copy: Mall -> Airport via Hotel | note "Door 4" | at 18:00
original: Mall -> Airport via Hotel | note "Gate 2" | at 18:00The first six lines are the same as the builder's output, from a design with no Builder class. Every optional value is named at the call site (note="Gate 2", { note: "Gate 2" }), so the swapped-arguments bug from the first version is much harder to write (Python 3.10 adds kw_only=True to forbid positional use altogether, and a JavaScript options object has no order; a Java record still has its positional constructor, and the withers, written by hand, are what name each value), and the rules run in the constructor, so they run no matter how the object is made, which the builder version could not promise in Python or JavaScript. The last two lines show the other half of immutability: a changed copy (replace, a wither, a copy of the options) leaves the original alone. The next lesson, on Prototype, is about copying done properly.
So when is a Builder still the right answer?
Keyword or options arguments are enough when
- The language has named arguments with defaults (Python, Kotlin, Swift, C#), or an object can stand in for them (a JavaScript options object, a C++ options struct)
- Every part can be supplied in one call, in any order
- The rules can be checked in the constructor
- The number of optional parts is a handful
A Builder earns its keep when
- The language has no named arguments and many optional parts: classic Java, C++17 (though a record with withers or an options struct often does)
- The object is assembled over time, in steps, from different places, and cannot exist half-built
- Different representations come from the same steps (the book's version, with a director)
- The builder is reused as a template: configure once,
build()many times, with a change in between
Two required fields
A teammate wants a CouponBuilder for a Coupon that has two fields, a code and a percentage, both required. What do you say?
One strong answer
A constructor is enough. A builder pays off when there are optional parts, steps or several representations. Two required values have no optional parts to name and no steps to take, so a builder is extra code that makes Coupon("SAVE10", 10) into five lines. Put the rule (the percentage between 1 and 90, say) in the constructor, as the hotel's Stay does, and move on.
A builder for a class with two required fields is more code for no gain. A constructor that checks the rule is clearer, and keeps both values required.
Builder and its look-alikes#
| Idea | What it is | How to tell it from Builder |
|---|---|---|
| Telescoping constructors | One constructor per combination of optional parts | The problem Builder solves in languages with no default arguments. It grows with every new option |
| JavaBeans style (a no-argument constructor, then setters) | Create the object, then set fields one at a time | The object is mutable and can be half-built, and a rule across fields has no place to run. A builder keeps the mutable part in the builder and returns an immutable product |
| Named or keyword arguments | The language's way to name optional values in one call | Does the same job for many cases with no extra class. A builder adds steps and reuse |
| Parameter object / options object | A small class or struct that carries the arguments | A builder is a parameter object plus chained methods and a build() that validates. The C++ options struct above is this |
| Fluent interface | Method chaining that reads like a sentence | A style, not a pattern. A builder usually uses it, but a fluent API such as a query DSL need not build one object |
| Factory | Returns a finished object from one call, usually choosing which class | A builder assembles one object over many calls. A builder's build() may even use a factory |
| Prototype | Copy an existing object, then change it | An immutable object's "changed copy" (replace, a wither) is Prototype-like. A builder starts from nothing |
A string builder (StringBuilder, list then join) |
A mutable accumulator that produces one immutable value | The same shape for one value type, and the best-known builder in practice. It is about efficiency more than validation |
A library builder (Java's HttpRequest.newBuilder()) |
A builder from the standard library, with chained calls and a build() |
The fluent builder of this lesson, used by an API with many optional settings and no named arguments |
When not to use it#
- In Python and JavaScript, as a reflex. Keyword arguments, defaults and an options object already name each value. A
Builderclass there is usually ceremony, unless you need steps or reuse. - For two or three required fields. A constructor reads better, and it keeps required values required.
- When the builder's setters mirror the product's fields one for one. You have written the same class twice. If nothing is validated or assembled, drop it.
- A builder that validates in each setter. A rule that spans fields can't run until all the values are in, so check in
build(). - Forgetting that
build()must copy. Handing the builder's own list to the product lets a reused builder change a finished request. - Making a mutable product "for convenience". The point of the pattern is a valid, immutable result. Add setters and you have the JavaBeans style back.
- A Director nobody needs. The book's director earns its place when there is a recipe shared by several builders. A single fluent builder needs none.
In Python and JavaScript, named arguments already say which value is which. A Builder whose methods mirror the fields is the same class written twice.
Where it shows up in the case studies#
None of the 22 case studies uses Builder by name, and that is informative: they are built from small values and services made with plain constructors, which is the right default. Two lessons are the nearest neighbours:
| Case study | What | How |
|---|---|---|
| Hotel reservation | A value that validates itself, no Builder | Stay is a frozen dataclass with two required dates, and its __post_init__ raises if check-out is not after check-in. Two required fields, a rule in the constructor: the answer to the checkpoint above. A booking that grew many optional parts (extras, a guest note, a promo code) would be where keyword arguments, or a builder, earn their place |
| Meeting room scheduler | A small immutable value, no Builder | Recurrence(every, count) is two values that expand into slots. A plain constructor is right. A meeting object that grew attendees, a room and a recurrence is the kind that could use keyword arguments first, and a builder only if it had to be assembled in steps |
In the interview#
Say it with the alternative: "I would reach for keyword arguments or an options object first, with the validation in the constructor. I would use a Builder when the language has no named arguments and the object has many optional parts, when it is assembled in steps and cannot exist half-built, or when I need to reuse a builder as a template, and I would put every cross-field rule in build() and copy the lists so the result is immutable."
Then expect the follow-ups:
- "What is a telescoping constructor?" One constructor per combination of optional parameters, each calling the next, so the count grows with each option and the call sites are positional.
- "Builder versus a factory?" A factory returns a finished object from one call. A builder assembles one object over many calls and checks it when asked to build.
- "Why is the product immutable, and why copy the list?" An immutable, valid-by-construction object is safe to share and cache. If
build()hands over the builder's own list, a reused builder changes finished objects. - "Where do you validate?" In
build()for cross-field rules, because setters see one value at a time. If code can bypass the builder (Python, JavaScript), also validate in the constructor. - "Is method chaining the Builder pattern?" No. Chaining is a fluent style. The pattern is the separation: a mutable collector, and an immutable product made by
build(). - "When would you not use one?" Few required fields, a language with named arguments, or no steps to take.
- "What is the Director in the book's version?" The object that runs a fixed sequence of builder steps, so several concrete builders can produce different representations from the same recipe.
Common mistakes#
- Building a builder for a class with two required fields, or in Python and JavaScript with no reason beyond the pattern's name.
- Putting the cross-field rules in the setters, where they cannot see the other values.
- Sharing the builder's list with the product, so a reused builder mutates a finished object.
- A mutable product with setters, which puts the half-built, changeable object back.
- Calling method chaining "the Builder pattern".
- Making optional parts required by putting them all in the builder's constructor, so it is the long constructor again.
- Forgetting that Python and JavaScript cannot hide the product's constructor, and not validating there too.
- A Director with one builder.
Builder
Q1/4 A booking flow sets the pickup with from_place() on one screen and the drop with to_place() on another, in either order. Where should a builder check "pickup and drop must differ"?
Answer & explanation
Answer: B
The rule needs both values, and either setter may run first, so neither setter can be sure the other value is there. build() is the single place every request passes through with every value in. describe() runs too late.
Q2/4 One builder builds a request, a stop is added, and build() is called again. The first request now has 2 stops. What went wrong?
Answer & explanation
Answer: C
The product must get its own copy of the list (a tuple, List.copyOf, a copied vector, a spread array). Otherwise a reused builder changes finished requests, which defeats an immutable result.
Q3/4 In Python, a class has a required code and a required percentage, and nothing else. What is the better design?
Answer & explanation
Answer: B
With two required values there is nothing optional to name and no step to take, so a builder is extra code. A constructor can hold the rule, as the hotel lesson's Stay does.
Q4/4 Which statement about the Builder pattern is right?
Answer & explanation
Answer: C
Chaining is only a style, and setters on the product would undo the pattern. Many languages can name optional arguments directly, so a builder is a choice, not a requirement.
this lesson in shorts · 3
play them all →One idea from this lesson in about a minute each, made for your phone.
New lessons by email
New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.
Discussion · …
Public: everyone reading this lesson sees comments. Be kind and keep it on the material.


