Design Patterns (GoF and modern) Module 2 – Creational patterns: Factory Method to Singleton
Builder: step-by-step object construction
Builder assembles an object step by step and checks it once in build(). Keyword arguments, options objects, Go functional options and Rust type-state.
What you will learn
- Implement a validating Builder that produces an immutable object
- Replace telescoping constructors with a builder, keyword arguments or an options object, choosing per language
- Write Go functional options and a Rust type-state builder
- Decide when a Builder is overkill
Before you start
What you’ll learn · Before you start
What you will learn
- Implement a validating Builder that produces an immutable object
- Replace telescoping constructors with a builder, keyword arguments or an options object, choosing per language
- Write Go functional options and a Rust type-state builder
- Decide when a Builder is overkill
Before you start
On this page
Builder is a creational design pattern that separates how a complex object is put together from the object
itself. Instead of one constructor call with a long list of arguments, you give a builder the parts one step at a
time (a buyer, a line, another line, a tax rate) and finish with build(), which checks the whole and returns the
finished object, usually an immutable one. Use it when an object has many optional parts, when parts arrive one at a
time, or when the rules involve several parts at once. Many languages now cover the simple cases with named
arguments or options objects, so the modern question is less “how do I write a Builder” than “does this need one”.
This lesson builds an invoice in Python three ways, then in Java, TypeScript, Go, C++ and Rust in each language’s own
style, including Go’s functional options and a Rust builder that turns a missing step into a compile error. It shows
the trap of a build() that does not copy, and the cases where a Builder is more design than you need.
The problem: constructors that grow
An invoice needs a number, a seller, a buyer, some lines and a tax rate, and later it may need a currency, notes, a
due date and a discount. Written as constructors, that becomes Invoice(number, seller, buyer, lines, 18, "INR", None, None, 0), or a ladder of overloads with three, four, five and six parameters, the telescoping constructor.
Two problems follow. A reader cannot tell what 18 or the third None means without opening the class, and two
arguments of the same type can be swapped without any complaint from the compiler. And the rules that involve
several parts, such as “every line has a positive quantity” and “there is a buyer”, have nowhere natural to live: a
constructor checks one call, but the parts may arrive one at a time, from a cart, a file or a form.
A builder answers both. Each step has a name, so every argument says what it is. And build() is the one place
where the object comes into existence, so it is the natural place to check every rule at once.
The parts
build() is the only way from the parts to the product
Text description of the diagram
The diagram is a flow from top to bottom.
- InvoiceBuilder is the changeable part. Calls such as to(buyer), line(...) and tax_rate(...) each add or change one part.
- build() checks every rule at once.
- If all rules hold, it returns an Invoice, which is immutable and has its own copy of the lines.
- If any rule is broken, it raises a ValueError that names every problem, not just the first one.
A dashed arrow from the Invoice back to the builder shows that the builder can be reused: change one part and build again, and the invoice built earlier does not change.
The catalogue describes four roles: a builder interface with a step for each part, concrete builders that
make different representations from the same steps, a director that calls the steps in order, and the
product. Most builders written today keep two of them: a concrete builder with fluent steps, each returning the
builder so that calls chain, and the product it builds. Being fluent is a style: Martin Fowler’s note of 2005,
written after a workshop with Eric Evans, gave it the name fluent interface. What makes something a Builder is
that construction is separate from the product and that build() is a gate: the product exists only if every rule
holds.
The invoice in Python
"""An invoice made three ways: keyword arguments, copy.replace() for a changed copy, and a
validating Builder.
A teaching model, not a legal invoice: amounts are whole minor units (paise or cents), and
the tax is rounded half up to a whole minor unit once per invoice, which is this model's own
rule."""
import copy
from dataclasses import FrozenInstanceError, dataclass
@dataclass(frozen=True)
class Line:
item: str
qty: int
unit_price: int # minor units
@dataclass(frozen=True)
class Invoice:
number: str
seller: str
buyer: str
lines: tuple[Line, ...]
tax_rate_percent: int
def __post_init__(self):
if not self.lines:
raise ValueError(f"{self.number}: an invoice needs at least one line")
@property
def subtotal(self) -> int:
return sum(line.qty * line.unit_price for line in self.lines)
@property
def tax(self) -> int:
return (self.subtotal * self.tax_rate_percent + 50) // 100
def summary(self) -> str:
money = lambda v: f"{v // 100}.{v % 100:02d}"
count = f"{len(self.lines)} line" + ("s" if len(self.lines) != 1 else "")
amounts = f"subtotal {money(self.subtotal)}, tax {self.tax_rate_percent}% {money(self.tax)}"
total = money(self.subtotal + self.tax)
return f"{self.number} for {self.buyer}: {count}, {amounts}, total {total}"
class InvoiceBuilder:
"""Collects the parts one step at a time; build() checks them all and returns an immutable
Invoice."""
def __init__(self, number: str, seller: str) -> None:
self._number, self._seller = number, seller
self._buyer: str | None = None
self._lines: list[Line] = []
self._tax_rate = 18
def numbered(self, number: str) -> "InvoiceBuilder":
self._number = number
return self
def to(self, buyer: str) -> "InvoiceBuilder":
self._buyer = buyer
return self
def line(self, item: str, qty: int, unit_price: int) -> "InvoiceBuilder":
self._lines.append(Line(item, qty, unit_price))
return self
def tax_rate(self, percent: int) -> "InvoiceBuilder":
self._tax_rate = percent
return self
def build(self) -> Invoice:
problems = [] if self._buyer else ["no buyer"]
for i, l in enumerate(self._lines, 1):
if l.qty <= 0:
problems.append(f"line {i} ({l.item}) has quantity {l.qty}")
if not 0 <= self._tax_rate <= 100:
problems.append(f"tax rate {self._tax_rate}% is outside 0-100")
if problems:
raise ValueError(f"cannot build {self._number}: " + "; ".join(problems))
return Invoice(self._number, self._seller, self._buyer, tuple(self._lines), self._tax_rate)
# 1. Python's own answer to long constructors: keyword arguments and a frozen dataclass.
kettles = Invoice(number="INV-1041", seller="Lakeside Kitchenware", buyer="Asha Traders",
lines=(Line("kettle", 2, 129900),), tax_rate_percent=18)
print(kettles.summary())
print(copy.replace(kettles, number="INV-1041A", tax_rate_percent=5).summary())
# 2. A Builder when the parts arrive one at a time, here from a cart.
builder = InvoiceBuilder("INV-1042", "Lakeside Kitchenware").to("Asha Traders")
for item, qty, price in [("kettle", 2, 129900), ("mug", 4, 24950), ("tea", 1, 45000)]:
builder.line(item, qty, price)
first = builder.build()
print(first.summary())
second = builder.numbered("INV-1043").line("tray", 1, 40000).build()
print(second.summary())
print(f"{first.number} still has {len(first.lines)} lines")
try:
InvoiceBuilder("INV-1044", "Lakeside Kitchenware").line("mug", 0, 24950).build()
except ValueError as error:
print("error:", error)
# 3. frozen=True stops assignment, not change: a list inside a frozen object can still grow.
try:
first.number = "INV-9999"
except FrozenInstanceError as error:
print("FrozenInstanceError:", error)
@dataclass(frozen=True)
class LeakyInvoice:
number: str
lines: list[Line]
leaky_lines = [Line("kettle", 2, 129900)]
leaky = LeakyInvoice("INV-1045", leaky_lines)
# the caller, or a builder reused later, still holds the same list
leaky_lines.append(Line("mug", 4, 24950))
print(f"{leaky.number} was built with 1 line and now has {len(leaky.lines)}") Output
INV-1041 for Asha Traders: 1 line, subtotal 2598.00, tax 18% 467.64, total 3065.64 INV-1041A for Asha Traders: 1 line, subtotal 2598.00, tax 5% 129.90, total 2727.90 INV-1042 for Asha Traders: 3 lines, subtotal 4046.00, tax 18% 728.28, total 4774.28 INV-1043 for Asha Traders: 4 lines, subtotal 4446.00, tax 18% 800.28, total 5246.28 INV-1042 still has 3 lines error: cannot build INV-1044: no buyer; line 1 (mug) has quantity 0 FrozenInstanceError: cannot assign to field 'number' INV-1045 was built with 1 line and now has 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 invoice.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The program makes the same kind of object three ways, and each fits a different situation:
- Keyword arguments and a frozen dataclass. For an object whose parts are all known at the call site, Python
needs no Builder:
Invoice(number=..., buyer=..., tax_rate_percent=18)names every part, defaults cover the optional ones, and__post_init__checks the rules.frozen=Truemakes assignment to a field raiseFrozenInstanceError. copy.replace()for a changed copy. Added in Python 3.13, it creates a new object of the same type with some fields replaced, here a second invoice at 5 % tax. It works with dataclasses, named tuples and classes that define__replace__(), and it runs__post_init__again, so the copy is checked as well.- A Builder when parts accumulate. The lines come from a loop over a cart, so a builder collects them.
build()checks every rule and reports all the problems in one message, not just the first: an invoice with no buyer and a zero quantity says both. It turns the list of lines into a tuple, so the invoice owns its lines and the builder can be reused: the second invoice adds a tray, and the first one still has 3 lines.
The amounts are whole minor units (paise or cents), and the tax is rounded half up to a whole minor unit once per invoice. That rounding is this model’s own rule, stated so that every language can compute the same numbers; a real invoice follows the rules of its tax authority.
GST invoices
Tax identifiers are a good example of what build() should check: a mistyped GSTIN on a GST invoice can be refused
before the invoice exists, instead of reaching a customer. The GSTIN validator on this site checks a GSTIN’s
structure and its check character.
The trap: build() that does not copy
The last two lines of the output show the most common Builder bug. frozen=True stops assignment to the fields of
LeakyInvoice, but it does not freeze the list that a field refers to. The caller still holds that list, so
appending to it changes an invoice that was built with one line. A builder that passes its own _lines list to the
product has exactly this bug: reuse the builder, and invoices that were already built change underneath their
owners.
The rule is that build() hands over copies of anything mutable: a tuple instead of a list, a read-only view of a
copied dictionary, List.copyOf in Java, a cloned Vec in Rust. The validation rule is related: a build() that
accepts incomplete state only moves the bug to the first code that uses the object. Check in build(), and write a
test for the failure path as well as the happy one.
The invoice in five more languages
Each version is written the way its language prefers, and each prints lines 3 to 6 of the Python output: the two invoices from one builder, the check that the first is unchanged, and the error that lists both problems.
Java · compiled/Invoices.java
// Builder in Java 25: an immutable record, and a static nested Builder whose build() checks
// every rule. Amounts are whole minor units; the tax is rounded half up once per invoice (this
// model's rule).
// Run: java Invoices.java
import java.util.ArrayList;
import java.util.List;
public class Invoices {
record Line(String item, int qty, long unitPrice) {}
record Invoice(
String number, String seller, String buyer, List<Line> lines, int taxRatePercent) {
Invoice {
lines = List.copyOf(lines); // the record keeps its own unmodifiable copy
}
long subtotal() {
return lines.stream().mapToLong(l -> l.qty() * l.unitPrice()).sum();
}
long tax() {
return (subtotal() * taxRatePercent + 50) / 100;
}
String summary() {
String count = lines.size() + (lines.size() == 1 ? " line" : " lines");
String subtotalPart = "subtotal " + money(subtotal());
String taxPart = "tax " + taxRatePercent + "% " + money(tax());
String totalPart = "total " + money(subtotal() + tax());
String head = number + " for " + buyer + ": " + count;
return String.join(", ", head, subtotalPart, taxPart, totalPart);
}
static String money(long v) {
return v / 100 + "." + String.format("%02d", v % 100);
}
static Builder builder(String number, String seller) {
return new Builder(number, seller);
}
static final class Builder {
private String number;
private final String seller;
private String buyer;
private final List<Line> lines = new ArrayList<>();
private int taxRatePercent = 18;
private Builder(String number, String seller) {
this.number = number;
this.seller = seller;
}
Builder numbered(String number) {
this.number = number;
return this;
}
Builder to(String buyer) {
this.buyer = buyer;
return this;
}
Builder line(String item, int qty, long unitPrice) {
lines.add(new Line(item, qty, unitPrice));
return this;
}
Builder taxRate(int percent) {
this.taxRatePercent = percent;
return this;
}
Invoice build() {
List<String> problems = new ArrayList<>();
if (buyer == null) problems.add("no buyer");
for (int i = 0; i < lines.size(); i++) {
Line l = lines.get(i);
if (l.qty() <= 0) {
String what = "line " + (i + 1) + " (" + l.item() + ")";
problems.add(what + " has quantity " + l.qty());
}
}
if (taxRatePercent < 0 || taxRatePercent > 100) {
problems.add("tax rate " + taxRatePercent + "% is outside 0-100");
}
if (!problems.isEmpty()) {
String all = String.join("; ", problems);
throw new IllegalStateException("cannot build " + number + ": " + all);
}
return new Invoice(number, seller, buyer, lines, taxRatePercent);
}
}
}
public static void main(String[] args) {
Invoice.Builder builder = Invoice.builder("INV-1042", "Lakeside Kitchenware")
.to("Asha Traders")
.line("kettle", 2, 129900)
.line("mug", 4, 24950)
.line("tea", 1, 45000);
Invoice first = builder.build();
System.out.println(first.summary());
Invoice second = builder.numbered("INV-1043").line("tray", 1, 40000).build();
System.out.println(second.summary());
System.out.println(first.number() + " still has " + first.lines().size() + " lines");
try {
Invoice.builder("INV-1044", "Lakeside Kitchenware").line("mug", 0, 24950).build();
} catch (IllegalStateException e) {
System.out.println("error: " + e.getMessage());
}
}
} TypeScript · compiled/invoice.ts
// Builder's job in TypeScript: an options object with optional fields and a factory that
// validates it and returns a frozen, read-only invoice. Amounts are whole minor units; the tax
// is rounded half up once per invoice.
// Run: node invoice.ts
interface Line {
readonly item: string;
readonly qty: number;
readonly unitPrice: number;
}
interface InvoiceOptions {
number: string;
seller: string;
buyer?: string;
lines?: readonly Line[];
taxRatePercent?: number;
}
interface Invoice {
readonly number: string;
readonly seller: string;
readonly buyer: string;
readonly lines: readonly Line[];
readonly taxRatePercent: number;
}
function createInvoice(options: InvoiceOptions): Invoice {
const { number, seller, buyer, lines = [], taxRatePercent = 18 } = options;
const problems: string[] = buyer ? [] : ["no buyer"];
lines.forEach((l, i) => {
if (l.qty <= 0) problems.push(`line ${i + 1} (${l.item}) has quantity ${l.qty}`);
});
if (taxRatePercent < 0 || taxRatePercent > 100)
problems.push(`tax rate ${taxRatePercent}% is outside 0-100`);
if (problems.length > 0 || buyer === undefined)
throw new Error(`cannot build ${number}: ${problems.join("; ")}`);
// Copies, frozen: changing the options object later cannot change this invoice.
const ownLines = Object.freeze(lines.map((l) => Object.freeze({ ...l })));
return Object.freeze({ number, seller, buyer, lines: ownLines, taxRatePercent });
}
const money = (v: number): string =>
`${Math.trunc(v / 100)}.${String(v % 100).padStart(2, "0")}`;
function summary(inv: Invoice): string {
const subtotal = inv.lines.reduce((sum, l) => sum + l.qty * l.unitPrice, 0);
const tax = Math.floor((subtotal * inv.taxRatePercent + 50) / 100);
const count = `${inv.lines.length} line${inv.lines.length === 1 ? "" : "s"}`;
const amounts = `subtotal ${money(subtotal)}, tax ${inv.taxRatePercent}% ${money(tax)}`;
return `${inv.number} for ${inv.buyer}: ${count}, ${amounts}, total ${money(subtotal + tax)}`;
}
const order: InvoiceOptions = {
number: "INV-1042",
seller: "Lakeside Kitchenware",
buyer: "Asha Traders",
lines: [
{ item: "kettle", qty: 2, unitPrice: 129900 },
{ item: "mug", qty: 4, unitPrice: 24950 },
{ item: "tea", qty: 1, unitPrice: 45000 },
],
};
const first = createInvoice(order);
console.log(summary(first));
const second = createInvoice({
...order,
number: "INV-1043",
lines: [...(order.lines ?? []), { item: "tray", qty: 1, unitPrice: 40000 }],
});
console.log(summary(second));
console.log(`${first.number} still has ${first.lines.length} lines`);
try {
createInvoice({
number: "INV-1044",
seller: "Lakeside Kitchenware",
lines: [{ item: "mug", qty: 0, unitPrice: 24950 }],
});
} catch (error) {
console.log(`error: ${(error as Error).message}`);
} Go · compiled/invoice.go
// Builder's job in Go: functional options. NewInvoice takes the required fields as arguments
// and the optional ones as Option functions, applies them in order, then validates. Amounts are
// whole minor units; the tax is rounded half up once per invoice.
// Run: go run invoice.go
package main
import (
"errors"
"fmt"
"strings"
)
type Line struct {
Item string
Qty int
UnitPrice int64
}
type Invoice struct {
number, seller, buyer string
lines []Line
taxRatePercent int64
}
// Option changes an invoice that is being built.
type Option func(*Invoice)
func WithBuyer(buyer string) Option { return func(inv *Invoice) { inv.buyer = buyer } }
func WithLine(item string, qty int, unitPrice int64) Option {
return func(inv *Invoice) { inv.lines = append(inv.lines, Line{item, qty, unitPrice}) }
}
func WithTaxRate(percent int64) Option {
return func(inv *Invoice) { inv.taxRatePercent = percent }
}
func NewInvoice(number, seller string, opts ...Option) (*Invoice, error) {
inv := &Invoice{number: number, seller: seller, taxRatePercent: 18}
for _, opt := range opts {
opt(inv)
}
var problems []string
if inv.buyer == "" {
problems = append(problems, "no buyer")
}
for i, l := range inv.lines {
if l.Qty <= 0 {
problems = append(problems, fmt.Sprintf("line %d (%s) has quantity %d", i+1, l.Item, l.Qty))
}
}
if inv.taxRatePercent < 0 || inv.taxRatePercent > 100 {
problems = append(problems, fmt.Sprintf("tax rate %d%% is outside 0-100", inv.taxRatePercent))
}
if len(problems) > 0 {
return nil, errors.New("cannot build " + number + ": " + strings.Join(problems, "; "))
}
return inv, nil
}
func money(v int64) string { return fmt.Sprintf("%d.%02d", v/100, v%100) }
func (inv *Invoice) Summary() string {
var subtotal int64
for _, l := range inv.lines {
subtotal += int64(l.Qty) * l.UnitPrice
}
tax := (subtotal*inv.taxRatePercent + 50) / 100
count := fmt.Sprintf("%d line", len(inv.lines))
if len(inv.lines) != 1 {
count += "s"
}
return fmt.Sprintf("%s for %s: %s, subtotal %s, tax %d%% %s, total %s",
inv.number, inv.buyer, count, money(subtotal), inv.taxRatePercent, money(tax),
money(subtotal+tax))
}
func main() {
order := []Option{
WithBuyer("Asha Traders"),
WithLine("kettle", 2, 129900),
WithLine("mug", 4, 24950),
WithLine("tea", 1, 45000),
}
first, err := NewInvoice("INV-1042", "Lakeside Kitchenware", order...)
if err != nil {
panic(err)
}
fmt.Println(first.Summary())
more := append(order, WithLine("tray", 1, 40000))
second, err := NewInvoice("INV-1043", "Lakeside Kitchenware", more...)
if err != nil {
panic(err)
}
fmt.Println(second.Summary())
fmt.Printf("%s still has %d lines\n", first.number, len(first.lines))
_, err = NewInvoice("INV-1044", "Lakeside Kitchenware", WithLine("mug", 0, 24950))
if err != nil {
fmt.Println("error:", err)
}
} C++ · compiled/invoice.cpp
// Builder in C++23: line items written with designated initializers, and a builder whose
// build() returns std::expected: an Invoice, or the list of problems. Amounts are whole minor
// units; the tax is rounded half up once per invoice.
// Build: clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined invoice.cpp -o invoice
#include <expected>
#include <format>
#include <print>
#include <string>
#include <utility>
#include <vector>
struct Line {
std::string item;
int qty = 1;
long long unit_price = 0;
};
class Invoice {
public:
const std::string& number() const { return number_; }
const std::vector<Line>& lines() const { return lines_; }
std::string summary() const {
long long subtotal = 0;
for (const auto& l : lines_) subtotal += l.qty * l.unit_price;
long long tax = (subtotal * tax_rate_percent_ + 50) / 100;
auto money = [](long long v) { return std::format("{}.{:02}", v / 100, v % 100); };
auto count = std::format("{} line{}", lines_.size(), lines_.size() == 1 ? "" : "s");
return std::format("{} for {}: {}, subtotal {}, tax {}% {}, total {}", number_, buyer_,
count, money(subtotal), tax_rate_percent_, money(tax),
money(subtotal + tax));
}
private:
// only the builder can make one, and only after it checked the parts
friend class InvoiceBuilder;
Invoice(std::string number, std::string seller, std::string buyer, std::vector<Line> lines,
int tax_rate_percent)
: number_{std::move(number)},
seller_{std::move(seller)},
buyer_{std::move(buyer)},
lines_{std::move(lines)},
tax_rate_percent_{tax_rate_percent} {}
std::string number_, seller_, buyer_;
std::vector<Line> lines_;
int tax_rate_percent_;
};
class InvoiceBuilder {
public:
InvoiceBuilder(std::string number, std::string seller)
: number_{std::move(number)}, seller_{std::move(seller)} {}
InvoiceBuilder& numbered(std::string number) {
number_ = std::move(number);
return *this;
}
InvoiceBuilder& to(std::string buyer) {
buyer_ = std::move(buyer);
return *this;
}
InvoiceBuilder& line(Line line) {
lines_.push_back(std::move(line));
return *this;
}
InvoiceBuilder& tax_rate(int percent) {
tax_rate_percent_ = percent;
return *this;
}
// const: building never changes the builder, and the invoice gets its own copy of the
// lines.
std::expected<Invoice, std::string> build() const {
std::vector<std::string> problems;
if (buyer_.empty()) problems.push_back("no buyer");
for (std::size_t i = 0; i < lines_.size(); ++i)
if (lines_[i].qty <= 0)
problems.push_back(std::format("line {} ({}) has quantity {}", i + 1,
lines_[i].item, lines_[i].qty));
if (tax_rate_percent_ < 0 || tax_rate_percent_ > 100)
problems.push_back(std::format("tax rate {}% is outside 0-100", tax_rate_percent_));
if (!problems.empty()) {
std::string joined;
for (const auto& p : problems) joined += (joined.empty() ? "" : "; ") + p;
return std::unexpected("cannot build " + number_ + ": " + joined);
}
return Invoice{number_, seller_, buyer_, lines_, tax_rate_percent_};
}
private:
std::string number_, seller_, buyer_;
std::vector<Line> lines_;
int tax_rate_percent_ = 18;
};
int main() {
InvoiceBuilder builder{"INV-1042", "Lakeside Kitchenware"};
builder.to("Asha Traders")
.line({.item = "kettle", .qty = 2, .unit_price = 129900})
.line({.item = "mug", .qty = 4, .unit_price = 24950})
.line({.item = "tea", .qty = 1, .unit_price = 45000});
auto first = builder.build().value();
std::println("{}", first.summary());
auto second = builder.numbered("INV-1043")
.line({.item = "tray", .qty = 1, .unit_price = 40000})
.build()
.value();
std::println("{}", second.summary());
std::println("{} still has {} lines", first.number(), first.lines().size());
auto broken = InvoiceBuilder{"INV-1044", "Lakeside Kitchenware"}
.line({.item = "mug", .qty = 0, .unit_price = 24950})
.build();
if (!broken) std::println("error: {}", broken.error());
} Rust · compiled/invoice.rs
// Builder in Rust: a non-consuming builder, the variant the Rust API guidelines prefer
// (C-BUILDER). Configuration methods take and return &mut self; build() takes &self and returns
// Result. Amounts are whole minor units; the tax is rounded half up once per invoice.
// Build: rustc --edition 2024 invoice.rs
#[derive(Clone)]
struct Line {
item: String,
qty: i64,
unit_price: i64,
}
struct Invoice {
number: String,
buyer: String,
lines: Vec<Line>,
tax_rate_percent: i64,
}
impl Invoice {
fn summary(&self) -> String {
let subtotal: i64 = self.lines.iter().map(|l| l.qty * l.unit_price).sum();
let tax = (subtotal * self.tax_rate_percent + 50) / 100;
let money = |v: i64| format!("{}.{:02}", v / 100, v % 100);
let count = format!(
"{} line{}",
self.lines.len(),
if self.lines.len() == 1 { "" } else { "s" }
);
format!(
"{} for {}: {count}, subtotal {}, tax {}% {}, total {}",
self.number,
self.buyer,
money(subtotal),
self.tax_rate_percent,
money(tax),
money(subtotal + tax)
)
}
}
struct InvoiceBuilder {
number: String,
buyer: Option<String>,
lines: Vec<Line>,
tax_rate_percent: i64,
}
impl InvoiceBuilder {
fn new(number: &str) -> Self {
InvoiceBuilder {
number: number.to_string(),
buyer: None,
lines: Vec::new(),
tax_rate_percent: 18,
}
}
fn numbered(&mut self, number: &str) -> &mut Self {
self.number = number.to_string();
self
}
fn to(&mut self, buyer: &str) -> &mut Self {
self.buyer = Some(buyer.to_string());
self
}
fn line(&mut self, item: &str, qty: i64, unit_price: i64) -> &mut Self {
self.lines.push(Line {
item: item.to_string(),
qty,
unit_price,
});
self
}
fn build(&self) -> Result<Invoice, String> {
let mut problems: Vec<String> = Vec::new();
if self.buyer.is_none() {
problems.push("no buyer".to_string());
}
for (i, l) in self.lines.iter().enumerate() {
if l.qty <= 0 {
problems.push(format!(
"line {} ({}) has quantity {}",
i + 1,
l.item,
l.qty
));
}
}
if !(0..=100).contains(&self.tax_rate_percent) {
problems.push(format!(
"tax rate {}% is outside 0-100",
self.tax_rate_percent
));
}
match &self.buyer {
Some(buyer) if problems.is_empty() => Ok(Invoice {
number: self.number.clone(),
buyer: buyer.clone(),
// the invoice owns its lines; the builder keeps its own
lines: self.lines.clone(),
tax_rate_percent: self.tax_rate_percent,
}),
_ => Err(format!(
"cannot build {}: {}",
self.number,
problems.join("; ")
)),
}
}
}
fn main() -> Result<(), String> {
let mut builder = InvoiceBuilder::new("INV-1042");
builder
.to("Asha Traders")
.line("kettle", 2, 129900)
.line("mug", 4, 24950)
.line("tea", 1, 45000);
let first = builder.build()?;
println!("{}", first.summary());
let second = builder
.numbered("INV-1043")
.line("tray", 1, 40000)
.build()?;
println!("{}", second.summary());
println!("{} still has {} lines", first.number, first.lines.len());
if let Err(error) = InvoiceBuilder::new("INV-1044")
.line("mug", 0, 24950)
.build()
{
println!("error: {error}");
}
Ok(())
} compiled/languages_session.py
from session import run, versions
# The invoice in five more languages, each in its own idiom: every program prints lines 3 to 6
# of invoice.py.
versions("javac --version", "node --version", "go version", "clang++ --version", "rustc --version")
run("""
java Invoices.java
node invoice.ts
go run invoice.go
clang++ -std=c++23 -O1 -g -Wall -Wextra -fsanitize=address,undefined invoice.cpp -o invoice
./invoice
rustc --edition 2024 -o invoice_rs invoice.rs && ./invoice_rs
""") Output
# javac 25.0.4.1 # v24.21.0 # go version go1.27.1 darwin/arm64 # Apple clang version 21.0.0 (clang-2100.3.34.2) # rustc 1.99.0 (b940084d7) $ java Invoices.java INV-1042 for Asha Traders: 3 lines, subtotal 4046.00, tax 18% 728.28, total 4774.28 INV-1043 for Asha Traders: 4 lines, subtotal 4446.00, tax 18% 800.28, total 5246.28 INV-1042 still has 3 lines error: cannot build INV-1044: no buyer; line 1 (mug) has quantity 0 $ node invoice.ts INV-1042 for Asha Traders: 3 lines, subtotal 4046.00, tax 18% 728.28, total 4774.28 INV-1043 for Asha Traders: 4 lines, subtotal 4446.00, tax 18% 800.28, total 5246.28 INV-1042 still has 3 lines error: cannot build INV-1044: no buyer; line 1 (mug) has quantity 0 $ go run invoice.go INV-1042 for Asha Traders: 3 lines, subtotal 4046.00, tax 18% 728.28, total 4774.28 INV-1043 for Asha Traders: 4 lines, subtotal 4446.00, tax 18% 800.28, total 5246.28 INV-1042 still has 3 lines error: cannot build INV-1044: no buyer; line 1 (mug) has quantity 0 $ clang++ -std=c++23 -O1 -g -Wall -Wextra -fsanitize=address,undefined invoice.cpp -o invoice $ ./invoice INV-1042 for Asha Traders: 3 lines, subtotal 4046.00, tax 18% 728.28, total 4774.28 INV-1043 for Asha Traders: 4 lines, subtotal 4446.00, tax 18% 800.28, total 5246.28 INV-1042 still has 3 lines error: cannot build INV-1044: no buyer; line 1 (mug) has quantity 0 $ rustc --edition 2024 -o invoice_rs invoice.rs && ./invoice_rs INV-1042 for Asha Traders: 3 lines, subtotal 4046.00, tax 18% 728.28, total 4774.28 INV-1043 for Asha Traders: 4 lines, subtotal 4446.00, tax 18% 800.28, total 5246.28 INV-1042 still has 3 lines error: cannot build INV-1044: no buyer; line 1 (mug) has quantity 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 languages_session.py
compiled/session.py
"""Prints a terminal session: "$ " and each command, then everything it printed (standard output
and standard error together), and its exit status when that is not 0.
The toolchains are the pinned ones: Java 25 (Eclipse Temurin 25.0.4), Node.js 24.21.0 and Go
1.27.1 from the mise installations next to the Python that runs this file, Rust 1.99.0 from
its rustup toolchain, and the system's Apple clang. learn:verify cannot let a sandboxed run
read the home folder those installations live in, so the sessions of this folder are recorded
with LEARN_VERIFY_NETWORK=allow. They read only this folder and the toolchains, write only
inside this folder and the run's own temporary home, and never use the network.
A command that fails and is not listed in expect_failure makes the session exit with status
1, so a run that could not build a program never becomes a record."""
import os
import pwd
import re
import signal
import subprocess
import sys
from pathlib import Path
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
REAL_HOME = Path(pwd.getpwuid(os.getuid()).pw_dir)
TOOLCHAINS = [
INSTALLS / "java" / "temurin-25.0.4+101.0.LTS" / "bin",
INSTALLS / "node" / "24.21.0" / "bin",
INSTALLS / "go" / "1.27.1" / "bin",
REAL_HOME / ".rustup" / "toolchains" / "1.99.0-aarch64-apple-darwin" / "bin",
]
# Version lines that end with a build date (rustc's) are shown without it: pages show no dates.
BUILD_DATE = re.compile(r"\s+\d{4}-\d{2}-\d{2}(?=\))")
def _env() -> dict[str, str]:
env = dict(os.environ)
env["PATH"] = os.pathsep.join([*map(str, TOOLCHAINS), env.get("PATH", "")])
env["GOTOOLCHAIN"] = "local" # never download another Go
env["GOTELEMETRY"] = "off"
env["GOFLAGS"] = "-trimpath"
return env
def _shell(command: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command, shell=True, env=_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
)
def versions(*commands: str) -> None:
"""Prints the first line of each version command as a comment."""
for command in commands:
done = _shell(command)
lines = done.stdout.splitlines()
if done.returncode != 0 or not lines:
sys.exit(f"{command} did not run: the pinned toolchain is missing here")
print("#", BUILD_DATE.sub("", lines[0]))
def run(script: str, expect_failure: tuple[str, ...] = ()) -> None:
"""Runs each line of the script as one shell command; only the commands in expect_failure
may fail."""
failed = 0
for command in (line.strip() for line in script.strip().splitlines()):
print(f"$ {command}")
done = _shell(command)
print(done.stdout, end="")
if done.returncode < 0:
print(f"[stopped by signal {signal.Signals(-done.returncode).name}]")
elif done.returncode != 0:
print(f"[exit status {done.returncode}]")
if done.returncode != 0 and command not in expect_failure:
failed += 1
if failed:
sys.exit(1) - Java 25. The product is a record, and its compact constructor replaces
lineswithList.copyOf(lines), an unmodifiable copy, so no caller can change an invoice after the fact. The builder is a static nested class reached throughInvoice.builder(...); itsbuild()collects the problems and throwsIllegalStateException. Records arrived in Java 16 (JEP 395). - TypeScript. No builder class at all: an options object with optional fields and a
createInvoice()function that validates it, copies the lines and returns a frozen object withreadonlytypes. A second invoice is the same options spread into a new object with a changed number and one more line. - Go. Functional options:
NewInvoice(number, seller, opts ...Option)takes the required parts as arguments and every optional one as a function that changes the invoice being built, then validates and returns an error. Rob Pike described options as functions early in 2014, and a talk by Dave Cheney later that year popularised the style. A new option is a new function, so no existing call changes. - C++23. The line items use designated initializers (C++20),
{.item = "kettle", .qty = 2, .unit_price = 129900}, which cover simple value types without any builder. TheInvoiceconstructor is private, with the builder as a friend, so the only way to get an invoice is throughbuild(), which isconstand returnsstd::expected: either the invoice or the text of the problems. - Rust. A non-consuming builder, the variant the Rust API guidelines prefer: configuration methods take and
return
&mut self, andbuild()takes&selfand returns aResult, so one builder can make several invoices. The standard library’sstd::process::Commandworks the same way: methods such asarg()return&mut Command, so a process is configured step by step and then started withspawn().
A builder the compiler checks: type-state in Rust
Every builder above checks its rules when the program runs. A type-state builder moves one rule into the type system. The builder carries a type parameter that records what has been set, and the final step exists only on the type that has everything:
compiled/typestate_session.py
from session import run
# A type-state builder used correctly, then with send() before to(): the second does not
# compile.
run("""
rustc --edition 2024 typestate.rs && ./typestate
rustc --edition 2024 typestate_misuse.rs
""", expect_failure=("rustc --edition 2024 typestate_misuse.rs",)) Output
$ rustc --edition 2024 typestate.rs && ./typestate
sent "Order 1042 has shipped" to asha@example.com
$ rustc --edition 2024 typestate_misuse.rs
error[E0599]: no method named `send` found for struct `EmailBuilder<NoRecipient>` in the current scope
--> typestate_misuse.rs:50:73
|
10 | struct EmailBuilder<State> {
| -------------------------- method `send` not found for this struct
...
50 | let receipt = EmailBuilder::new().subject("Order 1042 has shipped").send();
| ^^^^ method not found in `EmailBuilder<NoRecipient>`
|
= note: the method was found for `EmailBuilder<HasRecipient>`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0599`.
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typestate_session.py
compiled/typestate.rs
// A type-state builder: the builder's type records whether a recipient has been set, and send()
// exists only on the type that has one. Sending without a recipient is not a run-time error; it
// does not compile.
// Build: rustc --edition 2024 typestate.rs
use std::marker::PhantomData;
struct NoRecipient;
struct HasRecipient;
struct EmailBuilder<State> {
to: Vec<String>,
subject: String,
state: PhantomData<State>,
}
impl EmailBuilder<NoRecipient> {
fn new() -> Self {
EmailBuilder {
to: Vec::new(),
subject: String::new(),
state: PhantomData,
}
}
}
impl<State> EmailBuilder<State> {
// Adding a recipient turns any builder into one that has a recipient.
fn to(mut self, address: &str) -> EmailBuilder<HasRecipient> {
self.to.push(address.to_string());
EmailBuilder {
to: self.to,
subject: self.subject,
state: PhantomData,
}
}
fn subject(mut self, subject: &str) -> Self {
self.subject = subject.to_string();
self
}
}
impl EmailBuilder<HasRecipient> {
fn send(self) -> String {
format!("sent \"{}\" to {}", self.subject, self.to.join(", "))
}
}
fn main() {
let receipt = EmailBuilder::new()
.subject("Order 1042 has shipped")
.to("asha@example.com")
.send();
println!("{receipt}");
} compiled/typestate_misuse.rs
// The same type-state builder as typestate.rs, used wrongly: send() before to(). The compiler
// rejects it (error E0599), so the mistake never reaches a running program.
// Build: rustc --edition 2024 typestate_misuse.rs (it is meant to fail)
#![allow(dead_code)] // to() is not called here, which is the mistake
use std::marker::PhantomData;
struct NoRecipient;
struct HasRecipient;
struct EmailBuilder<State> {
to: Vec<String>,
subject: String,
state: PhantomData<State>,
}
impl EmailBuilder<NoRecipient> {
fn new() -> Self {
EmailBuilder {
to: Vec::new(),
subject: String::new(),
state: PhantomData,
}
}
}
impl<State> EmailBuilder<State> {
// Adding a recipient turns any builder into one that has a recipient.
fn to(mut self, address: &str) -> EmailBuilder<HasRecipient> {
self.to.push(address.to_string());
EmailBuilder {
to: self.to,
subject: self.subject,
state: PhantomData,
}
}
fn subject(mut self, subject: &str) -> Self {
self.subject = subject.to_string();
self
}
}
impl EmailBuilder<HasRecipient> {
fn send(self) -> String {
format!("sent \"{}\" to {}", self.subject, self.to.join(", "))
}
}
fn main() {
let receipt = EmailBuilder::new().subject("Order 1042 has shipped").send();
println!("{receipt}");
} EmailBuilder::new() returns an EmailBuilder<NoRecipient>, and to() returns an EmailBuilder<HasRecipient>.
send() is defined only for the second type, so typestate_misuse.rs, which calls send() without a recipient,
stops at compile time with error E0599, and the compiler’s note even says that the method exists for
EmailBuilder<HasRecipient>. The cost is extra type machinery for each rule you encode, so type-state suits a few
rules that must never be broken, such as “a payment has an amount before it can be submitted”, not every validation.
Choosing per language
| Criterion | Builder class | Keyword arguments or options object | Functional options (Go) | Type-state builder |
|---|---|---|---|---|
| Names every part | Yes, a step per part | Yes, a name per argument or field | Yes, a function per option | Yes, a step per part |
| Parts can arrive one at a time | Yes | No, all at the call | Partly: a list of options can be built first | Yes |
| When rules are checked | In build(), at run time | In the constructor or factory, at run time | In the constructor, at run time | Some at compile time |
| Idiomatic in | Java, C++, Rust | Python, TypeScript, and C++ for plain values | Go | Rust; possible in TypeScript and C++ |
| When to choose | Parts accumulate and rules span several of them | Every part is known where the object is made | Optional settings of a Go type that must stay compatible | A rule that must never be broken, enforced by the compiler |
When a Builder is overkill
- Few parts, all known at the call. A
Point(x, y)or a value with two or three required fields needs a constructor. In Python and TypeScript, named arguments and options objects already give the readability. - No rule spans several parts. If each part can be checked on its own, check it where it is set, and the builder adds little.
- The builder lets incomplete objects escape. A builder whose
build()checks nothing, or a product with public setters that callers use afterbuild(), has the cost of the pattern without its benefit. - One builder shared by many callers. A builder is mutable by design. Keep each one local to the code that builds, never in a field that two requests or threads share.
Exercise: an HTTP request builder
Exercise · Easy · Python
Make build() the gate of an HTTP request builder
RequestBuilder in request.py already has its fluent methods: method(), header(), timeout() and body() each change one part and return the builder. What it lacks is the part that makes a builder worth having: build() accepts anything, and the request it returns shares the builder's own headers dictionary. Make build() the gate:
1. Check every rule, and report them all. Collect the problems in this order and raise one ValueError whose message joins them with "; ": - a URL that does not start with http:// or https://: the URL ftp://shop.example.com/x must start with http:// or https:// - a method outside METHODS: FETCH is not a known method (methods are stored in capitals) - a body on a GET or HEAD request: a GET request has no body - a timeout of zero or less: the timeout must be above 0 seconds, not 0 (the value as it was given) 2. Return an immutable request. Request is already a frozen dataclass, but a dictionary inside it can still be changed. Give the request a read-only copy of the headers (types.MappingProxyType wraps a dictionary in a read-only view), so request.headers["Accept"] = "text/html" raises TypeError. Store the timeout as a float. 3. Keep the builder reusable. Adding a header to the builder after a build() must not change the request that was built before.
The sample tests check the defaults, a full POST, each rule on its own, all of them at once, the immutability of the request and the reuse of the builder. Run them when you are done.
Starter code · request.py
"""A builder for HTTP requests. The fluent methods work; build() checks nothing yet and shares its headers."""
from dataclasses import dataclass
METHODS = {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
@dataclass(frozen=True)
class Request:
method: str
url: str
headers: dict
timeout: float
body: bytes | None
class RequestBuilder:
def __init__(self, url):
self._url = url
self._method = "GET"
self._headers = {}
self._timeout = 10.0
self._body = None
def method(self, name):
self._method = name.upper()
return self
def header(self, name, value):
self._headers[name] = value
return self
def timeout(self, seconds):
self._timeout = seconds
return self
def body(self, data):
self._body = data
return self
def build(self):
return Request(self._method, self._url, self._headers, self._timeout, self._body) The sample tests · test_request.py
from dataclasses import FrozenInstanceError
from request import Request, RequestBuilder
URL = "https://shop.example.com/orders"
def refused(builder):
"""The ValueError message of builder.build(), or None if it built."""
try:
builder.build()
except ValueError as error:
return str(error)
return None
def test_defaults():
"""a request with nothing set is a GET with a 10 second timeout, no headers and no body"""
r = RequestBuilder(URL).build()
assert (r.method, r.url, dict(r.headers), r.timeout, r.body) == ("GET", URL, {}, 10.0, None)
def test_a_full_post():
"""every part that was set reaches the request; the method's case does not matter"""
r = RequestBuilder(URL).method("post").header("Content-Type", "application/json").timeout(5).body(b'{"order": 1042}').build()
assert isinstance(r, Request)
assert (r.method, dict(r.headers), r.timeout, r.body) == ("POST", {"Content-Type": "application/json"}, 5.0, b'{"order": 1042}')
def test_no_body_on_get_or_head():
"""GET and HEAD requests with a body are refused"""
assert refused(RequestBuilder(URL).body(b"x")) == "a GET request has no body"
assert refused(RequestBuilder(URL).method("HEAD").body(b"x")) == "a HEAD request has no body"
def test_timeout_above_zero():
"""a timeout of 0 or less is refused"""
assert refused(RequestBuilder(URL).timeout(0)) == "the timeout must be above 0 seconds, not 0"
assert refused(RequestBuilder(URL).timeout(-1.5)) == "the timeout must be above 0 seconds, not -1.5"
def test_url_and_method_are_checked():
"""the URL must be http or https, and the method one of the six known ones"""
assert refused(RequestBuilder("ftp://shop.example.com/x")) == "the URL ftp://shop.example.com/x must start with http:// or https://"
assert refused(RequestBuilder(URL).method("fetch")) == "FETCH is not a known method"
def test_every_problem_at_once():
"""build() reports every broken rule in one message, in the order URL, method, body, timeout"""
message = refused(RequestBuilder("shop.example.com").body(b"x").timeout(0))
assert message == ("the URL shop.example.com must start with http:// or https://; a GET request has no body; "
"the timeout must be above 0 seconds, not 0")
def test_the_request_is_immutable():
"""neither the request's fields nor its headers can be changed"""
r = RequestBuilder(URL).header("Accept", "application/json").build()
try:
r.method = "DELETE"
except FrozenInstanceError:
pass
else:
raise AssertionError("assigning to a field of a request should raise FrozenInstanceError")
try:
r.headers["Accept"] = "text/html"
except TypeError:
pass
else:
raise AssertionError("request.headers must be read-only: changing it should raise TypeError")
def test_a_builder_can_be_reused():
"""building again after more header() calls leaves the first request as it was"""
builder = RequestBuilder(URL).header("Accept", "application/json")
first = builder.build()
second = builder.header("Authorization", "Bearer test-token").build()
assert dict(first.headers) == {"Accept": "application/json"}
assert dict(second.headers) == {"Accept": "application/json", "Authorization": "Bearer test-token"} A hint
Start build() with an empty problems list and one if per rule, in the order the prompt gives, so the message comes out in that order too. Raise only when the list is not empty. For the headers, copy the dictionary first (dict(self._headers)) and wrap the copy, not the builder's own dictionary: a read-only view of a dictionary that the builder still changes would show those changes.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What problem does the Builder pattern solve that a constructor with many parameters does not?
A constructor with many parameters has three problems: callers cannot see what each positional argument means, two
arguments of the same type can be swapped without a compiler error, and optional parts lead to many overloaded
constructors. A builder gives every part a named step, lets the parts arrive one at a time, and checks the rules
that involve several parts in one place, build(), before the object exists, so callers never hold an invalid or
half-built object. It also lets the product be immutable while the builder stays changeable. A good answer adds the
modern caveat: in languages with named arguments and defaults, such as Python, or options objects, such as
TypeScript, those features often replace a Builder, which is worth it only when parts accumulate or rules span
several parts.
Key takeaways
- A Builder collects parts through named steps and makes the product in one place,
build(), which checks every rule and reports every problem. build()must copy anything mutable it hands to the product; a frozen object holding a list it does not own can still change.- Use the language’s own tool first: keyword arguments and
copy.replace()in Python, options objects in TypeScript, functional options in Go, designated initializers for plain C++ values, records with a nested builder in Java. - A type-state builder in Rust turns a forgotten step into a compile error (E0599 for a missing method); keep it for the few rules that must never break.
- Skip the Builder when there are few parts, no rules across them, or when the language’s named arguments already do the job.
References
- Design Patterns: Elements of Reusable Object-Oriented Software (Builder) (Addison-Wesley (Pearson))
- Rust API Guidelines: builders enable construction of complex values (C-BUILDER) (The Rust Project)
- std::process::Command (The Rust Project)
- Rust compiler error index: E0599 (The Rust Project)
- Self-referential functions and the design of options (Rob Pike)
- Functional options for friendly APIs (Dave Cheney)
- copy: shallow and deep copy operations, including copy.replace() (Python Software Foundation)
- dataclasses: frozen instances (Python Software Foundation)
- JEP 395: Records (OpenJDK)
- C++ working draft: aggregate initialization ([dcl.init.aggr]) (ISO C++ committee (working draft))
- C++ working draft: class template expected ([expected]) (ISO C++ committee (working draft))
- Fluent Interface (Martin Fowler)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress