Builder
A builder assembles a complex object one named step at a time and only hands it over, checked and finished, at the end. This page builds Pedal's ride receipts that way, enforces "discounts before VAT", and shows when Python's keyword arguments make a builder unnecessary.
1. The pain
Every Pedal ride ends with a receipt. Most have two lines; some have six: an e-bike surcharge, a promo discount, VAT, and a round-up donated to charity. The Receipt constructor grew one parameter per optional line, most of them None on any given call.
Two of those parameters are percentages next to each other, so swapping them compiles and runs and charges the wrong amount. And there are rules a constructor can't express well: discounts must be applied before VAT, and a receipt without a ride line is meaningless.
2. The idea in one picture
Builder separates putting an object together from the object itself. A builder has one named method per part, each returning the builder so the calls chain, and a final build() that checks the rules and returns the finished object. The product can then be immutable and always valid, because nobody ever holds a half-built one.
The original pattern also has a director: an object that knows a standard recipe ("a member receipt") and drives any builder through it, so the same steps can produce different outputs, such as plain text or an HTML email. Most modern builders, including this page's, skip the director and let the caller chain the steps.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Builder | Offers one method per part; collects them; checks the rules in build(). | ReceiptBuilder |
| Product | The finished, immutable result. | Receipt |
| Client | Chains the parts it needs and calls build(). | The checkout code at the end of a ride |
| Director (optional) | Runs a standard sequence of builder steps. | Not used here; a member_receipt(builder) function would be one |
4. Before and after
The same receipt made both ways (Python sketch; the full program is in section 5).
Without the pattern
receipt = Receipt(
"R-882", 280, # ride
None, # e-bike minutes
"SPRING10",
20, # discount %?
10, # VAT %? swapped!
True, # round up
)With it
receipt = (ReceiptBuilder("R-882")
.ride(280)
.discount("SPRING10", 10)
.vat(20)
.round_up_for_charity()
.build()) # checks the rules| The next change | Without the pattern | With it |
|---|---|---|
| Add a "carbon saved" line | A new parameter: every call site changes, or another default joins the list | A new carbon() method; existing calls are untouched |
| Enforce "discount before VAT" | Hard: the constructor sees all values at once, not their order | discount() refuses once VAT is on |
| Read a call six months later | Count commas to find which number is VAT | Every value sits next to its name |
In Python and TypeScript, keyword arguments or an options object solve the first and third problems. The builder earns its place when order or validation across parts matters, as with VAT here.
5. The code
A ReceiptBuilder with one method per optional line and a build() that checks the rules, producing an immutable Receipt. The last two calls break a rule each and are refused.
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Receipt:
"""The finished product: immutable, always valid."""
ride_id: str
heading: str
lines: tuple[tuple[str, int], ...]
def total(self) -> int:
return sum(p for _, p in self.lines)
def render(self) -> str:
rows = [f"Receipt {self.ride_id} ({self.heading})"]
rows += [f" {name:<20}{pence:>5}p" for name, pence in self.lines]
rows.append(f" {'total':<20}{self.total():>5}p")
return "\n".join(rows)
class ReceiptBuilder:
"""Collects the optional parts step by step and checks the rules once, in build()."""
def __init__(self, ride_id: str, heading: str) -> None:
self.ride_id, self.heading = ride_id, heading
self.lines: list[tuple[str, int]] = []
self.has_ride = self.has_vat = False
def _subtotal(self) -> int:
return sum(p for _, p in self.lines)
def ride(self, pence: int) -> ReceiptBuilder:
self.lines.append(("ride", pence))
self.has_ride = True
return self
def ebike(self, minutes: int) -> ReceiptBuilder:
self.lines.append(("e-bike", minutes * 5))
return self
def discount(self, code: str, percent: int) -> ReceiptBuilder:
if self.has_vat:
raise ValueError("VAT must be added after every discount")
self.lines.append((f"{code} {percent}% off", -(self._subtotal() * percent // 100)))
return self
def vat(self, percent: int) -> ReceiptBuilder:
self.lines.append((f"VAT {percent}%", self._subtotal() * percent // 100))
self.has_vat = True
return self
def round_up_for_charity(self) -> ReceiptBuilder:
self.lines.append(("round-up (charity)", -self._subtotal() % 100))
return self
def build(self) -> Receipt:
if not self.has_ride:
raise ValueError("a receipt needs a ride line")
return Receipt(self.ride_id, self.heading, tuple(self.lines))
if __name__ == "__main__":
member = ReceiptBuilder("R-881", "member, 42 min").ride(120).ebike(42).vat(20).build()
print(member.render())
payg = ReceiptBuilder("R-882", "pay-as-you-go, 18 min").ride(280).discount("SPRING10", 10).round_up_for_charity().build()
print(payg.render())
for attempt in (lambda: ReceiptBuilder("R-883", "member").ride(90).vat(20).discount("SPRING10", 10),
lambda: ReceiptBuilder("R-884", "member").vat(20).build()):
try:
attempt()
except ValueError as e:
print(f"rejected: {e}")
type Line = [string, number];
// The finished product: immutable, always valid.
class Receipt {
constructor(readonly rideId: string, readonly heading: string, readonly lines: readonly Line[]) {}
total(): number { return this.lines.reduce((s, [, p]) => s + p, 0); }
render(): string {
const row = (name: string, pence: number) => ` ${name.padEnd(20)}${String(pence).padStart(5)}p`;
return [`Receipt ${this.rideId} (${this.heading})`, ...this.lines.map(([n, p]) => row(n, p)), row("total", this.total())].join("\n");
}
}
// Collects the optional parts step by step and checks the rules once, in build().
class ReceiptBuilder {
private readonly lines: Line[] = [];
private hasRide = false;
private hasVat = false;
constructor(private readonly rideId: string, private readonly heading: string) {}
private subtotal(): number { return this.lines.reduce((s, [, p]) => s + p, 0); }
ride(pence: number): this { this.lines.push(["ride", pence]); this.hasRide = true; return this; }
ebike(minutes: number): this { this.lines.push(["e-bike", minutes * 5]); return this; }
discount(code: string, percent: number): this {
if (this.hasVat) throw new Error("VAT must be added after every discount");
this.lines.push([`${code} ${percent}% off`, -Math.floor(this.subtotal() * percent / 100)]);
return this;
}
vat(percent: number): this { this.lines.push([`VAT ${percent}%`, Math.floor(this.subtotal() * percent / 100)]); this.hasVat = true; return this; }
roundUpForCharity(): this { this.lines.push(["round-up (charity)", (100 - this.subtotal() % 100) % 100]); return this; }
build(): Receipt {
if (!this.hasRide) throw new Error("a receipt needs a ride line");
return new Receipt(this.rideId, this.heading, [...this.lines]);
}
}
const member = new ReceiptBuilder("R-881", "member, 42 min").ride(120).ebike(42).vat(20).build();
console.log(member.render());
const payg = new ReceiptBuilder("R-882", "pay-as-you-go, 18 min").ride(280).discount("SPRING10", 10).roundUpForCharity().build();
console.log(payg.render());
const attempts: (() => unknown)[] = [
() => new ReceiptBuilder("R-883", "member").ride(90).vat(20).discount("SPRING10", 10),
() => new ReceiptBuilder("R-884", "member").vat(20).build(),
];
for (const attempt of attempts) {
try {
attempt();
} catch (e) {
console.log(`rejected: ${(e as Error).message}`);
}
}import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;
public class Main {
record Line(String name, int pence) {}
// The finished product: immutable, always valid.
record Receipt(String rideId, String heading, List<Line> lines) {
int total() { return lines.stream().mapToInt(Line::pence).sum(); }
String render() {
StringBuilder sb = new StringBuilder("Receipt " + rideId + " (" + heading + ")");
for (Line l : lines) sb.append(String.format("%n %-20s%5dp", l.name(), l.pence()));
sb.append(String.format("%n %-20s%5dp", "total", total()));
return sb.toString();
}
}
// Collects the optional parts step by step and checks the rules once, in build().
static class ReceiptBuilder {
private final String rideId, heading;
private final List<Line> lines = new ArrayList<>();
private boolean hasRide, hasVat;
ReceiptBuilder(String rideId, String heading) { this.rideId = rideId; this.heading = heading; }
private int subtotal() { return lines.stream().mapToInt(Line::pence).sum(); }
ReceiptBuilder ride(int pence) { lines.add(new Line("ride", pence)); hasRide = true; return this; }
ReceiptBuilder ebike(int minutes) { lines.add(new Line("e-bike", minutes * 5)); return this; }
ReceiptBuilder discount(String code, int percent) {
if (hasVat) throw new IllegalStateException("VAT must be added after every discount");
lines.add(new Line(code + " " + percent + "% off", -(subtotal() * percent / 100)));
return this;
}
ReceiptBuilder vat(int percent) { lines.add(new Line("VAT " + percent + "%", subtotal() * percent / 100)); hasVat = true; return this; }
ReceiptBuilder roundUpForCharity() { lines.add(new Line("round-up (charity)", (100 - subtotal() % 100) % 100)); return this; }
Receipt build() {
if (!hasRide) throw new IllegalStateException("a receipt needs a ride line");
return new Receipt(rideId, heading, List.copyOf(lines));
}
}
public static void main(String[] args) {
Receipt member = new ReceiptBuilder("R-881", "member, 42 min").ride(120).ebike(42).vat(20).build();
System.out.println(member.render());
Receipt payg = new ReceiptBuilder("R-882", "pay-as-you-go, 18 min").ride(280).discount("SPRING10", 10).roundUpForCharity().build();
System.out.println(payg.render());
List<Supplier<Object>> attempts = List.of(
() -> new ReceiptBuilder("R-883", "member").ride(90).vat(20).discount("SPRING10", 10),
() -> new ReceiptBuilder("R-884", "member").vat(20).build());
for (Supplier<Object> attempt : attempts) {
try {
attempt.get();
} catch (IllegalStateException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}
}Output (the same in all three languages)
Receipt R-881 (member, 42 min) ride 120p e-bike 210p VAT 20% 66p total 396p Receipt R-882 (pay-as-you-go, 18 min) ride 280p SPRING10 10% off -28p round-up (charity) 48p total 300p rejected: VAT must be added after every discount rejected: a receipt needs a ride line
6. Try it
Constructor or builder?
Tick the lines this receipt needs and compare the two calls.
7. When not to use it
What you get
- Readable calls: every value is named by its method.
- Valid by construction:
build()checks rules across parts, once. - Immutable products: nobody sees a half-built receipt.
- Easy to extend: a new optional part is a new method.
What you pay
- A second class to maintain: every product field appears twice.
- Verbose for small objects: two fields don't need a builder.
- Mistakes surface at build time: not at compile time, unless you use typed step interfaces.
- Mutable builder: sharing one between threads or reusing it after
build()causes surprises.
| If… | Use instead |
|---|---|
| The language has named and default arguments, and parts have no ordering rules | A dataclass or options object |
| The object has two or three fields | A plain constructor |
| New objects are small variations of a configured one | 04 Prototype |
| You need a matching set of objects, not one complex one | 02 Abstract factory |
8. How it goes wrong
build() hands out an object anyone can change, the guarantee from build() doesn't last.build() sees the first object's parts. Make a new builder each time, or reset it explicitly.9. Remember it and say it
How this hook works: what “Order it, then wrap it” means
What the slogan means, phrase by phrase
- “Order it”
- Chain one named method per part.
- “then wrap it”
build()checks the rules and returns the product.- “the finished thing”
- An immutable, valid object.
Why this picture fits: A sandwich is ordered step by step and checked before it's wrapped; you never hold half of one.
The story → the concept
| The counter | The code |
|---|---|
| "White bread, chicken, no onions" | .ride(280).discount(…).round_up_for_charity() |
| The person checking the order | build() enforcing the rules |
| The wrapped sandwich | The immutable Receipt |
| The house special | A director with a standard recipe |
Watch it on an example
- Checkout chains the ride, a discount and a round-up.
build()checks there's a ride line and no discount after VAT.- It returns a frozen receipt that totals 300p.
The trap it saves you from
A long positional constructor where two numbers can be swapped without anyone noticing.
It still works when…
Yes: HTTP request builders, SQL query builders and test-data builders all use it.
In an interview or code review
- Say why, not just what: "Receipts have up to six optional lines and an ordering rule, so a builder with
build()validation, and an immutableReceipt." - Acknowledge the language: in Python I'd start with keyword arguments; I'd move to a builder when rules span several parts.
- Mention the director if the same steps must produce different outputs (a text receipt and an HTML email).
- Real examples: Java's
StringBuilderandHttpRequest.newBuilder(), and test-data builders in test suites.
10. Check yourself
What problem does a builder solve first?
Where should "discount before VAT" be enforced?
Why is the product often immutable?
In Python, when might keyword arguments be enough?
What does a director add?
11. Go deeper
| Resource | Why |
|---|---|
| Low-level design 04: Patterns you'll actually use | Builder in the interview shortlist. |
Java HttpRequest.Builder | A standard-library builder with an immutable product. |
Python dataclasses | Keyword arguments and frozen objects: often all you need. |
| Wikipedia: Builder pattern | The director variant and history. |