Skip to content

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.

Foundation20 min read

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 idea in one picture
new HttpRequest("POST", url, h, body, 5000, 3, true, false) which true is which? the caller names each part builder("POST", url) .header("Idem-Key", …) .retries(3) .followRedirects(false) any order, skip what you don't need Builder collects named parts defaults: tls on, timeout 10s, 0 retries build() rules checked here, once HttpRequest POST /charges retries = 3 followRedirects = false verifyTls = true (the default) immutable: valid once built, safe to share a broken rule? build() fails, no half-made object escapes
Scroll or pinch to zoom, drag to pan
The caller names each part it sets, the builder holds defaults, and build() checks the rules once and returns an immutable object. A long positional constructor gives none of that.

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.
The general shape

sets named parts

build() checks, then creates

uses

Client

Builder

-required

-optional = default

+partA(value) : Builder

+partB(value) : Builder

+build() : Product

Product

+fields(read-only)

sets named parts

build() checks, then creates

uses

Client

Builder

-required

-optional = default

+partA(value) : Builder

+partB(value) : Builder

+build() : Product

Product

+fields(read-only)

Scroll or pinch to zoom, drag to pan
  • 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 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:

Main.javahtml_receipt.pyReceiptWriter.cshtml_receipt.gohtml-receipt.ts
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>
}
}

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.

Easy

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.

The class grows “telescoping” constructors: a short one, a longer one, and one with every parameter in order.

HttpRequest.javahttp_request.pyHttpRequest.cshttp_request.gohttp-request.ts
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.
}
}

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.

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:

Easy: a request builder

builder(method, url)

build()

PaymentsClient

+charge(order)

Builder

-method, url

-headers = []

-timeoutMs = 10000

-retries = 0

-followRedirects = true

-verifyTls = true

+header(name, value) : Builder

+body(text) : Builder

+timeoutMs(ms) : Builder

+retries(n) : Builder

+followRedirects(on) : Builder

+build() : HttpRequest

HttpRequest

+method, url, headers, body

+timeoutMs, retries

+followRedirects, verifyTls

-HttpRequest(Builder)

builder(method, url)

build()

PaymentsClient

+charge(order)

Builder

-method, url

-headers = []

-timeoutMs = 10000

-retries = 0

-followRedirects = true

-verifyTls = true

+header(name, value) : Builder

+body(text) : Builder

+timeoutMs(ms) : Builder

+retries(n) : Builder

+followRedirects(on) : Builder

+build() : HttpRequest

HttpRequest

+method, url, headers, body

+timeoutMs, retries

+followRedirects, verifyTls

-HttpRequest(Builder)

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation

Toggle the optional parts. Then switch to the positional constructor and swap the booleans:

Optional parts

Call site

Built HttpRequest immutable

HttpRequest.javahttp_request_builder.pyHttpRequest.csrequest_builder.gohttp-request-builder.ts
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
}
}
}
  • 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 from Build().
Interviewer

This constructor takes nine arguments and we keep getting bugs from callers passing them in the wrong order. How would you fix it?

What they are testing

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.

Say this out loud

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.

Medium

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.

A mutable Booking with setters, each checking what it can see:

Booking.javabooking.pyBooking.csbooking.gobooking.ts
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
}
}

Two 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.

Steps only record values. build() checks every rule against the complete set of values, collects every violation, and only then creates the booking:

Medium: build() is the only way to get a Booking

fills

build() if every rule holds

otherwise throws

toBuilder() copies

BookingForm

+submit()

Builder

-room

-checkIn, checkOut

-adults = 1, children = 0

-promoCode, corporateRate

+checkIn(date) : Builder

+checkOut(date) : Builder

+adults(n) : Builder

+promoCode(code) : Builder

+build() : Booking

Booking

+room, checkIn, checkOut

+adults, children

+nights()

+toBuilder() : Builder

«enumeration»

RoomType

STANDARD 2

FAMILY 4

InvalidBooking

+errors: List<String>

fills

build() if every rule holds

otherwise throws

toBuilder() copies

BookingForm

+submit()

Builder

-room

-checkIn, checkOut

-adults = 1, children = 0

-promoCode, corporateRate

+checkIn(date) : Builder

+checkOut(date) : Builder

+adults(n) : Builder

+promoCode(code) : Builder

+build() : Booking

Booking

+room, checkIn, checkOut

+adults, children

+nights()

+toBuilder() : Builder

«enumeration»

RoomType

STANDARD 2

FAMILY 4

InvalidBooking

+errors: List<String>

Scroll or pinch to zoom, drag to pan
  • 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.

Booking.javabooking_builder.pyBooking.csbooking_builder.gobooking-builder.ts
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
}
}
}
  • 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. InvalidBooking carries the whole list, which an API can return as a field-by-field error response.
  • Valid by construction. The Booking constructor is private and is only called after the checks pass. Code that receives a Booking never 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 with on a record and Python’s dataclasses.replace copy an object without running build(). That’s why the copy goes through toBuilder() here.
Interviewer

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?

What they are testing

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.

Hard

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.

Main.javaexample.pyProgram.csmain.goexample.ts
// 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
}
}

The 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.

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

Hard: an immutable query builder

branches a shared base

*

nests

*

checks identifiers

build()

UserSearch

+handle(request)

Query

-table, columns

-where: List<Condition>

-orderBy, limit

+select(cols) : Query

+where(cond) : Query

+orderBy(col, dir) : Query

+limit(n) : Query

+build() : Sql

«interface»

Condition

+render(table, sql, params)

Eq

In

Group

-op: AND or OR

-parts: List<Condition>

Table

-name

-allowed columns

+column(name) : checked name

Sql

+text with ? placeholders

+params

branches a shared base

*

nests

*

checks identifiers

build()

UserSearch

+handle(request)

Query

-table, columns

-where: List<Condition>

-orderBy, limit

+select(cols) : Query

+where(cond) : Query

+orderBy(col, dir) : Query

+limit(n) : Query

+build() : Sql

«interface»

Condition

+render(table, sql, params)

Eq

In

Group

-op: AND or OR

-parts: List<Condition>

Table

-name

-allowed columns

+column(name) : checked name

Sql

+text with ? placeholders

+params

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
Two requests branch from the same base query
base query (shared)Request FRRequest DEbase query (shared)Request FRRequest DEunchanged: still "status = ?"where(country = "DE")new Query (base + DE)where(country = "FR")new Query (base + FR)build() → SQL + [active, DE]build() → SQL + [active, FR]base query (shared)Request FRRequest DEbase query (shared)Request FRRequest DEunchanged: still "status = ?"where(country = "DE")new Query (base + DE)where(country = "FR")new Query (base + FR)build() → SQL + [active, DE]build() → SQL + [active, FR]
Scroll or pinch to zoom, drag to pan
Query.javaquery.pyProgram.csquery.goquery.ts
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
}
}
  • Values never become SQL. Every value goes into params and 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 an OR can’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#’s ImmutableList shares 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 empty IN () is a syntax error in SQL, and a query with no columns is meaningless. Both fail in build() 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.

Interviewer

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.

What they are testing

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.

Say this out loud

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.

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.

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)
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.

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.

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
  • 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