Low Level DesignDesign patterns
Builder pattern
The Builder pattern through three real examples, from HTTP requests to hotel bookings to a safe SQL query builder, with diagrams, live demos and code in five languages.
- Read first
- Object modeling
A builder is a separate object. It collects the parts of another object, one named step at a time.
Then it creates that object in a single build() call. The caller sets only the parts it cares
about, in any order. Everything else keeps a default. build() is the one place the finished object
is checked and created. So the object can be immutable, and is never seen half made.
You use builders every day. Think of StringBuilder, Java’s HttpRequest.newBuilder(), the request
builders in the AWS SDK for Java, protobuf messages, and SQL query builders such as jOOQ and
SQLAlchemy’s select(). This page builds in three steps. First a request with many optional fields.
Then an object with rules that span fields. Then a query builder whose job is safety.
The shape
Section titled “The shape”Every builder has the same parts:
- The product is the object being made, for example
HttpRequest. Its constructor is private or internal, so the builder is the way in. - The builder holds the required fields (passed to its entry point), the optional ones
with their defaults, one method per part, and
build(). - The caller chains the steps it needs and calls
build()once.
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Each step returns the builder. That’s what allows the chained a().b().build() style. That chaining
is called a fluent interface. It’s a separate idea from the builder, as the look-alikes table below
explains.
The original version, in brief
Section titled “The original version, in brief”The Gang of Four described a broader builder. A director owns the order of the steps, and calls them on a builder interface. Each concrete builder turns the same steps into a different representation. Here, one receipt writer produces plain text and HTML:
import java.util.List;
record Item(String name, int qty, int cents) {}
// The Gang of Four builder: an interface of construction steps. Each implementation turns// the same steps into a different representation.interface ReceiptBuilder { void title(String orderId); void line(Item item); void total(int cents);}
final class Money { static String fmt(int cents) { return String.format("%d.%02d", cents / 100, cents % 100); }}
final class TextReceipt implements ReceiptBuilder { private final StringBuilder out = new StringBuilder(); public void title(String orderId) { out.append("RECEIPT ").append(orderId); } public void line(Item i) { out.append(" | ").append(i.qty()).append(" x ").append(i.name()).append(" ").append(Money.fmt(i.qty() * i.cents())); } public void total(int cents) { out.append(" | TOTAL ").append(Money.fmt(cents)); } // The result is fetched from the concrete builder, because the representations // don't share a type: here a line of text, elsewhere HTML or a PDF. String result() { return out.toString(); }}
final class HtmlReceipt implements ReceiptBuilder { private final StringBuilder out = new StringBuilder(); public void title(String orderId) { out.append("<h1>Receipt ").append(orderId).append("</h1><ul>"); } public void line(Item i) { out.append("<li>").append(i.qty()).append(" x ").append(i.name()).append(": ").append(Money.fmt(i.qty() * i.cents())).append("</li>"); } public void total(int cents) { out.append("</ul><p>Total: ").append(Money.fmt(cents)).append("</p>"); } String result() { return out.toString(); }}
// The director owns the order of the steps, once. It doesn't know which format it makes.final class ReceiptWriter { static void write(String orderId, List<Item> items, ReceiptBuilder b) { b.title(orderId); int total = 0; for (Item i : items) { b.line(i); total += i.qty() * i.cents(); } b.total(total); }}
class Main { public static void main(String[] args) { List<Item> items = List.of(new Item("Mug", 2, 999), new Item("Tea", 1, 450));
TextReceipt text = new TextReceipt(); ReceiptWriter.write("ord-42", items, text); System.out.println(text.result()); // RECEIPT ord-42 | 2 x Mug 19.98 | 1 x Tea 4.50 | TOTAL 24.48
HtmlReceipt html = new HtmlReceipt(); ReceiptWriter.write("ord-42", items, html); System.out.println(html.result()); // <h1>Receipt ord-42</h1><ul><li>2 x Mug: 19.98</li><li>1 x Tea: 4.50</li></ul><p>Total: 24.48</p> }}from dataclasses import dataclassfrom typing import Protocol
@dataclass(frozen=True)class Item: name: str qty: int cents: int
def money(cents: int) -> str: return f"{cents // 100}.{cents % 100:02d}"
# The Gang of Four builder: an interface of construction steps. Each implementation turns# the same steps into a different representation.class ReceiptBuilder(Protocol): def title(self, order_id: str) -> None: ... def line(self, item: Item) -> None: ... def total(self, cents: int) -> None: ...
class TextReceipt: def __init__(self): self._out: list[str] = []
def title(self, order_id: str) -> None: self._out.append(f"RECEIPT {order_id}")
def line(self, i: Item) -> None: self._out.append(f" | {i.qty} x {i.name} {money(i.qty * i.cents)}")
def total(self, cents: int) -> None: self._out.append(f" | TOTAL {money(cents)}")
# The result is fetched from the concrete builder, because the representations # don't share a type: here a line of text, elsewhere HTML or a PDF. def result(self) -> str: return "".join(self._out)
class HtmlReceipt: def __init__(self): self._out: list[str] = []
def title(self, order_id: str) -> None: self._out.append(f"<h1>Receipt {order_id}</h1><ul>")
def line(self, i: Item) -> None: self._out.append(f"<li>{i.qty} x {i.name}: {money(i.qty * i.cents)}</li>")
def total(self, cents: int) -> None: self._out.append(f"</ul><p>Total: {money(cents)}</p>")
def result(self) -> str: return "".join(self._out)
# The director owns the order of the steps, once. It doesn't know which format it makes.def write_receipt(order_id: str, items: list[Item], b: ReceiptBuilder) -> None: b.title(order_id) total = 0 for i in items: b.line(i) total += i.qty * i.cents b.total(total)
if __name__ == "__main__": items = [Item("Mug", 2, 999), Item("Tea", 1, 450)]
text = TextReceipt() write_receipt("ord-42", items, text) print(text.result()) # RECEIPT ord-42 | 2 x Mug 19.98 | 1 x Tea 4.50 | TOTAL 24.48
html = HtmlReceipt() write_receipt("ord-42", items, html) print(html.result()) # <h1>Receipt ord-42</h1><ul><li>2 x Mug: 19.98</li><li>1 x Tea: 4.50</li></ul><p>Total: 24.48</p>using System;using System.Collections.Generic;using System.Text;
public sealed record Item(string Name, int Qty, int Cents);
// The Gang of Four builder: an interface of construction steps. Each implementation turns// the same steps into a different representation.public interface IReceiptBuilder{ void Title(string orderId); void Line(Item item); void Total(int cents);}
public static class Money{ public static string Fmt(int cents) => $"{cents / 100}.{cents % 100:00}";}
public sealed class TextReceipt : IReceiptBuilder{ private readonly StringBuilder _out = new(); public void Title(string orderId) => _out.Append("RECEIPT ").Append(orderId); public void Line(Item i) => _out.Append($" | {i.Qty} x {i.Name} {Money.Fmt(i.Qty * i.Cents)}"); public void Total(int cents) => _out.Append(" | TOTAL ").Append(Money.Fmt(cents)); // The result is fetched from the concrete builder, because the representations don't // share a type: here a line of text, elsewhere HTML or a PDF. public string Result() => _out.ToString();}
public sealed class HtmlReceipt : IReceiptBuilder{ private readonly StringBuilder _out = new(); public void Title(string orderId) => _out.Append($"<h1>Receipt {orderId}</h1><ul>"); public void Line(Item i) => _out.Append($"<li>{i.Qty} x {i.Name}: {Money.Fmt(i.Qty * i.Cents)}</li>"); public void Total(int cents) => _out.Append($"</ul><p>Total: {Money.Fmt(cents)}</p>"); public string Result() => _out.ToString();}
// The director owns the order of the steps, once. It doesn't know which format it makes.public static class ReceiptWriter{ public static void Write(string orderId, IReadOnlyList<Item> items, IReceiptBuilder b) { b.Title(orderId); var total = 0; foreach (var i in items) { b.Line(i); total += i.Qty * i.Cents; } b.Total(total); }}
public static class Program{ public static void Main() { var items = new[] { new Item("Mug", 2, 999), new Item("Tea", 1, 450) };
var text = new TextReceipt(); ReceiptWriter.Write("ord-42", items, text); Console.WriteLine(text.Result()); // RECEIPT ord-42 | 2 x Mug 19.98 | 1 x Tea 4.50 | TOTAL 24.48
var html = new HtmlReceipt(); ReceiptWriter.Write("ord-42", items, html); Console.WriteLine(html.Result()); // <h1>Receipt ord-42</h1><ul><li>2 x Mug: 19.98</li><li>1 x Tea: 4.50</li></ul><p>Total: 24.48</p> }}package main
import ( "fmt" "strings")
type Item struct { Name string Qty int Cents int}
func money(cents int) string { return fmt.Sprintf("%d.%02d", cents/100, cents%100) }
// ReceiptBuilder is the Gang of Four builder: an interface of construction steps. Each// implementation turns the same steps into a different representation.type ReceiptBuilder interface { Title(orderID string) Line(item Item) Total(cents int)}
type TextReceipt struct{ out strings.Builder }
func (r *TextReceipt) Title(id string) { r.out.WriteString("RECEIPT " + id) }func (r *TextReceipt) Line(i Item) { fmt.Fprintf(&r.out, " | %d x %s %s", i.Qty, i.Name, money(i.Qty*i.Cents))}func (r *TextReceipt) Total(cents int) { r.out.WriteString(" | TOTAL " + money(cents)) }
// Result is fetched from the concrete builder, because the representations don't share// a type: here a line of text, elsewhere HTML or a PDF.func (r *TextReceipt) Result() string { return r.out.String() }
type HtmlReceipt struct{ out strings.Builder }
func (r *HtmlReceipt) Title(id string) { r.out.WriteString("<h1>Receipt " + id + "</h1><ul>") }func (r *HtmlReceipt) Line(i Item) { fmt.Fprintf(&r.out, "<li>%d x %s: %s</li>", i.Qty, i.Name, money(i.Qty*i.Cents))}func (r *HtmlReceipt) Total(cents int) { r.out.WriteString("</ul><p>Total: " + money(cents) + "</p>") }func (r *HtmlReceipt) Result() string { return r.out.String() }
// WriteReceipt is the director: it owns the order of the steps, once, and doesn't know// which format it makes.func WriteReceipt(orderID string, items []Item, b ReceiptBuilder) { b.Title(orderID) total := 0 for _, i := range items { b.Line(i) total += i.Qty * i.Cents } b.Total(total)}
func main() { items := []Item{{"Mug", 2, 999}, {"Tea", 1, 450}}
text := &TextReceipt{} WriteReceipt("ord-42", items, text) fmt.Println(text.Result()) // RECEIPT ord-42 | 2 x Mug 19.98 | 1 x Tea 4.50 | TOTAL 24.48
html := &HtmlReceipt{} WriteReceipt("ord-42", items, html) fmt.Println(html.Result()) // <h1>Receipt ord-42</h1><ul><li>2 x Mug: 19.98</li><li>1 x Tea: 4.50</li></ul><p>Total: 24.48</p>}type Item = { readonly name: string; readonly qty: number; readonly cents: number };
const money = (cents: number) => `${Math.floor(cents / 100)}.${String(cents % 100).padStart(2, "0")}`;
// The Gang of Four builder: an interface of construction steps. Each implementation turns// the same steps into a different representation.interface ReceiptBuilder { title(orderId: string): void; line(item: Item): void; total(cents: number): void;}
class TextReceipt implements ReceiptBuilder { private out = ""; title(orderId: string) { this.out += `RECEIPT ${orderId}`; } line(i: Item) { this.out += ` | ${i.qty} x ${i.name} ${money(i.qty * i.cents)}`; } total(cents: number) { this.out += ` | TOTAL ${money(cents)}`; } // The result is fetched from the concrete builder, because the representations don't // share a type: here a line of text, elsewhere HTML or a PDF. result(): string { return this.out; }}
class HtmlReceipt implements ReceiptBuilder { private out = ""; title(orderId: string) { this.out += `<h1>Receipt ${orderId}</h1><ul>`; } line(i: Item) { this.out += `<li>${i.qty} x ${i.name}: ${money(i.qty * i.cents)}</li>`; } total(cents: number) { this.out += `</ul><p>Total: ${money(cents)}</p>`; } result(): string { return this.out; }}
// The director owns the order of the steps, once. It doesn't know which format it makes.function writeReceipt(orderId: string, items: readonly Item[], b: ReceiptBuilder): void { b.title(orderId); let total = 0; for (const i of items) { b.line(i); total += i.qty * i.cents; } b.total(total);}
const items: Item[] = [{ name: "Mug", qty: 2, cents: 999 }, { name: "Tea", qty: 1, cents: 450 }];
const text = new TextReceipt();writeReceipt("ord-42", items, text);console.log(text.result()); // RECEIPT ord-42 | 2 x Mug 19.98 | 1 x Tea 4.50 | TOTAL 24.48
const html = new HtmlReceipt();writeReceipt("ord-42", items, html);console.log(html.result()); // <h1>Receipt ord-42</h1><ul><li>2 x Mug: 19.98</li><li>1 x Tea: 4.50</li></ul><p>Total: 24.48</p>This form is still used for exporters and document generators: one walk over the data, many output formats. Most builders in everyday code are the simpler kind in the levels below. Joshua Bloch made that kind popular in Effective Java. It has one product, and no director. The client chains the steps itself.
Worked examples
Section titled “Worked examples”HTTP requests
- Named steps instead of positions
- Defaults in one place
- An immutable product
A payments service calls an external API. Each request has two required parts: a method and a URL. It has six optional ones: headers, a body, a timeout, a retry count, whether to follow redirects, and whether to verify the server’s TLS certificate. Most calls use the defaults. Payment calls change four of them.
Without the pattern
Section titled “Without the pattern”The class grows “telescoping” constructors: a short one, a longer one, and one with every parameter in order.
import java.util.Map;
// Without a builder. Every optional field multiplies the useful constructors, so the class// grows a few "telescoping" ones that each add parameters and pass defaults down, ending in// one that takes everything in order.final class HttpRequest { private final String method, url, body; private final Map<String, String> headers; private final int timeoutMs, retries; private final boolean followRedirects, verifyTls;
HttpRequest(String method, String url) { this(method, url, Map.of(), null); }
HttpRequest(String method, String url, Map<String, String> headers, String body) { this(method, url, headers, body, 10_000, 0, true, true); }
// Eight positional parameters. Two ints and two booleans sit side by side, so swapping // them still compiles, and nothing at the call site says which value is which. HttpRequest(String method, String url, Map<String, String> headers, String body, int timeoutMs, int retries, boolean followRedirects, boolean verifyTls) { this.method = method; this.url = url; this.headers = headers; this.body = body; this.timeoutMs = timeoutMs; this.retries = retries; this.followRedirects = followRedirects; this.verifyTls = verifyTls; }
String describe() { return method + " " + url + " headers=[" + String.join(", ", headers.keySet()) + "]" + " body=" + (body == null ? "none" : body.length() + " bytes") + " timeout=" + timeoutMs + "ms retries=" + retries + " followRedirects=" + followRedirects + " verifyTls=" + verifyTls; }}
class Main { public static void main(String[] args) { // Callers happy with the defaults get a short call. HttpRequest health = new HttpRequest("GET", "https://pay.example/health"); System.out.println(health.describe()); // GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
// A payment call needs a few non-defaults, so it must use the long constructor and // restate every value. The author meant "don't follow redirects, do verify TLS" // and wrote the two booleans in the wrong order. HttpRequest charge = new HttpRequest("POST", "https://pay.example/charges", Map.of("Idempotency-Key", "ord-42"), "{\"cents\":1999}", 5000, 3, true, false); System.out.println(charge.describe()); // POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=true verifyTls=false // It compiles, it passes against a test server, and it no longer checks certificates. }}# Without a builder. Python has no overloading, so "telescoping constructors" become one# __init__ whose parameters can all be passed by position. Every optional field adds one# more slot to count, and nothing forces callers to name them.class HttpRequest: # Eight positional parameters. Two ints and two booleans sit side by side, so swapping # them runs fine, and nothing at the call site says which value is which. def __init__(self, method, url, headers=None, body=None, timeout_ms=10_000, retries=0, follow_redirects=True, verify_tls=True): self.method = method self.url = url self.headers = headers or {} self.body = body self.timeout_ms = timeout_ms self.retries = retries self.follow_redirects = follow_redirects self.verify_tls = verify_tls
def describe(self) -> str: body = "none" if self.body is None else f"{len(self.body)} bytes" return (f"{self.method} {self.url} headers=[{', '.join(self.headers)}] body={body}" f" timeout={self.timeout_ms}ms retries={self.retries}" f" followRedirects={str(self.follow_redirects).lower()}" f" verifyTls={str(self.verify_tls).lower()}")
if __name__ == "__main__": # Callers happy with the defaults get a short call. health = HttpRequest("GET", "https://pay.example/health") print(health.describe()) # GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
# A payment call needs a few non-defaults, passed by position and restating every # value. The author meant "don't follow redirects, do verify TLS" and wrote the two # booleans in the wrong order. charge = HttpRequest("POST", "https://pay.example/charges", {"Idempotency-Key": "ord-42"}, '{"cents":1999}', 5000, 3, True, False) print(charge.describe()) # POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=true verifyTls=false # It runs, it passes against a test server, and it no longer checks certificates.using System;using System.Collections.Generic;using System.Linq;
// Without a builder. Every optional field multiplies the useful constructors, so the class// grows a few "telescoping" ones that each add parameters and pass defaults down, ending in// one that takes everything in order.public sealed class HttpRequest{ private readonly string _method, _url; private readonly string? _body; private readonly IReadOnlyList<KeyValuePair<string, string>> _headers; private readonly int _timeoutMs, _retries; private readonly bool _followRedirects, _verifyTls;
public HttpRequest(string method, string url) : this(method, url, Array.Empty<KeyValuePair<string, string>>(), null) { }
public HttpRequest(string method, string url, IReadOnlyList<KeyValuePair<string, string>> headers, string? body) : this(method, url, headers, body, 10_000, 0, true, true) { }
// Eight positional parameters. Two ints and two bools sit side by side, so swapping // them still compiles, and nothing at the call site says which value is which. // (C# named arguments would help here, but only if every caller remembers to use them.) public HttpRequest(string method, string url, IReadOnlyList<KeyValuePair<string, string>> headers, string? body, int timeoutMs, int retries, bool followRedirects, bool verifyTls) { _method = method; _url = url; _headers = headers; _body = body; _timeoutMs = timeoutMs; _retries = retries; _followRedirects = followRedirects; _verifyTls = verifyTls; }
public string Describe() => $"{_method} {_url} headers=[{string.Join(", ", _headers.Select(h => h.Key))}]" + $" body={(_body is null ? "none" : $"{_body.Length} bytes")}" + $" timeout={_timeoutMs}ms retries={_retries}" + $" followRedirects={(_followRedirects ? "true" : "false")} verifyTls={(_verifyTls ? "true" : "false")}";}
public static class Program{ public static void Main() { // Callers happy with the defaults get a short call. var health = new HttpRequest("GET", "https://pay.example/health"); Console.WriteLine(health.Describe()); // GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
// A payment call needs a few non-defaults, so it must use the long constructor and // restate every value. The author meant "don't follow redirects, do verify TLS" // and wrote the two bools in the wrong order. var charge = new HttpRequest("POST", "https://pay.example/charges", new[] { KeyValuePair.Create("Idempotency-Key", "ord-42") }, "{\"cents\":1999}", 5000, 3, true, false); Console.WriteLine(charge.Describe()); // POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=true verifyTls=false // It compiles, it passes against a test server, and it no longer checks certificates. }}package main
import ( "fmt" "strings")
type Header struct{ Name, Value string }
type HttpRequest struct { method, url, body string headers []Header timeoutMs, retries int followRedirects, verifyTls bool}
// Without a builder. Go has no overloading, so "telescoping constructors" become a family// of New... functions that each add parameters and pass defaults down, ending in one that// takes everything in order.func NewHttpRequest(method, url string) HttpRequest { return NewHttpRequestWithBody(method, url, nil, "")}
func NewHttpRequestWithBody(method, url string, headers []Header, body string) HttpRequest { return NewHttpRequestFull(method, url, headers, body, 10_000, 0, true, true)}
// Eight positional parameters. Two ints and two bools sit side by side, so swapping them// still compiles, and nothing at the call site says which value is which.func NewHttpRequestFull(method, url string, headers []Header, body string, timeoutMs, retries int, followRedirects, verifyTls bool) HttpRequest { return HttpRequest{method, url, body, headers, timeoutMs, retries, followRedirects, verifyTls}}
func (r HttpRequest) Describe() string { names := make([]string, len(r.headers)) for i, h := range r.headers { names[i] = h.Name } body := "none" if r.body != "" { body = fmt.Sprintf("%d bytes", len(r.body)) } return fmt.Sprintf("%s %s headers=[%s] body=%s timeout=%dms retries=%d followRedirects=%t verifyTls=%t", r.method, r.url, strings.Join(names, ", "), body, r.timeoutMs, r.retries, r.followRedirects, r.verifyTls)}
func main() { // Callers happy with the defaults get a short call. health := NewHttpRequest("GET", "https://pay.example/health") fmt.Println(health.Describe()) // GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
// A payment call needs a few non-defaults, so it must use the long function and // restate every value. The author meant "don't follow redirects, do verify TLS" and // wrote the two bools in the wrong order. charge := NewHttpRequestFull("POST", "https://pay.example/charges", []Header{{"Idempotency-Key", "ord-42"}}, `{"cents":1999}`, 5000, 3, true, false) fmt.Println(charge.Describe()) // POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=true verifyTls=false // It compiles, it passes against a test server, and it no longer checks certificates.}// Without a builder. TypeScript has one constructor per class, so "telescoping" becomes a// constructor whose optional parameters can all be passed by position. Every optional// field adds one more slot to count, and nothing forces callers to name them.class HttpRequest { // Eight positional parameters. Two numbers and two booleans sit side by side, so // swapping them still type-checks, and nothing at the call site says which is which. constructor( readonly method: string, readonly url: string, readonly headers: Record<string, string> = {}, readonly body: string | null = null, readonly timeoutMs = 10_000, readonly retries = 0, readonly followRedirects = true, readonly verifyTls = true, ) {}
describe(): string { const body = this.body === null ? "none" : `${this.body.length} bytes`; return `${this.method} ${this.url} headers=[${Object.keys(this.headers).join(", ")}] body=${body}` + ` timeout=${this.timeoutMs}ms retries=${this.retries}` + ` followRedirects=${this.followRedirects} verifyTls=${this.verifyTls}`; }}
// Callers happy with the defaults get a short call.const health = new HttpRequest("GET", "https://pay.example/health");console.log(health.describe());// GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
// A payment call needs a few non-defaults, passed by position and restating every value.// The author meant "don't follow redirects, do verify TLS" and wrote the two booleans in// the wrong order.const charge = new HttpRequest("POST", "https://pay.example/charges", { "Idempotency-Key": "ord-42" }, '{"cents":1999}', 5000, 3, true, false);console.log(charge.describe());// POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=true verifyTls=false// It type-checks, it passes against a test server, and it no longer checks certificates.The payment call needs a timeout, so it must use the eight-argument constructor and restate
every default it doesn’t care about. Its last two arguments are two booleans, and the author
wrote them in the wrong order. The code compiles, the tests pass against a test server, and in
production the client no longer checks certificates. A reviewer reading 5000, 3, true, false
can’t see the mistake either.
With a builder
Section titled “With a builder”Give the request a builder. The required parts go in the entry point, the optional parts each get a named method, and the defaults live in the builder:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Toggle the optional parts. Then switch to the positional constructor and swap the booleans:
Call site
Built HttpRequest immutable
import java.util.Collections;import java.util.LinkedHashMap;import java.util.Map;
// The product. Immutable: every field is final and set once from a finished builder, so a// request can be shared, logged and retried without anyone changing it underneath.final class HttpRequest { private final String method, url, body; private final Map<String, String> headers; private final int timeoutMs, retries; private final boolean followRedirects, verifyTls;
// Private: the builder is the only way in, so defaults and checks live in one place. private HttpRequest(Builder b) { method = b.method; url = b.url; body = b.body; // Copy, so a later change to the builder can't reach a request already built. headers = Collections.unmodifiableMap(new LinkedHashMap<>(b.headers)); timeoutMs = b.timeoutMs; retries = b.retries; followRedirects = b.followRedirects; verifyTls = b.verifyTls; }
// Required fields are the entry point's parameters, so they can't be forgotten. static Builder builder(String method, String url) { return new Builder(method, url); }
String describe() { return method + " " + url + " headers=[" + String.join(", ", headers.keySet()) + "]" + " body=" + (body == null ? "none" : body.length() + " bytes") + " timeout=" + timeoutMs + "ms retries=" + retries + " followRedirects=" + followRedirects + " verifyTls=" + verifyTls; }
static final class Builder { private final String method, url; // Defaults live here, once, and they are the safe ones: TLS checked, finite timeout. private final Map<String, String> headers = new LinkedHashMap<>(); private String body; private int timeoutMs = 10_000; private int retries = 0; private boolean followRedirects = true; private boolean verifyTls = true;
private Builder(String method, String url) { this.method = method; this.url = url; }
// Each step names its value at the call site and returns the builder, so calls chain // and there is no position to get wrong. Builder header(String name, String value) { headers.put(name, value); return this; }
Builder body(String body) { this.body = body; return this; }
// Single-field checks fail at the step that is wrong, with the bad value in the message. Builder timeoutMs(int ms) { if (ms <= 0) throw new IllegalArgumentException("timeout must be > 0, got " + ms); this.timeoutMs = ms; return this; }
Builder retries(int n) { if (n < 0) throw new IllegalArgumentException("retries must be >= 0, got " + n); this.retries = n; return this; }
Builder followRedirects(boolean on) { this.followRedirects = on; return this; }
Builder verifyTls(boolean on) { this.verifyTls = on; return this; }
HttpRequest build() { return new HttpRequest(this); } }}
class Main { public static void main(String[] args) { // Only the non-defaults appear, each with its name. Nothing to swap. HttpRequest charge = HttpRequest.builder("POST", "https://pay.example/charges") .header("Idempotency-Key", "ord-42") .body("{\"cents\":1999}") .timeoutMs(5000) .retries(3) .followRedirects(false) .build(); System.out.println(charge.describe()); // POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=false verifyTls=true
// build() copies, so the first request keeps its state when the builder moves on. HttpRequest.Builder health = HttpRequest.builder("GET", "https://pay.example/health"); HttpRequest first = health.build(); HttpRequest second = health.header("X-Trace", "on").build(); System.out.println(first.describe()); // GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true System.out.println(second.describe()); // GET https://pay.example/health headers=[X-Trace] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
try { HttpRequest.builder("GET", "https://pay.example/health").retries(-1).build(); } catch (IllegalArgumentException e) { System.out.println("error: " + e.getMessage()); // error: retries must be >= 0, got -1 } }}from __future__ import annotations
from dataclasses import dataclass
# The product. frozen=True makes it immutable: a request can be shared, logged and retried# without anyone changing it underneath. Headers are a tuple of pairs, so they're frozen too.@dataclass(frozen=True)class HttpRequest: method: str url: str headers: tuple[tuple[str, str], ...] body: str | None timeout_ms: int retries: int follow_redirects: bool verify_tls: bool
# Required fields are the entry point's parameters, so they can't be forgotten. @staticmethod def builder(method: str, url: str) -> HttpRequestBuilder: return HttpRequestBuilder(method, url)
def describe(self) -> str: body = "none" if self.body is None else f"{len(self.body)} bytes" return (f"{self.method} {self.url} headers=[{', '.join(k for k, _ in self.headers)}] body={body}" f" timeout={self.timeout_ms}ms retries={self.retries}" f" followRedirects={str(self.follow_redirects).lower()}" f" verifyTls={str(self.verify_tls).lower()}")
# In Python a builder class is often unnecessary: keyword-only arguments with defaults on# a frozen dataclass give named, unswappable fields. It earns its place when the object is# assembled across several steps, as here, or when build() has rules to check.class HttpRequestBuilder: def __init__(self, method: str, url: str): self._method = method self._url = url # Defaults live here, once, and they are the safe ones: TLS checked, finite timeout. self._headers: dict[str, str] = {} self._body: str | None = None self._timeout_ms = 10_000 self._retries = 0 self._follow_redirects = True self._verify_tls = True
# Each step names its value at the call site and returns the builder, so calls chain # and there is no position to get wrong. def header(self, name: str, value: str) -> HttpRequestBuilder: self._headers[name] = value return self
def body(self, body: str) -> HttpRequestBuilder: self._body = body return self
# Single-field checks fail at the step that is wrong, with the bad value in the message. def timeout_ms(self, ms: int) -> HttpRequestBuilder: if ms <= 0: raise ValueError(f"timeout must be > 0, got {ms}") self._timeout_ms = ms return self
def retries(self, n: int) -> HttpRequestBuilder: if n < 0: raise ValueError(f"retries must be >= 0, got {n}") self._retries = n return self
def follow_redirects(self, on: bool) -> HttpRequestBuilder: self._follow_redirects = on return self
def verify_tls(self, on: bool) -> HttpRequestBuilder: self._verify_tls = on return self
def build(self) -> HttpRequest: # tuple(...) copies, so a later change to the builder can't reach a built request. return HttpRequest(self._method, self._url, tuple(self._headers.items()), self._body, self._timeout_ms, self._retries, self._follow_redirects, self._verify_tls)
if __name__ == "__main__": # Only the non-defaults appear, each with its name. Nothing to swap. charge = (HttpRequest.builder("POST", "https://pay.example/charges") .header("Idempotency-Key", "ord-42") .body('{"cents":1999}') .timeout_ms(5000) .retries(3) .follow_redirects(False) .build()) print(charge.describe()) # POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=false verifyTls=true
# build() copies, so the first request keeps its state when the builder moves on. health = HttpRequest.builder("GET", "https://pay.example/health") first = health.build() second = health.header("X-Trace", "on").build() print(first.describe()) # GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true print(second.describe()) # GET https://pay.example/health headers=[X-Trace] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
try: HttpRequest.builder("GET", "https://pay.example/health").retries(-1).build() except ValueError as e: print(f"error: {e}") # error: retries must be >= 0, got -1using System;using System.Collections.Generic;using System.Linq;
// The product. Immutable: get-only properties set once from a finished builder, so a// request can be shared, logged and retried without anyone changing it underneath.public sealed class HttpRequest{ public string Method { get; } public string Url { get; } public IReadOnlyList<KeyValuePair<string, string>> Headers { get; } public string? Body { get; } public int TimeoutMs { get; } public int Retries { get; } public bool FollowRedirects { get; } public bool VerifyTls { get; }
// Private: the builder is the only way in, so defaults and checks live in one place. private HttpRequest(Builder b) { Method = b.Method; Url = b.Url; // Copy, so a later change to the builder can't reach a request already built. Headers = b.HeaderList.ToArray(); Body = b.BodyText; TimeoutMs = b.Timeout; Retries = b.RetryCount; FollowRedirects = b.Redirects; VerifyTls = b.Tls; }
// Required fields are the entry point's parameters, so they can't be forgotten. public static Builder CreateBuilder(string method, string url) => new(method, url);
public string Describe() => $"{Method} {Url} headers=[{string.Join(", ", Headers.Select(h => h.Key))}]" + $" body={(Body is null ? "none" : $"{Body.Length} bytes")}" + $" timeout={TimeoutMs}ms retries={Retries}" + $" followRedirects={(FollowRedirects ? "true" : "false")} verifyTls={(VerifyTls ? "true" : "false")}";
public sealed class Builder { internal readonly string Method, Url; // Defaults live here, once, and they are the safe ones: TLS checked, finite timeout. internal readonly List<KeyValuePair<string, string>> HeaderList = new(); internal string? BodyText; internal int Timeout = 10_000; internal int RetryCount = 0; internal bool Redirects = true; internal bool Tls = true;
internal Builder(string method, string url) { Method = method; Url = url; }
// Each step names its value at the call site and returns the builder, so calls // chain and there is no position to get wrong. public Builder Header(string name, string value) { HeaderList.Add(KeyValuePair.Create(name, value)); return this; }
public Builder Body(string body) { BodyText = body; return this; }
// Single-field checks fail at the step that is wrong, with the bad value in the message. public Builder TimeoutMs(int ms) { if (ms <= 0) throw new ArgumentException($"timeout must be > 0, got {ms}"); Timeout = ms; return this; }
public Builder Retries(int n) { if (n < 0) throw new ArgumentException($"retries must be >= 0, got {n}"); RetryCount = n; return this; }
public Builder FollowRedirects(bool on) { Redirects = on; return this; }
public Builder VerifyTls(bool on) { Tls = on; return this; }
public HttpRequest Build() => new(this); }}
public static class Program{ public static void Main() { // Only the non-defaults appear, each with its name. Nothing to swap. var charge = HttpRequest.CreateBuilder("POST", "https://pay.example/charges") .Header("Idempotency-Key", "ord-42") .Body("{\"cents\":1999}") .TimeoutMs(5000) .Retries(3) .FollowRedirects(false) .Build(); Console.WriteLine(charge.Describe()); // POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=false verifyTls=true
// Build() copies, so the first request keeps its state when the builder moves on. var health = HttpRequest.CreateBuilder("GET", "https://pay.example/health"); var first = health.Build(); var second = health.Header("X-Trace", "on").Build(); Console.WriteLine(first.Describe()); // GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true Console.WriteLine(second.Describe()); // GET https://pay.example/health headers=[X-Trace] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
try { HttpRequest.CreateBuilder("GET", "https://pay.example/health").Retries(-1).Build(); } catch (ArgumentException e) { Console.WriteLine("error: " + e.Message); // error: retries must be >= 0, got -1 } }}package main
import ( "fmt" "strings")
type Header struct{ Name, Value string }
// HttpRequest is the product. Its fields are unexported and it has no setters, so code// outside this package can't change a request after it's built: it can be shared, logged// and retried safely.type HttpRequest struct { method, url, body string headers []Header timeoutMs, retries int followRedirects, verifyTls bool}
func (r HttpRequest) Describe() string { names := make([]string, len(r.headers)) for i, h := range r.headers { names[i] = h.Name } body := "none" if r.body != "" { body = fmt.Sprintf("%d bytes", len(r.body)) } return fmt.Sprintf("%s %s headers=[%s] body=%s timeout=%dms retries=%d followRedirects=%t verifyTls=%t", r.method, r.url, strings.Join(names, ", "), body, r.timeoutMs, r.retries, r.followRedirects, r.verifyTls)}
// RequestBuilder assembles an HttpRequest step by step. (Idiomatic Go more often uses// functional options for this; see "In each language". A builder fits when the value is// assembled across several places before it's finished.)type RequestBuilder struct { req HttpRequest // Go methods can't throw, and a chain can't stop to return an error, so the first // error is remembered and returned by Build. Later steps become no-ops. err error}
// Required fields are the entry point's parameters, so they can't be forgotten. Defaults// live here, once, and they are the safe ones: TLS checked, finite timeout.func NewRequest(method, url string) *RequestBuilder { return &RequestBuilder{req: HttpRequest{ method: method, url: url, timeoutMs: 10_000, followRedirects: true, verifyTls: true, }}}
// Each step names its value at the call site and returns the builder, so calls chain and// there is no position to get wrong.func (b *RequestBuilder) Header(name, value string) *RequestBuilder { b.req.headers = append(b.req.headers, Header{name, value}) return b}
func (b *RequestBuilder) Body(body string) *RequestBuilder { b.req.body = body return b}
// Single-field checks record the bad value in the message.func (b *RequestBuilder) TimeoutMs(ms int) *RequestBuilder { if ms <= 0 && b.err == nil { b.err = fmt.Errorf("timeout must be > 0, got %d", ms) } b.req.timeoutMs = ms return b}
func (b *RequestBuilder) Retries(n int) *RequestBuilder { if n < 0 && b.err == nil { b.err = fmt.Errorf("retries must be >= 0, got %d", n) } b.req.retries = n return b}
func (b *RequestBuilder) FollowRedirects(on bool) *RequestBuilder { b.req.followRedirects = on return b}
func (b *RequestBuilder) VerifyTls(on bool) *RequestBuilder { b.req.verifyTls = on return b}
func (b *RequestBuilder) Build() (HttpRequest, error) { if b.err != nil { return HttpRequest{}, b.err } r := b.req // Copy the slice: the struct copy shares its backing array, so without this a later // Header() call on the builder could reach a request already built. r.headers = append([]Header(nil), b.req.headers...) return r, nil}
func main() { // Only the non-defaults appear, each with its name. Nothing to swap. charge, _ := NewRequest("POST", "https://pay.example/charges"). Header("Idempotency-Key", "ord-42"). Body(`{"cents":1999}`). TimeoutMs(5000). Retries(3). FollowRedirects(false). Build() fmt.Println(charge.Describe()) // POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=false verifyTls=true
// Build copies, so the first request keeps its state when the builder moves on. health := NewRequest("GET", "https://pay.example/health") first, _ := health.Build() second, _ := health.Header("X-Trace", "on").Build() fmt.Println(first.Describe()) // GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true fmt.Println(second.Describe()) // GET https://pay.example/health headers=[X-Trace] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
if _, err := NewRequest("GET", "https://pay.example/health").Retries(-1).Build(); err != nil { fmt.Println("error:", err) // error: retries must be >= 0, got -1 }}// The product. readonly fields and a frozen headers object make it immutable, so a request// can be shared, logged and retried without anyone changing it underneath.class HttpRequest { readonly headers: Readonly<Record<string, string>>;
// Only the builder calls this (TypeScript can't make it private to another class, so // the convention is: go through HttpRequest.builder). constructor( readonly method: string, readonly url: string, headers: Record<string, string>, readonly body: string | null, readonly timeoutMs: number, readonly retries: number, readonly followRedirects: boolean, readonly verifyTls: boolean, ) { // Copy and freeze, so a later change to the builder can't reach a request already built. this.headers = Object.freeze({ ...headers }); Object.freeze(this); }
// Required fields are the entry point's parameters, so they can't be forgotten. static builder(method: string, url: string): HttpRequestBuilder { return new HttpRequestBuilder(method, url); }
describe(): string { const body = this.body === null ? "none" : `${this.body.length} bytes`; return `${this.method} ${this.url} headers=[${Object.keys(this.headers).join(", ")}] body=${body}` + ` timeout=${this.timeoutMs}ms retries=${this.retries}` + ` followRedirects=${this.followRedirects} verifyTls=${this.verifyTls}`; }}
// In TypeScript a builder class is often unnecessary: one options object with defaults// gives named, unswappable fields. It earns its place when the object is assembled across// several steps, as here, or when build() has rules to check.class HttpRequestBuilder { // Defaults live here, once, and they are the safe ones: TLS checked, finite timeout. private readonly headers: Record<string, string> = {}; private bodyText: string | null = null; private timeout = 10_000; private retryCount = 0; private redirects = true; private tls = true;
constructor(private readonly method: string, private readonly url: string) {}
// Each step names its value at the call site and returns the builder, so calls chain // and there is no position to get wrong. header(name: string, value: string): this { this.headers[name] = value; return this; }
body(body: string): this { this.bodyText = body; return this; }
// Single-field checks fail at the step that is wrong, with the bad value in the message. timeoutMs(ms: number): this { if (ms <= 0) throw new RangeError(`timeout must be > 0, got ${ms}`); this.timeout = ms; return this; }
retries(n: number): this { if (n < 0) throw new RangeError(`retries must be >= 0, got ${n}`); this.retryCount = n; return this; }
followRedirects(on: boolean): this { this.redirects = on; return this; }
verifyTls(on: boolean): this { this.tls = on; return this; }
build(): HttpRequest { return new HttpRequest(this.method, this.url, this.headers, this.bodyText, this.timeout, this.retryCount, this.redirects, this.tls); }}
// Only the non-defaults appear, each with its name. Nothing to swap.const charge = HttpRequest.builder("POST", "https://pay.example/charges") .header("Idempotency-Key", "ord-42") .body('{"cents":1999}') .timeoutMs(5000) .retries(3) .followRedirects(false) .build();console.log(charge.describe());// POST https://pay.example/charges headers=[Idempotency-Key] body=14 bytes timeout=5000ms retries=3 followRedirects=false verifyTls=true
// build() copies, so the first request keeps its state when the builder moves on.const health = HttpRequest.builder("GET", "https://pay.example/health");const first = health.build();const second = health.header("X-Trace", "on").build();console.log(first.describe());// GET https://pay.example/health headers=[] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=trueconsole.log(second.describe());// GET https://pay.example/health headers=[X-Trace] body=none timeout=10000ms retries=0 followRedirects=true verifyTls=true
try { HttpRequest.builder("GET", "https://pay.example/health").retries(-1).build();} catch (e) { console.log(`error: ${(e as Error).message}`); // error: retries must be >= 0, got -1}What to notice
Section titled “What to notice”- The call site says what it means.
.followRedirects(false)can’t be confused with.verifyTls(false). Only the non-defaults appear, so a reader sees what’s special about this request. - Required parts can’t be forgotten. They are parameters of
builder(method, url), so a request without a URL doesn’t compile. Putting them in setters would turn a compile error into a run-time one. - Defaults live in one place, and they are the safe ones. TLS on, a finite timeout. The dangerous setting has to be asked for by name, where a reviewer will see it.
- The product is immutable. Its constructor is private, and takes the finished builder.
build()copies the header map. So changing the builder later can’t reach a request that was already built, and perhaps already sent to a retry queue. - Bad values fail at the step that’s wrong.
retries(-1)throws immediately, with the bad value in the message. Go can’t throw from a chained call, so its builder remembers the first error and returns it fromBuild().
This constructor takes nine arguments and we keep getting bugs from callers passing them in the wrong order. How would you fix it?
Whether you reach for a builder, with required fields in its entry point, defaults inside the builder, and an immutable product. And whether you also mention named arguments or an options object, where the language has them. The red flag is a JavaBean with a no-argument constructor and nine setters. It fixes the naming, but leaves a mutable object that can be used half-built.
The request has two required fields and six optional ones, and two of those are booleans side by side. I’ll put a builder in front. The required fields go in builder(). Each optional one gets a named method. The defaults live in the builder. And build() returns an immutable request.
Hotel bookings
- Rules that span fields
- Every error at once
- Valid by construction
- toBuilder() for changed copies
A hotel booking has a room type, check-in and check-out dates, a number of adults and children, and an optional promo code or corporate rate. The rules involve more than one field:
- Check-out must be after check-in, and a stay is at most 30 nights.
- There’s at least one adult, and the guests fit the room. A standard room sleeps 2, and a family room 4.
- a promo code and a corporate rate can’t be combined.
Guests change bookings often: new dates, a smaller room, one more child.
Without the pattern
Section titled “Without the pattern”A mutable Booking with setters, each checking what it can see:
import java.time.LocalDate;import java.time.temporal.ChronoUnit;
enum RoomType { STANDARD(2), FAMILY(4);
final int capacity; RoomType(int capacity) { this.capacity = capacity; } String label() { return name().toLowerCase(); }}
// Without a builder: a mutable booking whose setters each check what they can see.// A rule that spans two fields has to pick one setter to live in, and that setter only// knows the other field's *current* value, which may be about to change.final class Booking { private RoomType room; private LocalDate checkIn, checkOut; private int adults, children;
void setRoom(RoomType room) { // Nothing to check against yet, or so it seems: the guest count is checked in // setChildren, so changing the room later slips past it. this.room = room; }
void setCheckIn(LocalDate d) { if (checkOut != null && !checkOut.isAfter(d)) throw new IllegalArgumentException("check-out (" + checkOut + ") must be after check-in (" + d + ")"); this.checkIn = d; }
void setCheckOut(LocalDate d) { if (checkIn != null && !d.isAfter(checkIn)) throw new IllegalArgumentException("check-out (" + d + ") must be after check-in (" + checkIn + ")"); this.checkOut = d; }
void setAdults(int n) { this.adults = n; }
void setChildren(int n) { if (room != null && adults + n > room.capacity) throw new IllegalArgumentException((adults + n) + " guests exceed the " + room.label() + " room capacity of " + room.capacity); this.children = n; }
String describe() { long nights = ChronoUnit.DAYS.between(checkIn, checkOut); return room.label() + " room, " + checkIn + " to " + checkOut + " (" + nights + " nights), " + adults + " adults + " + children + " children"; }}
class Main { public static void main(String[] args) { Booking b = new Booking(); b.setRoom(RoomType.FAMILY); b.setCheckIn(LocalDate.parse("2026-11-03")); b.setCheckOut(LocalDate.parse("2026-11-06")); b.setAdults(2); b.setChildren(2); System.out.println(b.describe()); // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children
// The guest moves the stay one week later. Setting check-in first is the natural // order, but for a moment the booking would end before it starts, so it throws. try { b.setCheckIn(LocalDate.parse("2026-11-10")); b.setCheckOut(LocalDate.parse("2026-11-13")); } catch (IllegalArgumentException e) { System.out.println("error: " + e.getMessage()); // error: check-out (2026-11-06) must be after check-in (2026-11-10) }
// Downgrading the room checks nothing, because the capacity rule lives in another // setter. The object is now invalid, and anyone holding it sees the change. b.setRoom(RoomType.STANDARD); System.out.println(b.describe()); // standard room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children }}from datetime import datefrom enum import Enum
class RoomType(Enum): STANDARD = 2 FAMILY = 4
@property def capacity(self) -> int: return self.value
@property def label(self) -> str: return self.name.lower()
# Without a builder: a mutable booking whose setters each check what they can see.# A rule that spans two fields has to pick one setter to live in, and that setter only# knows the other field's *current* value, which may be about to change.class Booking: def __init__(self): self.room = None self.check_in = None self.check_out = None self.adults = 0 self.children = 0
def set_room(self, room: RoomType) -> None: # Nothing to check against yet, or so it seems: the guest count is checked in # set_children, so changing the room later slips past it. self.room = room
def set_check_in(self, d: date) -> None: if self.check_out is not None and not self.check_out > d: raise ValueError(f"check-out ({self.check_out}) must be after check-in ({d})") self.check_in = d
def set_check_out(self, d: date) -> None: if self.check_in is not None and not d > self.check_in: raise ValueError(f"check-out ({d}) must be after check-in ({self.check_in})") self.check_out = d
def set_adults(self, n: int) -> None: self.adults = n
def set_children(self, n: int) -> None: if self.room is not None and self.adults + n > self.room.capacity: raise ValueError(f"{self.adults + n} guests exceed the {self.room.label} room capacity of {self.room.capacity}") self.children = n
def describe(self) -> str: nights = (self.check_out - self.check_in).days return (f"{self.room.label} room, {self.check_in} to {self.check_out} ({nights} nights), " f"{self.adults} adults + {self.children} children")
if __name__ == "__main__": b = Booking() b.set_room(RoomType.FAMILY) b.set_check_in(date(2026, 11, 3)) b.set_check_out(date(2026, 11, 6)) b.set_adults(2) b.set_children(2) print(b.describe()) # family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children
# The guest moves the stay one week later. Setting check-in first is the natural # order, but for a moment the booking would end before it starts, so it raises. try: b.set_check_in(date(2026, 11, 10)) b.set_check_out(date(2026, 11, 13)) except ValueError as e: print(f"error: {e}") # error: check-out (2026-11-06) must be after check-in (2026-11-10)
# Downgrading the room checks nothing, because the capacity rule lives in another # setter. The object is now invalid, and anyone holding it sees the change. b.set_room(RoomType.STANDARD) print(b.describe()) # standard room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 childrenusing System;
public sealed record RoomType(string Label, int Capacity){ public static readonly RoomType Standard = new("standard", 2); public static readonly RoomType Family = new("family", 4);}
// Without a builder: a mutable booking whose setters each check what they can see.// A rule that spans two fields has to pick one setter to live in, and that setter only// knows the other field's *current* value, which may be about to change.public sealed class Booking{ private RoomType? _room; private DateOnly? _checkIn, _checkOut; private int _adults, _children;
public void SetRoom(RoomType room) { // Nothing to check against yet, or so it seems: the guest count is checked in // SetChildren, so changing the room later slips past it. _room = room; }
public void SetCheckIn(DateOnly d) { if (_checkOut is { } o && o <= d) throw new InvalidOperationException($"check-out ({o:yyyy-MM-dd}) must be after check-in ({d:yyyy-MM-dd})"); _checkIn = d; }
public void SetCheckOut(DateOnly d) { if (_checkIn is { } i && d <= i) throw new InvalidOperationException($"check-out ({d:yyyy-MM-dd}) must be after check-in ({i:yyyy-MM-dd})"); _checkOut = d; }
public void SetAdults(int n) => _adults = n;
public void SetChildren(int n) { if (_room is not null && _adults + n > _room.Capacity) throw new InvalidOperationException($"{_adults + n} guests exceed the {_room.Label} room capacity of {_room.Capacity}"); _children = n; }
public string Describe() { DateOnly i = _checkIn!.Value, o = _checkOut!.Value; return $"{_room!.Label} room, {i:yyyy-MM-dd} to {o:yyyy-MM-dd} ({o.DayNumber - i.DayNumber} nights), " + $"{_adults} adults + {_children} children"; }}
public static class Program{ public static void Main() { var b = new Booking(); b.SetRoom(RoomType.Family); b.SetCheckIn(new DateOnly(2026, 11, 3)); b.SetCheckOut(new DateOnly(2026, 11, 6)); b.SetAdults(2); b.SetChildren(2); Console.WriteLine(b.Describe()); // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children
// The guest moves the stay one week later. Setting check-in first is the natural // order, but for a moment the booking would end before it starts, so it throws. try { b.SetCheckIn(new DateOnly(2026, 11, 10)); b.SetCheckOut(new DateOnly(2026, 11, 13)); } catch (InvalidOperationException e) { Console.WriteLine("error: " + e.Message); // error: check-out (2026-11-06) must be after check-in (2026-11-10) }
// Downgrading the room checks nothing, because the capacity rule lives in another // setter. The object is now invalid, and anyone holding it sees the change. b.SetRoom(RoomType.Standard); Console.WriteLine(b.Describe()); // standard room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children }}package main
import ( "fmt" "time")
type RoomType struct { Label string Capacity int}
var ( Standard = RoomType{"standard", 2} Family = RoomType{"family", 4})
func day(s string) time.Time { t, err := time.Parse(time.DateOnly, s) if err != nil { panic(err) } return t}
// Booking without a builder: a mutable struct whose setters each check what they can see.// A rule that spans two fields has to pick one setter to live in, and that setter only// knows the other field's *current* value, which may be about to change.type Booking struct { room *RoomType checkIn, checkOut time.Time adults, children int}
func (b *Booking) SetRoom(r RoomType) { // Nothing to check against yet, or so it seems: the guest count is checked in // SetChildren, so changing the room later slips past it. b.room = &r}
func (b *Booking) SetCheckIn(d time.Time) error { if !b.checkOut.IsZero() && !b.checkOut.After(d) { return fmt.Errorf("check-out (%s) must be after check-in (%s)", b.checkOut.Format(time.DateOnly), d.Format(time.DateOnly)) } b.checkIn = d return nil}
func (b *Booking) SetCheckOut(d time.Time) error { if !b.checkIn.IsZero() && !d.After(b.checkIn) { return fmt.Errorf("check-out (%s) must be after check-in (%s)", d.Format(time.DateOnly), b.checkIn.Format(time.DateOnly)) } b.checkOut = d return nil}
func (b *Booking) SetAdults(n int) { b.adults = n }
func (b *Booking) SetChildren(n int) error { if b.room != nil && b.adults+n > b.room.Capacity { return fmt.Errorf("%d guests exceed the %s room capacity of %d", b.adults+n, b.room.Label, b.room.Capacity) } b.children = n return nil}
func (b *Booking) Describe() string { nights := int(b.checkOut.Sub(b.checkIn).Hours() / 24) return fmt.Sprintf("%s room, %s to %s (%d nights), %d adults + %d children", b.room.Label, b.checkIn.Format(time.DateOnly), b.checkOut.Format(time.DateOnly), nights, b.adults, b.children)}
func main() { b := &Booking{} b.SetRoom(Family) b.SetCheckIn(day("2026-11-03")) b.SetCheckOut(day("2026-11-06")) b.SetAdults(2) b.SetChildren(2) fmt.Println(b.Describe()) // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children
// The guest moves the stay one week later. Setting check-in first is the natural // order, but for a moment the booking would end before it starts, so it fails. if err := b.SetCheckIn(day("2026-11-10")); err != nil { fmt.Println("error:", err) // error: check-out (2026-11-06) must be after check-in (2026-11-10) } else { b.SetCheckOut(day("2026-11-13")) }
// Downgrading the room checks nothing, because the capacity rule lives in another // setter. The struct is now invalid, and anyone holding the pointer sees the change. b.SetRoom(Standard) fmt.Println(b.Describe()) // standard room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children}// Dates are ISO "YYYY-MM-DD" strings: they compare correctly as strings, and// Date.parse reads them as UTC midnight, so night counts don't shift with time zones.const nightsBetween = (a: string, b: string) => (Date.parse(b) - Date.parse(a)) / 86_400_000;
const RoomType = { STANDARD: { label: "standard", capacity: 2 }, FAMILY: { label: "family", capacity: 4 },} as const;type RoomType = (typeof RoomType)[keyof typeof RoomType];
// Without a builder: a mutable booking whose setters each check what they can see.// A rule that spans two fields has to pick one setter to live in, and that setter only// knows the other field's *current* value, which may be about to change.class Booking { private room: RoomType | null = null; private checkIn: string | null = null; private checkOut: string | null = null; private adults = 0; private children = 0;
setRoom(room: RoomType): void { // Nothing to check against yet, or so it seems: the guest count is checked in // setChildren, so changing the room later slips past it. this.room = room; }
setCheckIn(d: string): void { if (this.checkOut !== null && !(this.checkOut > d)) throw new Error(`check-out (${this.checkOut}) must be after check-in (${d})`); this.checkIn = d; }
setCheckOut(d: string): void { if (this.checkIn !== null && !(d > this.checkIn)) throw new Error(`check-out (${d}) must be after check-in (${this.checkIn})`); this.checkOut = d; }
setAdults(n: number): void { this.adults = n; }
setChildren(n: number): void { if (this.room !== null && this.adults + n > this.room.capacity) throw new Error(`${this.adults + n} guests exceed the ${this.room.label} room capacity of ${this.room.capacity}`); this.children = n; }
describe(): string { const nights = nightsBetween(this.checkIn!, this.checkOut!); return `${this.room!.label} room, ${this.checkIn} to ${this.checkOut} (${nights} nights), ` + `${this.adults} adults + ${this.children} children`; }}
const b = new Booking();b.setRoom(RoomType.FAMILY);b.setCheckIn("2026-11-03");b.setCheckOut("2026-11-06");b.setAdults(2);b.setChildren(2);console.log(b.describe()); // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children
// The guest moves the stay one week later. Setting check-in first is the natural order,// but for a moment the booking would end before it starts, so it throws.try { b.setCheckIn("2026-11-10"); b.setCheckOut("2026-11-13");} catch (e) { console.log(`error: ${(e as Error).message}`); // error: check-out (2026-11-06) must be after check-in (2026-11-10)}
// Downgrading the room checks nothing, because the capacity rule lives in another setter.// The object is now invalid, and anyone holding it sees the change.b.setRoom(RoomType.STANDARD);console.log(b.describe()); // standard room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 childrenTwo things go wrong. First, say the guest moves the stay one week later. Setting check-in first
fails, because for a moment the booking would end before it starts. So the caller must know to set
the fields in a particular order. Second, the capacity rule lives in setChildren. So
setRoom(STANDARD) passes without a check, and leaves four guests in a room for two. Every rule that
spans fields has to pick one setter to live in. And the other setter skips it.
With a builder
Section titled “With a builder”Steps only record values. build() checks every rule against the complete set of values,
collects every violation, and only then creates the booking:
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
Fill the builder and press build(). Break several rules at once to see them all reported:
Builder calls
Result
Press build() to check every rule at once.
import java.time.LocalDate;import java.time.temporal.ChronoUnit;import java.util.ArrayList;import java.util.List;
enum RoomType { STANDARD(2), FAMILY(4);
final int capacity; RoomType(int capacity) { this.capacity = capacity; } String label() { return name().toLowerCase(); }}
// Carries every broken rule, not just the first, so a form can show them all at once.final class InvalidBooking extends RuntimeException { final List<String> errors; InvalidBooking(List<String> errors) { super("invalid booking: " + String.join("; ", errors)); this.errors = List.copyOf(errors); }}
// The product: immutable and valid by construction. The only constructor is private and// is called only after build() has checked every rule, so a Booking that exists is valid.final class Booking { final RoomType room; final LocalDate checkIn, checkOut; final int adults, children; final String promoCode, corporateRate; // null when not used
private Booking(Builder b) { room = b.room; checkIn = b.checkIn; checkOut = b.checkOut; adults = b.adults; children = b.children; promoCode = b.promoCode; corporateRate = b.corporateRate; }
static Builder builder(RoomType room) { return new Builder(room); }
// "Change one thing" on an immutable object: copy it into a fresh builder, change the // fields you want, and build again. The copy goes through the same rules. Builder toBuilder() { Builder b = new Builder(room); b.checkIn = checkIn; b.checkOut = checkOut; b.adults = adults; b.children = children; b.promoCode = promoCode; b.corporateRate = corporateRate; return b; }
long nights() { return ChronoUnit.DAYS.between(checkIn, checkOut); }
String describe() { String rate = promoCode != null ? "promo " + promoCode : corporateRate != null ? "corporate " + corporateRate : "standard"; return room.label() + " room, " + checkIn + " to " + checkOut + " (" + nights() + " nights), " + adults + " adults + " + children + " children, rate=" + rate; }
static final class Builder { private final RoomType room; private LocalDate checkIn, checkOut; private int adults = 1, children = 0; private String promoCode, corporateRate;
private Builder(RoomType room) { this.room = room; }
// Steps only record values. Rules that span fields wait for build(), so the steps // can be called in any order and an intermediate state is never judged. Builder checkIn(LocalDate d) { checkIn = d; return this; } Builder checkOut(LocalDate d) { checkOut = d; return this; } Builder adults(int n) { adults = n; return this; } Builder children(int n) { children = n; return this; } Builder promoCode(String code) { promoCode = code; return this; } Builder corporateRate(String account) { corporateRate = account; return this; }
Booking build() { // Collect every violation, then fail once. One round trip fixes the whole form. List<String> errors = new ArrayList<>(); if (checkIn == null) errors.add("check-in is required"); if (checkOut == null) errors.add("check-out is required"); if (checkIn != null && checkOut != null) { if (!checkOut.isAfter(checkIn)) errors.add("check-out (" + checkOut + ") must be after check-in (" + checkIn + ")"); else if (ChronoUnit.DAYS.between(checkIn, checkOut) > 30) errors.add("stay of " + ChronoUnit.DAYS.between(checkIn, checkOut) + " nights exceeds the 30-night limit"); } if (adults < 1) errors.add("at least one adult is required"); if (adults + children > room.capacity) errors.add((adults + children) + " guests exceed the " + room.label() + " room capacity of " + room.capacity); if (promoCode != null && corporateRate != null) errors.add("a promo code and a corporate rate can't be combined"); if (!errors.isEmpty()) throw new InvalidBooking(errors); return new Booking(this); } }}
class Main { public static void main(String[] args) { Booking stay = Booking.builder(RoomType.FAMILY) .checkOut(LocalDate.parse("2026-11-06")) // before check-in: order doesn't matter .checkIn(LocalDate.parse("2026-11-03")) .adults(2) .children(2) .promoCode("FALL10") .build(); System.out.println(stay.describe()); // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children, rate=promo FALL10
// A changed copy. The original is untouched, so anyone already holding it is safe. Booking longer = stay.toBuilder().checkOut(LocalDate.parse("2026-11-07")).build(); System.out.println(longer.describe()); // family room, 2026-11-03 to 2026-11-07 (4 nights), 2 adults + 2 children, rate=promo FALL10 System.out.println(stay.nights()); // 3
try { Booking.builder(RoomType.STANDARD) .checkIn(LocalDate.parse("2026-11-06")) .checkOut(LocalDate.parse("2026-11-03")) .adults(2) .children(1) .promoCode("FALL10") .corporateRate("ACME") .build(); } catch (InvalidBooking e) { System.out.println(e.getMessage()); // invalid booking: check-out (2026-11-03) must be after check-in (2026-11-06); 3 guests exceed the standard room capacity of 2; a promo code and a corporate rate can't be combined } }}from __future__ import annotations
from dataclasses import dataclassfrom datetime import datefrom enum import Enum
class RoomType(Enum): STANDARD = 2 FAMILY = 4
@property def capacity(self) -> int: return self.value
@property def label(self) -> str: return self.name.lower()
# Carries every broken rule, not just the first, so a form can show them all at once.class InvalidBooking(ValueError): def __init__(self, errors: list[str]): super().__init__("invalid booking: " + "; ".join(errors)) self.errors = tuple(errors)
# The product: immutable (frozen) and valid by construction, as long as callers go through# the builder. Python can't make __init__ private; the convention is that build() is the# way in, and the leading underscore on the builder's fields says "don't reach in".@dataclass(frozen=True)class Booking: room: RoomType check_in: date check_out: date adults: int children: int promo_code: str | None corporate_rate: str | None
@staticmethod def builder(room: RoomType) -> BookingBuilder: return BookingBuilder(room)
# "Change one thing" on an immutable object: copy it into a fresh builder, change the # fields you want, and build again. The copy goes through the same rules. # (dataclasses.replace does the copy too, but it skips the rules in build().) def to_builder(self) -> BookingBuilder: b = BookingBuilder(self.room) b._check_in, b._check_out = self.check_in, self.check_out b._adults, b._children = self.adults, self.children b._promo_code, b._corporate_rate = self.promo_code, self.corporate_rate return b
@property def nights(self) -> int: return (self.check_out - self.check_in).days
def describe(self) -> str: rate = (f"promo {self.promo_code}" if self.promo_code else f"corporate {self.corporate_rate}" if self.corporate_rate else "standard") return (f"{self.room.label} room, {self.check_in} to {self.check_out} ({self.nights} nights), " f"{self.adults} adults + {self.children} children, rate={rate}")
class BookingBuilder: def __init__(self, room: RoomType): self._room = room self._check_in: date | None = None self._check_out: date | None = None self._adults = 1 self._children = 0 self._promo_code: str | None = None self._corporate_rate: str | None = None
# Steps only record values. Rules that span fields wait for build(), so the steps # can be called in any order and an intermediate state is never judged. def check_in(self, d: date) -> BookingBuilder: self._check_in = d return self
def check_out(self, d: date) -> BookingBuilder: self._check_out = d return self
def adults(self, n: int) -> BookingBuilder: self._adults = n return self
def children(self, n: int) -> BookingBuilder: self._children = n return self
def promo_code(self, code: str) -> BookingBuilder: self._promo_code = code return self
def corporate_rate(self, account: str) -> BookingBuilder: self._corporate_rate = account return self
def build(self) -> Booking: # Collect every violation, then fail once. One round trip fixes the whole form. errors: list[str] = [] if self._check_in is None: errors.append("check-in is required") if self._check_out is None: errors.append("check-out is required") if self._check_in is not None and self._check_out is not None: nights = (self._check_out - self._check_in).days if nights <= 0: errors.append(f"check-out ({self._check_out}) must be after check-in ({self._check_in})") elif nights > 30: errors.append(f"stay of {nights} nights exceeds the 30-night limit") if self._adults < 1: errors.append("at least one adult is required") guests = self._adults + self._children if guests > self._room.capacity: errors.append(f"{guests} guests exceed the {self._room.label} room capacity of {self._room.capacity}") if self._promo_code is not None and self._corporate_rate is not None: errors.append("a promo code and a corporate rate can't be combined") if errors: raise InvalidBooking(errors) return Booking(self._room, self._check_in, self._check_out, self._adults, self._children, self._promo_code, self._corporate_rate)
if __name__ == "__main__": stay = (Booking.builder(RoomType.FAMILY) .check_out(date(2026, 11, 6)) # before check-in: order doesn't matter .check_in(date(2026, 11, 3)) .adults(2) .children(2) .promo_code("FALL10") .build()) print(stay.describe()) # family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children, rate=promo FALL10
# A changed copy. The original is untouched, so anyone already holding it is safe. longer = stay.to_builder().check_out(date(2026, 11, 7)).build() print(longer.describe()) # family room, 2026-11-03 to 2026-11-07 (4 nights), 2 adults + 2 children, rate=promo FALL10 print(stay.nights) # 3
try: (Booking.builder(RoomType.STANDARD) .check_in(date(2026, 11, 6)) .check_out(date(2026, 11, 3)) .adults(2) .children(1) .promo_code("FALL10") .corporate_rate("ACME") .build()) except InvalidBooking as e: print(e) # invalid booking: check-out (2026-11-03) must be after check-in (2026-11-06); 3 guests exceed the standard room capacity of 2; a promo code and a corporate rate can't be combinedusing System;using System.Collections.Generic;
public sealed record RoomType(string Label, int Capacity){ public static readonly RoomType Standard = new("standard", 2); public static readonly RoomType Family = new("family", 4);}
// Carries every broken rule, not just the first, so a form can show them all at once.public sealed class InvalidBookingException : Exception{ public IReadOnlyList<string> Errors { get; } public InvalidBookingException(List<string> errors) : base("invalid booking: " + string.Join("; ", errors)) => Errors = errors.ToArray();}
// The product: immutable and valid by construction. The only constructor is private and// is called only after Build() has checked every rule, so a Booking that exists is valid.// (A record with `with` would copy without running the rules, so the copy goes through// ToBuilder() instead.)public sealed class Booking{ public RoomType Room { get; } public DateOnly CheckIn { get; } public DateOnly CheckOut { get; } public int Adults { get; } public int Children { get; } public string? PromoCode { get; } public string? CorporateRate { get; }
private Booking(Builder b) { Room = b.Room; CheckIn = b.In!.Value; CheckOut = b.Out!.Value; Adults = b.AdultCount; Children = b.ChildCount; PromoCode = b.Promo; CorporateRate = b.Corporate; }
public static Builder CreateBuilder(RoomType room) => new(room);
// "Change one thing" on an immutable object: copy it into a fresh builder, change the // fields you want, and build again. The copy goes through the same rules. public Builder ToBuilder() => new(Room) { In = CheckIn, Out = CheckOut, AdultCount = Adults, ChildCount = Children, Promo = PromoCode, Corporate = CorporateRate, };
public int Nights => CheckOut.DayNumber - CheckIn.DayNumber;
public string Describe() { var rate = PromoCode is not null ? $"promo {PromoCode}" : CorporateRate is not null ? $"corporate {CorporateRate}" : "standard"; return $"{Room.Label} room, {CheckIn:yyyy-MM-dd} to {CheckOut:yyyy-MM-dd} ({Nights} nights), " + $"{Adults} adults + {Children} children, rate={rate}"; }
public sealed class Builder { internal readonly RoomType Room; internal DateOnly? In, Out; internal int AdultCount = 1, ChildCount = 0; internal string? Promo, Corporate;
internal Builder(RoomType room) => Room = room;
// Steps only record values. Rules that span fields wait for Build(), so the steps // can be called in any order and an intermediate state is never judged. public Builder CheckIn(DateOnly d) { In = d; return this; } public Builder CheckOut(DateOnly d) { Out = d; return this; } public Builder Adults(int n) { AdultCount = n; return this; } public Builder Children(int n) { ChildCount = n; return this; } public Builder PromoCode(string code) { Promo = code; return this; } public Builder CorporateRate(string account) { Corporate = account; return this; }
public Booking Build() { // Collect every violation, then fail once. One round trip fixes the whole form. var errors = new List<string>(); if (In is null) errors.Add("check-in is required"); if (Out is null) errors.Add("check-out is required"); if (In is { } i && Out is { } o) { var nights = o.DayNumber - i.DayNumber; if (nights <= 0) errors.Add($"check-out ({o:yyyy-MM-dd}) must be after check-in ({i:yyyy-MM-dd})"); else if (nights > 30) errors.Add($"stay of {nights} nights exceeds the 30-night limit"); } if (AdultCount < 1) errors.Add("at least one adult is required"); var guests = AdultCount + ChildCount; if (guests > Room.Capacity) errors.Add($"{guests} guests exceed the {Room.Label} room capacity of {Room.Capacity}"); if (Promo is not null && Corporate is not null) errors.Add("a promo code and a corporate rate can't be combined"); if (errors.Count > 0) throw new InvalidBookingException(errors); return new Booking(this); } }}
public static class Program{ public static void Main() { var stay = Booking.CreateBuilder(RoomType.Family) .CheckOut(new DateOnly(2026, 11, 6)) // before check-in: order doesn't matter .CheckIn(new DateOnly(2026, 11, 3)) .Adults(2) .Children(2) .PromoCode("FALL10") .Build(); Console.WriteLine(stay.Describe()); // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children, rate=promo FALL10
// A changed copy. The original is untouched, so anyone already holding it is safe. var longer = stay.ToBuilder().CheckOut(new DateOnly(2026, 11, 7)).Build(); Console.WriteLine(longer.Describe()); // family room, 2026-11-03 to 2026-11-07 (4 nights), 2 adults + 2 children, rate=promo FALL10 Console.WriteLine(stay.Nights); // 3
try { Booking.CreateBuilder(RoomType.Standard) .CheckIn(new DateOnly(2026, 11, 6)) .CheckOut(new DateOnly(2026, 11, 3)) .Adults(2) .Children(1) .PromoCode("FALL10") .CorporateRate("ACME") .Build(); } catch (InvalidBookingException e) { Console.WriteLine(e.Message); // invalid booking: check-out (2026-11-03) must be after check-in (2026-11-06); 3 guests exceed the standard room capacity of 2; a promo code and a corporate rate can't be combined } }}package main
import ( "errors" "fmt" "strings" "time")
type RoomType struct { Label string Capacity int}
var ( Standard = RoomType{"standard", 2} Family = RoomType{"family", 4})
func day(s string) time.Time { t, err := time.Parse(time.DateOnly, s) if err != nil { panic(err) } return t}
func nightsBetween(in, out time.Time) int { return int(out.Sub(in).Hours() / 24) }
// InvalidBooking carries every broken rule, not just the first, so a form can show them// all at once.type InvalidBooking struct{ Errors []string }
func (e *InvalidBooking) Error() string { return "invalid booking: " + strings.Join(e.Errors, "; ") }
// Booking is the product: immutable and valid by construction. Its fields are unexported// and only Build fills them, after checking every rule, so a Booking that exists is valid.type Booking struct { room RoomType checkIn, checkOut time.Time adults, children int promoCode, corporateRate string // "" when not used}
func (b Booking) Nights() int { return nightsBetween(b.checkIn, b.checkOut) }
func (b Booking) Describe() string { rate := "standard" if b.promoCode != "" { rate = "promo " + b.promoCode } else if b.corporateRate != "" { rate = "corporate " + b.corporateRate } return fmt.Sprintf("%s room, %s to %s (%d nights), %d adults + %d children, rate=%s", b.room.Label, b.checkIn.Format(time.DateOnly), b.checkOut.Format(time.DateOnly), b.Nights(), b.adults, b.children, rate)}
// ToBuilder is "change one thing" on an immutable value: copy it into a fresh builder,// change the fields you want, and build again. The copy goes through the same rules.func (b Booking) ToBuilder() *BookingBuilder { return &BookingBuilder{draft: b}}
type BookingBuilder struct{ draft Booking }
func NewBooking(room RoomType) *BookingBuilder { return &BookingBuilder{draft: Booking{room: room, adults: 1}}}
// Steps only record values. Rules that span fields wait for Build, so the steps can be// called in any order and an intermediate state is never judged.func (b *BookingBuilder) CheckIn(d time.Time) *BookingBuilder { b.draft.checkIn = d; return b }func (b *BookingBuilder) CheckOut(d time.Time) *BookingBuilder { b.draft.checkOut = d; return b }func (b *BookingBuilder) Adults(n int) *BookingBuilder { b.draft.adults = n; return b }func (b *BookingBuilder) Children(n int) *BookingBuilder { b.draft.children = n; return b }func (b *BookingBuilder) PromoCode(code string) *BookingBuilder { b.draft.promoCode = code return b}func (b *BookingBuilder) CorporateRate(account string) *BookingBuilder { b.draft.corporateRate = account return b}
func (b *BookingBuilder) Build() (Booking, error) { d := b.draft // Collect every violation, then fail once. One round trip fixes the whole form. var errs []string if d.checkIn.IsZero() { errs = append(errs, "check-in is required") } if d.checkOut.IsZero() { errs = append(errs, "check-out is required") } if !d.checkIn.IsZero() && !d.checkOut.IsZero() { if n := nightsBetween(d.checkIn, d.checkOut); n <= 0 { errs = append(errs, fmt.Sprintf("check-out (%s) must be after check-in (%s)", d.checkOut.Format(time.DateOnly), d.checkIn.Format(time.DateOnly))) } else if n > 30 { errs = append(errs, fmt.Sprintf("stay of %d nights exceeds the 30-night limit", n)) } } if d.adults < 1 { errs = append(errs, "at least one adult is required") } if g := d.adults + d.children; g > d.room.Capacity { errs = append(errs, fmt.Sprintf("%d guests exceed the %s room capacity of %d", g, d.room.Label, d.room.Capacity)) } if d.promoCode != "" && d.corporateRate != "" { errs = append(errs, "a promo code and a corporate rate can't be combined") } if len(errs) > 0 { return Booking{}, &InvalidBooking{errs} } // Booking holds only values, so returning it copies it: the builder can't reach it. return d, nil}
func main() { stay, _ := NewBooking(Family). CheckOut(day("2026-11-06")). // before check-in: order doesn't matter CheckIn(day("2026-11-03")). Adults(2). Children(2). PromoCode("FALL10"). Build() fmt.Println(stay.Describe()) // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children, rate=promo FALL10
// A changed copy. The original is untouched, so anyone already holding it is safe. longer, _ := stay.ToBuilder().CheckOut(day("2026-11-07")).Build() fmt.Println(longer.Describe()) // family room, 2026-11-03 to 2026-11-07 (4 nights), 2 adults + 2 children, rate=promo FALL10 fmt.Println(stay.Nights()) // 3
_, err := NewBooking(Standard). CheckIn(day("2026-11-06")). CheckOut(day("2026-11-03")). Adults(2). Children(1). PromoCode("FALL10"). CorporateRate("ACME"). Build() var invalid *InvalidBooking if errors.As(err, &invalid) { fmt.Println(invalid) // invalid booking: check-out (2026-11-03) must be after check-in (2026-11-06); 3 guests exceed the standard room capacity of 2; a promo code and a corporate rate can't be combined }}// Dates are ISO "YYYY-MM-DD" strings: they compare correctly as strings, and// Date.parse reads them as UTC midnight, so night counts don't shift with time zones.const nightsBetween = (a: string, b: string) => (Date.parse(b) - Date.parse(a)) / 86_400_000;
const RoomType = { STANDARD: { label: "standard", capacity: 2 }, FAMILY: { label: "family", capacity: 4 },} as const;type RoomType = (typeof RoomType)[keyof typeof RoomType];
// Carries every broken rule, not just the first, so a form can show them all at once.class InvalidBooking extends Error { constructor(readonly errors: readonly string[]) { super(`invalid booking: ${errors.join("; ")}`); }}
type BookingFields = { room: RoomType; checkIn: string | null; checkOut: string | null; adults: number; children: number; promoCode: string | null; corporateRate: string | null;};
// The product: immutable (readonly, frozen) and valid by construction, as long as callers// go through the builder, which only creates a Booking after checking every rule.class Booking { private constructor( readonly room: RoomType, readonly checkIn: string, readonly checkOut: string, readonly adults: number, readonly children: number, readonly promoCode: string | null, readonly corporateRate: string | null, ) { Object.freeze(this); }
static builder(room: RoomType): BookingBuilder { return new BookingBuilder({ room, checkIn: null, checkOut: null, adults: 1, children: 0, promoCode: null, corporateRate: null }); }
// Used only by BookingBuilder.build(), after validation. static fromValid(f: BookingFields): Booking { return new Booking(f.room, f.checkIn!, f.checkOut!, f.adults, f.children, f.promoCode, f.corporateRate); }
// "Change one thing" on an immutable object: copy it into a fresh builder, change the // fields you want, and build again. The copy goes through the same rules. toBuilder(): BookingBuilder { return new BookingBuilder({ ...this }); }
get nights(): number { return nightsBetween(this.checkIn, this.checkOut); }
describe(): string { const rate = this.promoCode ? `promo ${this.promoCode}` : this.corporateRate ? `corporate ${this.corporateRate}` : "standard"; return `${this.room.label} room, ${this.checkIn} to ${this.checkOut} (${this.nights} nights), ` + `${this.adults} adults + ${this.children} children, rate=${rate}`; }}
class BookingBuilder { constructor(private readonly f: BookingFields) {}
// Steps only record values. Rules that span fields wait for build(), so the steps can // be called in any order and an intermediate state is never judged. checkIn(d: string): this { this.f.checkIn = d; return this; } checkOut(d: string): this { this.f.checkOut = d; return this; } adults(n: number): this { this.f.adults = n; return this; } children(n: number): this { this.f.children = n; return this; } promoCode(code: string): this { this.f.promoCode = code; return this; } corporateRate(account: string): this { this.f.corporateRate = account; return this; }
build(): Booking { const f = this.f; // Collect every violation, then fail once. One round trip fixes the whole form. const errors: string[] = []; if (f.checkIn === null) errors.push("check-in is required"); if (f.checkOut === null) errors.push("check-out is required"); if (f.checkIn !== null && f.checkOut !== null) { const nights = nightsBetween(f.checkIn, f.checkOut); if (nights <= 0) errors.push(`check-out (${f.checkOut}) must be after check-in (${f.checkIn})`); else if (nights > 30) errors.push(`stay of ${nights} nights exceeds the 30-night limit`); } if (f.adults < 1) errors.push("at least one adult is required"); const guests = f.adults + f.children; if (guests > f.room.capacity) errors.push(`${guests} guests exceed the ${f.room.label} room capacity of ${f.room.capacity}`); if (f.promoCode !== null && f.corporateRate !== null) errors.push("a promo code and a corporate rate can't be combined"); if (errors.length > 0) throw new InvalidBooking(errors); // Spread copies the fields, so the builder can't reach the Booking afterwards. return Booking.fromValid({ ...f }); }}
const stay = Booking.builder(RoomType.FAMILY) .checkOut("2026-11-06") // before check-in: order doesn't matter .checkIn("2026-11-03") .adults(2) .children(2) .promoCode("FALL10") .build();console.log(stay.describe()); // family room, 2026-11-03 to 2026-11-06 (3 nights), 2 adults + 2 children, rate=promo FALL10
// A changed copy. The original is untouched, so anyone already holding it is safe.const longer = stay.toBuilder().checkOut("2026-11-07").build();console.log(longer.describe()); // family room, 2026-11-03 to 2026-11-07 (4 nights), 2 adults + 2 children, rate=promo FALL10console.log(stay.nights); // 3
try { Booking.builder(RoomType.STANDARD) .checkIn("2026-11-06") .checkOut("2026-11-03") .adults(2) .children(1) .promoCode("FALL10") .corporateRate("ACME") .build();} catch (e) { if (!(e instanceof InvalidBooking)) throw e; console.log(e.message); // invalid booking: check-out (2026-11-03) must be after check-in (2026-11-06); 3 guests exceed the standard room capacity of 2; a promo code and a corporate rate can't be combined}What to notice
Section titled “What to notice”- Cross-field rules belong in
build(). Only there are all the values known at once, so the order of the steps stops mattering. Single-field checks can stay in the steps, where they fail early. - Report every broken rule. A form that shows one error at a time takes three round trips
to fix three mistakes.
InvalidBookingcarries the whole list, which an API can return as a field-by-field error response. - Valid by construction. The
Bookingconstructor is private and is only called after the checks pass. Code that receives aBookingnever needs to re-validate it, and immutability keeps it valid: nobody can change the room after the capacity check. toBuilder()is how you change an immutable object. It copies the booking into a fresh builder. You change one field, and build again. The copy is checked by the same rules. The original is untouched. So anything already holding it (a confirmation email, a cache) stays correct.- Language shortcuts can skip the rules. C#’s
withon a record and Python’sdataclasses.replacecopy an object without runningbuild(). That’s why the copy goes throughtoBuilder()here.
A booking has check-in, check-out, guests, a room type and two discount options with rules between them. Where do you put the validation, and how does a guest change their dates?
Whether you validate cross-field rules in build(), and collect all errors. Whether you make
the product immutable, and offer toBuilder() (or an equivalent copy-and-rebuild) for changes.
The red flag is validation spread across setters, which breaks when fields are set in a
different order. Or a mutable booking that can be changed after it was checked.
A safe SQL query builder
- Values bound, never pasted
- Allow-listed identifiers
- Nested condition groups
- Immutable, shareable builders
- Staged builders
A user-search endpoint filters by status, email and country, and sorts by a column the client
names in ?sort=. Every filter comes from the request, so it is untrusted. The service has
dozens of such endpoints, many built from the same base query (“active users”), and requests
are served on many threads.
Here the builder’s job is not convenience. It’s to make one class of bug impossible: user input becoming SQL.
Without the pattern
Section titled “Without the pattern”// Without a builder: SQL assembled by string concatenation. Values and identifiers from// the request are pasted into the text the database will parse as code.final class UserSearch { static String byEmail(String email) { return "SELECT id, email FROM users WHERE status = 'active' AND email = '" + email + "'"; }
static String sortedBy(String column) { return "SELECT id, email FROM users ORDER BY " + column; }}
class Main { public static void main(String[] args) { System.out.println(UserSearch.byEmail("ana@shop.example")); // SELECT id, email FROM users WHERE status = 'active' AND email = 'ana@shop.example'
// A search box is user input. This value closes the string and adds its own clause. System.out.println(UserSearch.byEmail("x' OR '1'='1")); // SELECT id, email FROM users WHERE status = 'active' AND email = 'x' OR '1'='1' // AND binds tighter than OR, so the WHERE is true for every row: all users, any status.
// Escaping quotes would stop the first attack but not this one: a column name is // not inside quotes, so there is nothing to escape. System.out.println(UserSearch.sortedBy("created_at; DROP TABLE users")); // SELECT id, email FROM users ORDER BY created_at; DROP TABLE users }}# Without a builder: SQL assembled by string formatting. Values and identifiers from the# request are pasted into the text the database will parse as code.def by_email(email: str) -> str: return f"SELECT id, email FROM users WHERE status = 'active' AND email = '{email}'"
def sorted_by(column: str) -> str: return f"SELECT id, email FROM users ORDER BY {column}"
if __name__ == "__main__": print(by_email("ana@shop.example")) # SELECT id, email FROM users WHERE status = 'active' AND email = 'ana@shop.example'
# A search box is user input. This value closes the string and adds its own clause. print(by_email("x' OR '1'='1")) # SELECT id, email FROM users WHERE status = 'active' AND email = 'x' OR '1'='1' # AND binds tighter than OR, so the WHERE is true for every row: all users, any status.
# Escaping quotes would stop the first attack but not this one: a column name is # not inside quotes, so there is nothing to escape. print(sorted_by("created_at; DROP TABLE users")) # SELECT id, email FROM users ORDER BY created_at; DROP TABLE usersusing System;
// Without a builder: SQL assembled with string interpolation. Values and identifiers from// the request are pasted into the text the database will parse as code.public static class UserSearch{ public static string ByEmail(string email) => $"SELECT id, email FROM users WHERE status = 'active' AND email = '{email}'";
public static string SortedBy(string column) => "SELECT id, email FROM users ORDER BY " + column;}
public static class Program{ public static void Main() { Console.WriteLine(UserSearch.ByEmail("ana@shop.example")); // SELECT id, email FROM users WHERE status = 'active' AND email = 'ana@shop.example'
// A search box is user input. This value closes the string and adds its own clause. Console.WriteLine(UserSearch.ByEmail("x' OR '1'='1")); // SELECT id, email FROM users WHERE status = 'active' AND email = 'x' OR '1'='1' // AND binds tighter than OR, so the WHERE is true for every row: all users, any status.
// Escaping quotes would stop the first attack but not this one: a column name is // not inside quotes, so there is nothing to escape. Console.WriteLine(UserSearch.SortedBy("created_at; DROP TABLE users")); // SELECT id, email FROM users ORDER BY created_at; DROP TABLE users }}package main
import "fmt"
// Without a builder: SQL assembled with Sprintf. Values and identifiers from the request// are pasted into the text the database will parse as code.func byEmail(email string) string { return fmt.Sprintf("SELECT id, email FROM users WHERE status = 'active' AND email = '%s'", email)}
func sortedBy(column string) string { return "SELECT id, email FROM users ORDER BY " + column}
func main() { fmt.Println(byEmail("ana@shop.example")) // SELECT id, email FROM users WHERE status = 'active' AND email = 'ana@shop.example'
// A search box is user input. This value closes the string and adds its own clause. fmt.Println(byEmail("x' OR '1'='1")) // SELECT id, email FROM users WHERE status = 'active' AND email = 'x' OR '1'='1' // AND binds tighter than OR, so the WHERE is true for every row: all users, any status.
// Escaping quotes would stop the first attack but not this one: a column name is // not inside quotes, so there is nothing to escape. fmt.Println(sortedBy("created_at; DROP TABLE users")) // SELECT id, email FROM users ORDER BY created_at; DROP TABLE users}// Without a builder: SQL assembled with template strings. Values and identifiers from the// request are pasted into the text the database will parse as code.const byEmail = (email: string) => `SELECT id, email FROM users WHERE status = 'active' AND email = '${email}'`;
const sortedBy = (column: string) => `SELECT id, email FROM users ORDER BY ${column}`;
console.log(byEmail("ana@shop.example"));// SELECT id, email FROM users WHERE status = 'active' AND email = 'ana@shop.example'
// A search box is user input. This value closes the string and adds its own clause.console.log(byEmail("x' OR '1'='1"));// SELECT id, email FROM users WHERE status = 'active' AND email = 'x' OR '1'='1'// AND binds tighter than OR, so the WHERE is true for every row: all users, any status.
// Escaping quotes would stop the first attack but not this one: a column name is not// inside quotes, so there is nothing to escape.console.log(sortedBy("created_at; DROP TABLE users"));// SELECT id, email FROM users ORDER BY created_at; DROP TABLE usersThe search value x' OR '1'='1 closes the string literal. It adds a clause that is true for every
row. So the query returns all users, of any status. Escaping quotes fixes that case, but not the
sort column. A column name isn’t inside quotes, so created_at; DROP TABLE users is pasted in as
SQL. The two holes need two different fixes. And every one of the dozens of endpoints has to
remember both.
With a builder
Section titled “With a builder”The builder produces two things that never mix: SQL text containing only ? placeholders,
and a list of values to bind to them. The database driver sends the values separately, so
the database never parses them. Identifiers can’t be placeholders, so the builder checks every
column against the table’s allow-list. And each step returns a new query, so a shared base
can be branched safely.
Type a malicious value or sort column. Then branch a shared base query with each kind of builder:
String concatenation
Query builder
One shared base query, two requests at once
- Color key
- Facade
- Entity
- Value object
- Enum
- Interface
- Implementation
import java.util.ArrayList;import java.util.LinkedHashSet;import java.util.List;import java.util.Set;
// The product: SQL text with ? placeholders, and the values to bind to them, kept apart.// The driver sends the values separately, so the database never parses them as SQL.record Sql(String text, List<Object> params) { @Override public String toString() { return text + " " + params; }}
// Identifiers (table and column names) can't be bound as parameters, so they are checked// against a fixed list from code instead. Anything not on the list is rejected.final class Table { final String name; private final Set<String> columns;
private Table(String name, Set<String> columns) { this.name = name; this.columns = columns; }
static Table of(String name, String... columns) { return new Table(name, new LinkedHashSet<>(List.of(columns))); }
String column(String c) { if (!columns.contains(c)) throw new IllegalArgumentException("unknown column '" + c + "' (allowed: " + String.join(", ", columns) + ")"); return c; }}
enum Dir { ASC, DESC }
// A condition writes its own SQL with placeholders and appends its values in the same// order. Conditions are small immutable values, and groups nest them.interface Condition { void render(Table t, StringBuilder sql, List<Object> params);}
final class Where { private Where() {}
static Condition eq(String column, Object value) { return (t, sql, params) -> { sql.append(t.column(column)).append(" = ?"); params.add(value); }; }
static Condition in(String column, Object... values) { List<Object> copy = List.of(values); return (t, sql, params) -> { // "IN ()" is a syntax error in SQL, so fail here with a clear message instead. if (copy.isEmpty()) throw new IllegalArgumentException("IN list for '" + column + "' is empty"); sql.append(t.column(column)).append(" IN (").append("?, ".repeat(copy.size() - 1)).append("?)"); params.addAll(copy); }; }
// A nested group: renders its parts in parentheses, so precedence is explicit and an // OR can never leak out and swallow the rest of the WHERE clause. static Condition anyOf(Condition... parts) { return group(" OR ", parts); } static Condition allOf(Condition... parts) { return group(" AND ", parts); }
private static Condition group(String op, Condition... parts) { List<Condition> copy = List.of(parts); return (t, sql, params) -> { sql.append("("); for (int i = 0; i < copy.size(); i++) { if (i > 0) sql.append(op); copy.get(i).render(t, sql, params); } sql.append(")"); }; }}
// An immutable builder: every step returns a new Query and never changes this one. A// shared base query can be branched by any number of threads without locks, because// there is nothing to race on. Cost: each step copies its lists (a few entries).final class Query { private final Table table; private final List<String> columns; private final List<Condition> where; private final String orderBy; private final Dir dir; private final Integer limit;
private Query(Table table, List<String> columns, List<Condition> where, String orderBy, Dir dir, Integer limit) { this.table = table; this.columns = columns; this.where = where; this.orderBy = orderBy; this.dir = dir; this.limit = limit; }
static Query from(Table table) { return new Query(table, List.of(), List.of(), null, null, null); }
Query select(String... cols) { return new Query(table, List.of(cols), where, orderBy, dir, limit); }
// Repeated where() calls are ANDed. Copy, add, freeze: this Query keeps its own list. Query where(Condition c) { List<Condition> next = new ArrayList<>(where); next.add(c); return new Query(table, columns, List.copyOf(next), orderBy, dir, limit); }
Query orderBy(String column, Dir d) { return new Query(table, columns, where, column, d, limit); }
Query limit(int n) { if (n < 1 || n > 1000) throw new IllegalArgumentException("limit must be 1..1000, got " + n); return new Query(table, columns, where, orderBy, dir, n); }
// build() is the one place SQL text is produced: identifiers checked, values bound. Sql build() { if (columns.isEmpty()) throw new IllegalStateException("no columns selected"); StringBuilder sql = new StringBuilder("SELECT "); List<Object> params = new ArrayList<>(); for (int i = 0; i < columns.size(); i++) sql.append(i > 0 ? ", " : "").append(table.column(columns.get(i))); sql.append(" FROM ").append(table.name); for (int i = 0; i < where.size(); i++) { sql.append(i == 0 ? " WHERE " : " AND "); where.get(i).render(table, sql, params); } if (orderBy != null) sql.append(" ORDER BY ").append(table.column(orderBy)).append(" ").append(dir); if (limit != null) { sql.append(" LIMIT ?"); params.add(limit); } return new Sql(sql.toString(), List.copyOf(params)); }}
class Main { public static void main(String[] args) throws InterruptedException { Table users = Table.of("users", "id", "email", "status", "country", "created_at");
// A shared base query. Branches below start from it and can't change it. Query active = Query.from(users).select("id", "email").where(Where.eq("status", "active"));
Query eu = active .where(Where.anyOf(Where.eq("country", "DE"), Where.in("country", "FR", "IT"))) .orderBy("created_at", Dir.DESC) .limit(50); // The same input that broke the concatenated version is now just a value. Query byEmail = active.where(Where.eq("email", "x' OR '1'='1"));
System.out.println(eu.build()); // SELECT id, email FROM users WHERE status = ? AND (country = ? OR country IN (?, ?)) ORDER BY created_at DESC LIMIT ? [active, DE, FR, IT, 50] System.out.println(byEmail.build()); // SELECT id, email FROM users WHERE status = ? AND email = ? [active, x' OR '1'='1]
// Two threads branch from the same base at once. No locks: nothing is shared mutably. Sql[] results = new Sql[2]; Thread de = new Thread(() -> results[0] = active.where(Where.eq("country", "DE")).build()); Thread fr = new Thread(() -> results[1] = active.where(Where.eq("country", "FR")).build()); de.start(); fr.start(); de.join(); fr.join(); System.out.println(results[0]); // SELECT id, email FROM users WHERE status = ? AND country = ? [active, DE] System.out.println(results[1]); // SELECT id, email FROM users WHERE status = ? AND country = ? [active, FR] System.out.println(active.build()); // SELECT id, email FROM users WHERE status = ? [active]
// Anything that can't be a parameter is checked, and fails before any SQL exists. List<Runnable> bad = List.of( () -> active.orderBy("created_at; DROP TABLE users", Dir.ASC).build(), () -> active.where(Where.in("country")).build(), () -> Query.from(users).build()); for (Runnable r : bad) { try { r.run(); } catch (RuntimeException e) { System.out.println("error: " + e.getMessage()); } } // error: unknown column 'created_at; DROP TABLE users' (allowed: id, email, status, country, created_at) // error: IN list for 'country' is empty // error: no columns selected }}from __future__ import annotations
from concurrent.futures import ThreadPoolExecutorfrom dataclasses import dataclass, replacefrom enum import Enumfrom typing import Any, Callable
# The product: SQL text with ? placeholders, and the values to bind to them, kept apart.# The driver sends the values separately, so the database never parses them as SQL.@dataclass(frozen=True)class Sql: text: str params: tuple[Any, ...]
def __str__(self) -> str: return f"{self.text} [{', '.join(str(p) for p in self.params)}]"
# Identifiers (table and column names) can't be bound as parameters, so they are checked# against a fixed list from code instead. Anything not on the list is rejected.@dataclass(frozen=True)class Table: name: str columns: tuple[str, ...]
@staticmethod def of(name: str, *columns: str) -> Table: return Table(name, columns)
def column(self, c: str) -> str: if c not in self.columns: raise ValueError(f"unknown column '{c}' (allowed: {', '.join(self.columns)})") return c
class Dir(Enum): ASC = "ASC" DESC = "DESC"
# A condition writes its own SQL with placeholders and appends its values in the same# order. Conditions are small immutable values (closures here), and groups nest them.Condition = Callable[[Table, list[str], list[Any]], None]
def eq(column: str, value: Any) -> Condition: def render(t: Table, sql: list[str], params: list[Any]) -> None: sql.append(f"{t.column(column)} = ?") params.append(value) return render
def in_(column: str, *values: Any) -> Condition: def render(t: Table, sql: list[str], params: list[Any]) -> None: # "IN ()" is a syntax error in SQL, so fail here with a clear message instead. if not values: raise ValueError(f"IN list for '{column}' is empty") sql.append(f"{t.column(column)} IN ({', '.join('?' for _ in values)})") params.extend(values) return render
# A nested group: renders its parts in parentheses, so precedence is explicit and an OR# can never leak out and swallow the rest of the WHERE clause.def _group(op: str, parts: tuple[Condition, ...]) -> Condition: def render(t: Table, sql: list[str], params: list[Any]) -> None: sql.append("(") for i, part in enumerate(parts): if i > 0: sql.append(op) part(t, sql, params) sql.append(")") return render
def any_of(*parts: Condition) -> Condition: return _group(" OR ", parts)
def all_of(*parts: Condition) -> Condition: return _group(" AND ", parts)
# An immutable builder: every step returns a new Query (dataclasses.replace) and never# changes this one. A shared base query can be branched by any number of threads without# locks, because there is nothing to race on. Cost: each step copies a few small tuples.@dataclass(frozen=True)class Query: table: Table columns: tuple[str, ...] = () where_: tuple[Condition, ...] = () order_by_: tuple[str, Dir] | None = None limit_: int | None = None
@staticmethod def from_(table: Table) -> Query: return Query(table)
def select(self, *cols: str) -> Query: return replace(self, columns=cols)
# Repeated where() calls are ANDed. A new tuple each time: this Query keeps its own. def where(self, c: Condition) -> Query: return replace(self, where_=self.where_ + (c,))
def order_by(self, column: str, d: Dir) -> Query: return replace(self, order_by_=(column, d))
def limit(self, n: int) -> Query: if not 1 <= n <= 1000: raise ValueError(f"limit must be 1..1000, got {n}") return replace(self, limit_=n)
# build() is the one place SQL text is produced: identifiers checked, values bound. def build(self) -> Sql: if not self.columns: raise ValueError("no columns selected") t = self.table sql = [f"SELECT {', '.join(t.column(c) for c in self.columns)} FROM {t.name}"] params: list[Any] = [] for i, c in enumerate(self.where_): sql.append(" WHERE " if i == 0 else " AND ") c(t, sql, params) if self.order_by_ is not None: column, d = self.order_by_ sql.append(f" ORDER BY {t.column(column)} {d.value}") if self.limit_ is not None: sql.append(" LIMIT ?") params.append(self.limit_) return Sql("".join(sql), tuple(params))
if __name__ == "__main__": users = Table.of("users", "id", "email", "status", "country", "created_at")
# A shared base query. Branches below start from it and can't change it. active = Query.from_(users).select("id", "email").where(eq("status", "active"))
eu = (active .where(any_of(eq("country", "DE"), in_("country", "FR", "IT"))) .order_by("created_at", Dir.DESC) .limit(50)) # The same input that broke the formatted version is now just a value. by_email = active.where(eq("email", "x' OR '1'='1"))
print(eu.build()) # SELECT id, email FROM users WHERE status = ? AND (country = ? OR country IN (?, ?)) ORDER BY created_at DESC LIMIT ? [active, DE, FR, IT, 50] print(by_email.build()) # SELECT id, email FROM users WHERE status = ? AND email = ? [active, x' OR '1'='1]
# Two threads branch from the same base at once. No locks: nothing is shared mutably. with ThreadPoolExecutor(max_workers=2) as pool: results = list(pool.map(lambda c: active.where(eq("country", c)).build(), ["DE", "FR"])) print(results[0]) # SELECT id, email FROM users WHERE status = ? AND country = ? [active, DE] print(results[1]) # SELECT id, email FROM users WHERE status = ? AND country = ? [active, FR] print(active.build()) # SELECT id, email FROM users WHERE status = ? [active]
# Anything that can't be a parameter is checked, and fails before any SQL exists. bad = [ lambda: active.order_by("created_at; DROP TABLE users", Dir.ASC).build(), lambda: active.where(in_("country")).build(), lambda: Query.from_(users).build(), ] for attempt in bad: try: attempt() except ValueError as e: print(f"error: {e}") # error: unknown column 'created_at; DROP TABLE users' (allowed: id, email, status, country, created_at) # error: IN list for 'country' is empty # error: no columns selectedusing System;using System.Collections.Generic;using System.Collections.Immutable;using System.Linq;using System.Text;using System.Threading.Tasks;
// The product: SQL text with ? placeholders, and the values to bind to them, kept apart.// The driver sends the values separately, so the database never parses them as SQL.public sealed record Sql(string Text, ImmutableArray<object> Params){ public override string ToString() => $"{Text} [{string.Join(", ", Params)}]";}
// Identifiers (table and column names) can't be bound as parameters, so they are checked// against a fixed list from code instead. Anything not on the list is rejected.public sealed class Table{ public string Name { get; } private readonly ImmutableArray<string> _columns;
private Table(string name, ImmutableArray<string> columns) { Name = name; _columns = columns; }
public static Table Of(string name, params string[] columns) => new(name, columns.ToImmutableArray());
public string Column(string c) => _columns.Contains(c) ? c : throw new ArgumentException($"unknown column '{c}' (allowed: {string.Join(", ", _columns)})");}
public enum Dir { ASC, DESC }
// A condition writes its own SQL with placeholders and appends its values in the same// order. Conditions are small immutable values (delegates here), and groups nest them.public delegate void Condition(Table t, StringBuilder sql, List<object> parameters);
public static class Where{ public static Condition Eq(string column, object value) => (t, sql, ps) => { sql.Append(t.Column(column)).Append(" = ?"); ps.Add(value); };
public static Condition In(string column, params object[] values) { var copy = values.ToImmutableArray(); return (t, sql, ps) => { // "IN ()" is a syntax error in SQL, so fail here with a clear message instead. if (copy.IsEmpty) throw new ArgumentException($"IN list for '{column}' is empty"); sql.Append(t.Column(column)).Append(" IN (").Append(string.Join(", ", copy.Select(_ => "?"))).Append(')'); ps.AddRange(copy); }; }
// A nested group renders its parts in parentheses, so precedence is explicit and an OR // can never leak out and swallow the rest of the WHERE clause. public static Condition AnyOf(params Condition[] parts) => Group(" OR ", parts.ToImmutableArray()); public static Condition AllOf(params Condition[] parts) => Group(" AND ", parts.ToImmutableArray());
private static Condition Group(string op, ImmutableArray<Condition> parts) => (t, sql, ps) => { sql.Append('('); for (var i = 0; i < parts.Length; i++) { if (i > 0) sql.Append(op); parts[i](t, sql, ps); } sql.Append(')'); };}
// An immutable builder: a record whose steps return a copy made with `with`, never// changing the one they were called on. A shared base query can be branched by any number// of threads without locks, because there is nothing to race on. ImmutableList.Add// returns a new list and shares structure with the old one, so steps stay cheap.public sealed record Query{ private Table Table { get; init; } = null!; private ImmutableList<string> Columns { get; init; } = ImmutableList<string>.Empty; private ImmutableList<Condition> Conditions { get; init; } = ImmutableList<Condition>.Empty; private (string Column, Dir Direction)? Order { get; init; } private int? Max { get; init; }
private Query() { }
public static Query From(Table table) => new() { Table = table };
public Query Select(params string[] cols) => this with { Columns = cols.ToImmutableList() };
// Repeated Where() calls are ANDed. public Query Where(Condition c) => this with { Conditions = Conditions.Add(c) };
public Query OrderBy(string column, Dir d) => this with { Order = (column, d) };
public Query Limit(int n) => n is >= 1 and <= 1000 ? this with { Max = n } : throw new ArgumentException($"limit must be 1..1000, got {n}");
// Build() is the one place SQL text is produced: identifiers checked, values bound. public Sql Build() { if (Columns.IsEmpty) throw new InvalidOperationException("no columns selected"); var sql = new StringBuilder("SELECT ") .Append(string.Join(", ", Columns.Select(c => Table.Column(c)))) .Append(" FROM ").Append(Table.Name); var ps = new List<object>(); for (var i = 0; i < Conditions.Count; i++) { sql.Append(i == 0 ? " WHERE " : " AND "); Conditions[i](Table, sql, ps); } if (Order is { } o) sql.Append(" ORDER BY ").Append(Table.Column(o.Column)).Append(' ').Append(o.Direction); if (Max is { } max) { sql.Append(" LIMIT ?"); ps.Add(max); } return new Sql(sql.ToString(), ps.ToImmutableArray()); }}
public static class Program{ public static void Main() { var users = Table.Of("users", "id", "email", "status", "country", "created_at");
// A shared base query. Branches below start from it and can't change it. var active = Query.From(users).Select("id", "email").Where(Where.Eq("status", "active"));
var eu = active .Where(Where.AnyOf(Where.Eq("country", "DE"), Where.In("country", "FR", "IT"))) .OrderBy("created_at", Dir.DESC) .Limit(50); // The same input that broke the interpolated version is now just a value. var byEmail = active.Where(Where.Eq("email", "x' OR '1'='1"));
Console.WriteLine(eu.Build()); // SELECT id, email FROM users WHERE status = ? AND (country = ? OR country IN (?, ?)) ORDER BY created_at DESC LIMIT ? [active, DE, FR, IT, 50] Console.WriteLine(byEmail.Build()); // SELECT id, email FROM users WHERE status = ? AND email = ? [active, x' OR '1'='1]
// Two threads branch from the same base at once. No locks: nothing is shared mutably. var de = Task.Run(() => active.Where(Where.Eq("country", "DE")).Build()); var fr = Task.Run(() => active.Where(Where.Eq("country", "FR")).Build()); Task.WaitAll(de, fr); Console.WriteLine(de.Result); // SELECT id, email FROM users WHERE status = ? AND country = ? [active, DE] Console.WriteLine(fr.Result); // SELECT id, email FROM users WHERE status = ? AND country = ? [active, FR] Console.WriteLine(active.Build()); // SELECT id, email FROM users WHERE status = ? [active]
// Anything that can't be a parameter is checked, and fails before any SQL exists. var bad = new Func<Sql>[] { () => active.OrderBy("created_at; DROP TABLE users", Dir.ASC).Build(), () => active.Where(Where.In("country")).Build(), () => Query.From(users).Build(), }; foreach (var attempt in bad) { try { attempt(); } catch (Exception e) when (e is ArgumentException or InvalidOperationException) { Console.WriteLine("error: " + e.Message); } } // error: unknown column 'created_at; DROP TABLE users' (allowed: id, email, status, country, created_at) // error: IN list for 'country' is empty // error: no columns selected }}package main
import ( "errors" "fmt" "slices" "strings" "sync")
// Sql is the product: SQL text with ? placeholders, and the values to bind to them, kept// apart. The driver sends the values separately, so the database never parses them as SQL.type Sql struct { Text string Params []any}
func (s Sql) String() string { parts := make([]string, len(s.Params)) for i, p := range s.Params { parts[i] = fmt.Sprint(p) } return s.Text + " [" + strings.Join(parts, ", ") + "]"}
// Table lists the allowed identifiers. Table and column names can't be bound as// parameters, so they are checked against this fixed list from code instead.type Table struct { Name string columns []string}
func NewTable(name string, columns ...string) Table { return Table{name, columns} }
func (t Table) Column(c string) (string, error) { if !slices.Contains(t.columns, c) { return "", fmt.Errorf("unknown column '%s' (allowed: %s)", c, strings.Join(t.columns, ", ")) } return c, nil}
type Dir string
const ( Asc Dir = "ASC" Desc Dir = "DESC")
// Condition writes its own SQL with placeholders and appends its values in the same// order. Conditions are small immutable values (closures here), and groups nest them.type Condition func(t Table, sql *strings.Builder, params *[]any) error
func Eq(column string, value any) Condition { return func(t Table, sql *strings.Builder, params *[]any) error { c, err := t.Column(column) if err != nil { return err } sql.WriteString(c + " = ?") *params = append(*params, value) return nil }}
func In(column string, values ...any) Condition { values = slices.Clone(values) return func(t Table, sql *strings.Builder, params *[]any) error { // "IN ()" is a syntax error in SQL, so fail here with a clear message instead. if len(values) == 0 { return fmt.Errorf("IN list for '%s' is empty", column) } c, err := t.Column(column) if err != nil { return err } sql.WriteString(c + " IN (" + strings.Repeat("?, ", len(values)-1) + "?)") *params = append(*params, values...) return nil }}
// A nested group renders its parts in parentheses, so precedence is explicit and an OR// can never leak out and swallow the rest of the WHERE clause.func AnyOf(parts ...Condition) Condition { return group(" OR ", parts) }func AllOf(parts ...Condition) Condition { return group(" AND ", parts) }
func group(op string, parts []Condition) Condition { parts = slices.Clone(parts) return func(t Table, sql *strings.Builder, params *[]any) error { sql.WriteString("(") for i, p := range parts { if i > 0 { sql.WriteString(op) } if err := p(t, sql, params); err != nil { return err } } sql.WriteString(")") return nil }}
// Query is an immutable builder: a value type whose methods take a copy and return a new// Query, never changing the one they were called on. A shared base query can be branched// by any number of goroutines without locks, because there is nothing to race on.// Cost: each step copies its slices (a few entries).type Query struct { table Table columns []string where []Condition orderBy string dir Dir limit int err error // a bad step is remembered and returned by Build}
func From(t Table) Query { return Query{table: t} }
func (q Query) Select(cols ...string) Query { q.columns = slices.Clone(cols) return q}
// Where is ANDed with earlier conditions. Clone before append: two branches appending to// the same backing array would overwrite each other's last condition.func (q Query) Where(c Condition) Query { q.where = append(slices.Clone(q.where), c) return q}
func (q Query) OrderBy(column string, d Dir) Query { q.orderBy, q.dir = column, d return q}
func (q Query) Limit(n int) Query { if (n < 1 || n > 1000) && q.err == nil { q.err = fmt.Errorf("limit must be 1..1000, got %d", n) } q.limit = n return q}
// Build is the one place SQL text is produced: identifiers checked, values bound.func (q Query) Build() (Sql, error) { if q.err != nil { return Sql{}, q.err } if len(q.columns) == 0 { return Sql{}, errors.New("no columns selected") } var sql strings.Builder params := []any{} sql.WriteString("SELECT ") for i, col := range q.columns { c, err := q.table.Column(col) if err != nil { return Sql{}, err } if i > 0 { sql.WriteString(", ") } sql.WriteString(c) } sql.WriteString(" FROM " + q.table.Name) for i, cond := range q.where { if i == 0 { sql.WriteString(" WHERE ") } else { sql.WriteString(" AND ") } if err := cond(q.table, &sql, ¶ms); err != nil { return Sql{}, err } } if q.orderBy != "" { c, err := q.table.Column(q.orderBy) if err != nil { return Sql{}, err } sql.WriteString(" ORDER BY " + c + " " + string(q.dir)) } if q.limit != 0 { sql.WriteString(" LIMIT ?") params = append(params, q.limit) } return Sql{sql.String(), params}, nil}
func main() { users := NewTable("users", "id", "email", "status", "country", "created_at")
// A shared base query. Branches below start from it and can't change it. active := From(users).Select("id", "email").Where(Eq("status", "active"))
eu := active. Where(AnyOf(Eq("country", "DE"), In("country", "FR", "IT"))). OrderBy("created_at", Desc). Limit(50) // The same input that broke the Sprintf version is now just a value. byEmail := active.Where(Eq("email", "x' OR '1'='1"))
s, _ := eu.Build() fmt.Println(s) // SELECT id, email FROM users WHERE status = ? AND (country = ? OR country IN (?, ?)) ORDER BY created_at DESC LIMIT ? [active, DE, FR, IT, 50] s, _ = byEmail.Build() fmt.Println(s) // SELECT id, email FROM users WHERE status = ? AND email = ? [active, x' OR '1'='1]
// Two goroutines branch from the same base at once. No locks: nothing is shared mutably. results := make([]Sql, 2) var wg sync.WaitGroup for i, country := range []string{"DE", "FR"} { wg.Add(1) go func() { defer wg.Done() results[i], _ = active.Where(Eq("country", country)).Build() }() } wg.Wait() fmt.Println(results[0]) // SELECT id, email FROM users WHERE status = ? AND country = ? [active, DE] fmt.Println(results[1]) // SELECT id, email FROM users WHERE status = ? AND country = ? [active, FR] s, _ = active.Build() fmt.Println(s) // SELECT id, email FROM users WHERE status = ? [active]
// Anything that can't be a parameter is checked, and fails before any SQL exists. bad := []Query{ active.OrderBy("created_at; DROP TABLE users", Asc), active.Where(In("country")), From(users), } for _, q := range bad { if _, err := q.Build(); err != nil { fmt.Println("error:", err) } } // error: unknown column 'created_at; DROP TABLE users' (allowed: id, email, status, country, created_at) // error: IN list for 'country' is empty // error: no columns selected}// The product: SQL text with ? placeholders, and the values to bind to them, kept apart.// The driver sends the values separately, so the database never parses them as SQL.class Sql { constructor(readonly text: string, readonly params: readonly unknown[]) { Object.freeze(this); } toString(): string { return `${this.text} [${this.params.join(", ")}]`; }}
// Identifiers (table and column names) can't be bound as parameters, so they are checked// against a fixed list from code instead. Anything not on the list is rejected.class Table { private constructor(readonly name: string, private readonly columns: readonly string[]) {}
static of(name: string, ...columns: string[]): Table { return new Table(name, Object.freeze([...columns])); }
column(c: string): string { if (!this.columns.includes(c)) throw new Error(`unknown column '${c}' (allowed: ${this.columns.join(", ")})`); return c; }}
type Dir = "ASC" | "DESC";
// A condition writes its own SQL with placeholders and appends its values in the same// order. Conditions are small immutable values (closures here), and groups nest them.type Condition = (t: Table, sql: string[], params: unknown[]) => void;
const eq = (column: string, value: unknown): Condition => (t, sql, params) => { sql.push(`${t.column(column)} = ?`); params.push(value);};
const inList = (column: string, ...values: unknown[]): Condition => (t, sql, params) => { // "IN ()" is a syntax error in SQL, so fail here with a clear message instead. if (values.length === 0) throw new Error(`IN list for '${column}' is empty`); sql.push(`${t.column(column)} IN (${values.map(() => "?").join(", ")})`); params.push(...values);};
// A nested group renders its parts in parentheses, so precedence is explicit and an OR// can never leak out and swallow the rest of the WHERE clause.const group = (op: string, parts: readonly Condition[]): Condition => (t, sql, params) => { sql.push("("); parts.forEach((part, i) => { if (i > 0) sql.push(op); part(t, sql, params); }); sql.push(")");};const anyOf = (...parts: Condition[]) => group(" OR ", parts);const allOf = (...parts: Condition[]) => group(" AND ", parts);
type QueryState = { readonly table: Table; readonly columns: readonly string[]; readonly where: readonly Condition[]; readonly orderBy: readonly [string, Dir] | null; readonly limit: number | null;};
// An immutable builder: every step returns a new Query and never changes this one. A// shared base query can be branched anywhere (across awaits, in parallel requests)// without one branch seeing another's conditions. Cost: each step copies a small object.class Query { private constructor(private readonly s: QueryState) { Object.freeze(this); }
static from(table: Table): Query { return new Query({ table, columns: [], where: [], orderBy: null, limit: null }); }
private with(change: Partial<QueryState>): Query { return new Query({ ...this.s, ...change }); }
select(...cols: string[]): Query { return this.with({ columns: [...cols] }); }
// Repeated where() calls are ANDed. A new array each time: this Query keeps its own. where(c: Condition): Query { return this.with({ where: [...this.s.where, c] }); }
orderBy(column: string, d: Dir): Query { return this.with({ orderBy: [column, d] }); }
limit(n: number): Query { if (!(n >= 1 && n <= 1000)) throw new RangeError(`limit must be 1..1000, got ${n}`); return this.with({ limit: n }); }
// build() is the one place SQL text is produced: identifiers checked, values bound. build(): Sql { const { table: t, columns, where, orderBy, limit } = this.s; if (columns.length === 0) throw new Error("no columns selected"); const sql = [`SELECT ${columns.map((c) => t.column(c)).join(", ")} FROM ${t.name}`]; const params: unknown[] = []; where.forEach((c, i) => { sql.push(i === 0 ? " WHERE " : " AND "); c(t, sql, params); }); if (orderBy !== null) sql.push(` ORDER BY ${t.column(orderBy[0])} ${orderBy[1]}`); if (limit !== null) { sql.push(" LIMIT ?"); params.push(limit); } return new Sql(sql.join(""), Object.freeze(params)); }}
const users = Table.of("users", "id", "email", "status", "country", "created_at");
// A shared base query. Branches below start from it and can't change it.const active = Query.from(users).select("id", "email").where(eq("status", "active"));
const eu = active .where(anyOf(eq("country", "DE"), inList("country", "FR", "IT"))) .orderBy("created_at", "DESC") .limit(50);// The same input that broke the template-string version is now just a value.const byEmail = active.where(eq("email", "x' OR '1'='1"));
console.log(String(eu.build()));// SELECT id, email FROM users WHERE status = ? AND (country = ? OR country IN (?, ?)) ORDER BY created_at DESC LIMIT ? [active, DE, FR, IT, 50]console.log(String(byEmail.build()));// SELECT id, email FROM users WHERE status = ? AND email = ? [active, x' OR '1'='1]
// JavaScript runs this on one thread, but concurrent requests still interleave at every// await. Two handlers branch from the same base at once; neither can see the other's.const handle = async (country: string) => { await Promise.resolve(); // stands in for reading the request return active.where(eq("country", country)).build();};const [de, fr] = await Promise.all([handle("DE"), handle("FR")]);console.log(String(de)); // SELECT id, email FROM users WHERE status = ? AND country = ? [active, DE]console.log(String(fr)); // SELECT id, email FROM users WHERE status = ? AND country = ? [active, FR]console.log(String(active.build())); // SELECT id, email FROM users WHERE status = ? [active]
// Anything that can't be a parameter is checked, and fails before any SQL exists.const bad = [ () => active.orderBy("created_at; DROP TABLE users", "ASC").build(), () => active.where(inList("country")).build(), () => Query.from(users).build(),];for (const attempt of bad) { try { attempt(); } catch (e) { console.log(`error: ${(e as Error).message}`); }}// error: unknown column 'created_at; DROP TABLE users' (allowed: id, email, status, country, created_at)// error: IN list for 'country' is empty// error: no columns selected
export {};What to notice
Section titled “What to notice”- Values never become SQL. Every value goes into
paramsand leaves a?in the text. It doesn’t matter what the value contains, so there is nothing to escape and nothing for an endpoint to forget. The builder makes the safe way the only way. - Identifiers are allow-listed, not escaped. Placeholders only work for values. Column names are checked against the table’s list and rejected with a message naming the valid ones. Sort direction is an enum, so it can’t carry text at all.
- Groups nest, and their parentheses are automatic.
anyOf(eq(...), in(...))always renders in parentheses. So anORcan’t leak out and change the meaning of the conditions around it. That’s exactly the bug in the injected query. - Each step returns a new builder. The base query is shared by every endpoint and every thread.
With a mutable builder,
base.where(...)would add the condition to the base itself. One request’s filter would appear in the next one’s query. And two threads appending at once could corrupt the list. With an immutable one, there’s nothing to lock. The cost is a small copy per step. C#’sImmutableListshares structure, so even that is cheap. Go must clone the slice before appending, because two branches appending to the same backing array would overwrite each other. build()refuses what SQL can’t express. An emptyIN ()is a syntax error in SQL, and a query with no columns is meaningless. Both fail inbuild()with a clear message, before anything reaches the database.
Step builders: enforcing order at compile time
Section titled “Step builders: enforcing order at compile time”The builder above checks at run time that select() was called. A staged (or “step”) builder
makes it a compile error. from() returns a type that only has select(). That returns a type that
has where(), orderBy() and build(), and so on. jOOQ’s SQL DSL is built this way. The editor’s
autocomplete then shows only the steps that are legal next.
Design the query layer for a search endpoint whose filters and sort order come from the request. Many endpoints share a base query, and requests run in parallel.
Whether you separate SQL text from bound parameters, and allow-list identifiers (since they can’t be bound). Whether you nest condition groups with explicit parentheses. And whether you make the builder immutable, so a shared base can be branched without locks. The red flag is escaping quotes as the defence, or a mutable builder shared between requests.
The builder emits SQL with placeholders and a separate parameter list, so user values are never parsed as SQL. Column names can’t be parameters, so I check them against the table’s allow-list. Each step returns a new query, so the shared base can be branched by any thread without locks.
Tradeoffs
Section titled “Tradeoffs”Use a builder in four cases. The object has many optional parts. Rules span several fields. It is assembled over several steps. Or the builder itself is worth keeping as a value (a query, a request template). Keep the constructor for small objects with a few required fields. And use named or default arguments where your language has them.
In each language
Section titled “In each language”| Language | Usual form | Built in or common |
|---|---|---|
| Java | A static nested Builder with a private product constructor; Lombok’s @Builder generates one (toBuilder = true adds toBuilder()) |
StringBuilder, HttpRequest.newBuilder(), Stream.builder(), Locale.Builder, protobuf and AWS SDK v2 builders |
| Python | Usually no builder class: keyword-only arguments with defaults on a @dataclass(frozen=True), and dataclasses.replace for changed copies (it skips custom checks unless they’re in __post_init__) |
SQLAlchemy’s select().where(...), which returns a new statement at each step |
| C# | Often object initializers with init and required properties, and records with with for copies. A builder when build() must check rules |
StringBuilder, UriBuilder, WebApplication.CreateBuilder, DbConnectionStringBuilder |
| Go | Functional options: NewServer(addr, WithTimeout(5*time.Second)), or a struct literal with named fields. Builders are rarer, and record errors for Build() |
gRPC’s grpc.DialOption, strings.Builder (a buffer, not this pattern) |
| TypeScript | Usually no builder class: one options object with defaults via destructuring, typed by an interface | Query builders such as Kysely, which return a new builder at each step |
Go’s idiom, functional options, gives named optional parts and safe defaults without a second type. Each option is a function that sets one field:
type Option func(*Client)
func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }
func NewClient(baseURL string, opts ...Option) *Client { c := &Client{baseURL: baseURL, timeout: 10 * time.Second} // defaults for _, o := range opts { o(c) } return c}
client := NewClient("https://pay.example", WithTimeout(5*time.Second))In Python, a frozen dataclass with keyword-only fields does the same. Every field after
_: KW_ONLY (Python 3.10+) must be passed by name, so none can be swapped.
@dataclass(frozen=True)class HttpRequest: method: str url: str _: KW_ONLY timeout_ms: int = 10_000 retries: int = 0 follow_redirects: bool = True verify_tls: bool = True
HttpRequest("POST", url, retries=3, follow_redirects=False)Commonly confused with
Section titled “Commonly confused with”| Pattern | Looks like Builder because | The difference |
|---|---|---|
| Factory | Both create objects | A factory decides which class to create, in one call, and usually returns an interface. A builder assembles one known class step by step. A factory may use a builder inside. |
| Abstract factory | Both have one method per “part” | An abstract factory returns several separate objects that must match. A builder’s steps all contribute to one object returned by build(). |
| Fluent interface (method chaining) | Builders are usually chained | Chaining is a style: methods return this. jQuery and stream pipelines chain without being builders, and a builder works fine without chaining. |
| Named and default arguments | They also name each value | A language feature that solves the telescoping-constructor problem with no extra class. It can’t collect values over several steps or be shared as a value, and it checks rules only if the constructor does. |
C# object initializers, records with |
Set named properties, copy with changes | Initializers set properties one at a time after the constructor, so no single point sees every value to check cross-field rules; with copies without re-validating. Fine for plain data, not for objects with invariants. |
| Go functional options | Named optional parts with defaults | Options are applied inside the constructor, so there’s no separate builder object. It’s Go’s usual answer; a builder is for multi-step assembly or reusable values. |
| TypeScript object literals | { timeout: 5000, retries: 3 } names every field |
An options object is the builder’s call site without the class. Add a builder when assembly is spread over steps or needs a build() checkpoint. |
Where it shows up
Section titled “Where it shows up”On this site:
- Hotel booking: a reservation request has the same cross-field rules as the Medium level, and a builder is a natural way to create it.
- Factory: a factory often configures what it creates with a builder.
In real systems: Java’s HttpRequest.newBuilder() and OkHttp’s Request.Builder. Protobuf messages
(newBuilder() and toBuilder()). The AWS SDK for Java v2 request builders. jOOQ and SQLAlchemy
queries. StringBuilder in Java and C#. And Lombok’s generated builders.
Interview questions
Section titled “Interview questions”Interviewer asksWhen would you use a builder instead of a constructor?
When the object has many optional fields, especially several of the same type, so positional arguments are easy to swap. When rules span fields, and need one checkpoint. Or when it’s assembled over several steps. For a few required fields, a constructor is clearer. In a language with named and default arguments, those usually cover the first case on their own.
Interviewer asksWhere should validation go?
Single-field checks can go in the step, so they fail where the bad value was passed. Cross-field
rules go in build(), where every value is known and order doesn’t matter, and build() should
report every broken rule at once. The product’s constructor stays private so nothing skips the
checks.
Interviewer asksIs a builder thread-safe?
A typical mutable builder is not, and doesn’t need to be. It’s created, filled and built by one thread, then thrown away. The product should be immutable, which makes it safe to share. If the builder itself is shared, like a base query, make it immutable. Then each step returns a new builder, and no locks are needed.
Interviewer asksHow do you change one field of an immutable object built with a builder?
Copy it back into a builder with toBuilder(), change the field, and build again. The copy goes
through the same checks, and the original is untouched, so anything holding it still sees a valid,
unchanged object.
| Decision | We chose | We gave up |
|---|---|---|
| How callers set fields | Named steps, any order, defaults in the builder | A second class that mirrors the product |
| Required fields | Parameters of the builder’s entry point | Nothing; setters would make them run-time errors |
| The product | Immutable, private constructor | Changes need toBuilder() and a rebuild |
| Rules that span fields | Checked in build(), all errors at once |
Errors show up at build(), not at the step |
| User input in a query | Bound parameters, allow-listed identifiers | Identifiers must be declared up front |
| A builder shared across requests | Immutable: each step returns a new builder | A small copy per step |
| Step order | Checked at run time in build() |
Compile-time order, which a staged builder gives at more cost |
Further reading
Section titled “Further reading”- Factory, for choosing which object to create
- Object modeling, for deciding what is a value and what is an entity
- Joshua Bloch, Effective Java, Item 2: “Consider a builder when faced with many constructor parameters”
- The OWASP SQL Injection Prevention Cheat Sheet, on parameterized queries and allow-listing
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.