InterviewKata
Design patterns · 03 of 26 · Making objects

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

At a sandwich counter you don't announce the whole order in one breath with blanks for what you don't want. You say "white bread", then "chicken", then "no onions", and the person behind the counter checks it makes sense before wrapping it. You get the sandwich only at the end, finished.

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

RoleIts jobIn Pedal
BuilderOffers one method per part; collects them; checks the rules in build().ReceiptBuilder
ProductThe finished, immutable result.Receipt
ClientChains 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 changeWithout the patternWith it
Add a "carbon saved" lineA new parameter: every call site changes, or another default joins the listA new carbon() method; existing calls are untouched
Enforce "discount before VAT"Hard: the constructor sees all values at once, not their orderdiscount() refuses once VAT is on
Read a call six months laterCount commas to find which number is VATEvery 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.

Constructor call

Builder chain

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 rulesA dataclass or options object
The object has two or three fieldsA plain constructor
New objects are small variations of a configured one04 Prototype
You need a matching set of objects, not one complex one02 Abstract factory

8. How it goes wrong

A builder for a two-field objectCeremony with no payoff. Use the constructor, or keyword arguments.
No validation in build()A builder that only stores values is a setter with extra steps. The rules across parts are the point.
Returning a mutable productIf build() hands out an object anyone can change, the guarantee from build() doesn't last.
Reusing one builder for two objectsThe second build() sees the first object's parts. Make a new builder each time, or reset it explicitly.

9. Remember it and say it

Memory hook: "Order it, then wrap it"Name each part as you go; you get the finished thing only at the end, checked.
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 counterThe code
"White bread, chicken, no onions".ride(280).discount(…).round_up_for_charity()
The person checking the orderbuild() enforcing the rules
The wrapped sandwichThe immutable Receipt
The house specialA director with a standard recipe

Watch it on an example

  1. Checkout chains the ride, a discount and a round-up.
  2. build() checks there's a ride line and no discount after VAT.
  3. 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

10. Check yourself

What problem does a builder solve first?

Named steps plus validation in build().

Where should "discount before VAT" be enforced?

The product stays valid by construction.

Why is the product often immutable?

Nobody can change it into an invalid state afterwards.

In Python, when might keyword arguments be enough?

Named and default arguments already fix readability.

What does a director add?

For example, the same member receipt as text or as HTML.

11. Go deeper

ResourceWhy
Low-level design 04: Patterns you'll actually useBuilder in the interview shortlist.
Java HttpRequest.BuilderA standard-library builder with an immutable product.
Python dataclassesKeyword arguments and frozen objects: often all you need.
Wikipedia: Builder patternThe director variant and history.