Builder Pattern
Builder turns long, error-prone constructor calls into readable, validated, step-by-step assembly.
Introduction
Builder turns long, error-prone constructor calls into readable, validated, step-by-step assembly. The canonical case: many optional fields, cross-field validation rules, and immutability after construction. Effective Java Item 2 elevated Builder from GoF curiosity to default style in Java, Kotlin, OkHttp, Stripe SDKs, and AWS v2 fluent clients.
The story
A reporting service had a 14-parameter QueryRequest constructor. Engineers swapped two booleans (includeArchived vs includeDeleted) — both compiled, wrong data shipped. A Builder with named methods made the mistake structurally impossible.
The business problem
Telescoping constructors and positional args cause subtle production bugs:
- Positional confusion: two booleans or two numbers swapped — compiles, wrong behaviour.
- Telescoping overloads: 5-arg, 6-arg, 7-arg constructors — unmaintainable.
- Mutable options objects: helpers mutate shared state — stale filters in search APIs.
- Validation timing: cross-field rules can't run until all fields are set.
The problem teams faced
Builder earns its cost when you see:
- Constructors with 4+ parameters, especially optional ones.
- Cross-field invariants ("from must precede to", "pageSize > 0 if paginated").
- Immutability required after construction.
- Call sites that need to read like a spec, not a positional arg list.
Understanding the topic
Intent: Separate construction of a complex object from its representation; same process can build different representations.
- Builder — fluent setters returning this + terminal build().
- Product — immutable object build() produces.
- Director (optional) — orchestrates known recipe — rarely needed today.
- Step builder — type-enforced field ordering for mandatory fields.
Internal architecture
Fluent Builder structure:
class QueryRequest {private constructor(readonly fields: Fields) {}static builder() { return new QueryRequestBuilder() }}class QueryRequestBuilder {private fields: Partial<Fields> = {}withArchived() { this.fields.includeArchived = true; return this }withDeleted() { this.fields.includeDeleted = true; return this }build(): QueryRequest {if (this.fields.from && this.fields.to && this.fields.from > this.fields.to)throw new Error("from must precede to")return new QueryRequest(this.fields as Fields)}}
Visual explanation
Three diagrams cover Builder flow, validation, and when to skip:
Informative example
Before / after — positional constructor to fluent Builder:
// ❌ Positional booleans — swapped args compilenew QueryRequest("orders", true, false, 0, 50, null, null, true)// ✅ Named fluent API — self-documentingQueryRequest.builder().source("orders").withArchived().page(0, 50).sortBy("createdAt", "desc").build()// Cross-field validation at build()class DateRangeBuilder {private from?: Dateprivate to?: DatefromDate(d: Date) { this.from = d; return this }toDate(d: Date) { this.to = d; return this }build() {if (this.from && this.to && this.from > this.to)throw new Error("from must precede to")return new DateRange(this.from!, this.to!)}}
Execution workflow
Create builder
Product.builder() — never raw constructor from callers.
Real-world use
StringBuilder, ProcessBuilder, Lombok @Builder, Kotlin data class builders, OkHttpClient.Builder, Stripe Charge builders, AWS SDK v2 fluent clients, jOOQ/Knex query DSLs, test fixture builders.
Production case study
Search API with 22 optional filters:
- Scenario: Giant mutable Options object; helpers caused stale filter state.
- Decision: Fluent Builder; Object.freeze on product; validate at build().
- Outcome: search-bug reports down 40%; call sites self-document queries.
- Lesson: immutability + named methods beat shared mutable options.
Trade-offs
- Pro: eliminates positional-arg bugs; readable call sites.
- Pro: cross-field validation at build(); immutability preserved.
- Con: boilerplate for Builder + Product (Lombok/Kotlin reduce this).
- Con: two types to keep in sync when fields change.
Decision framework
- Use at ~4+ parameters or when optional fields appear with immutability.
- Use when cross-field validation spans multiple setters.
- Avoid for 1–3 fields with no optional defaults.
- Skip Director unless a real shared recipe exists (test fixtures).
Best practices
- Validate cross-field rules in build(); field-local rules in setters OK.
- Private product constructor — force callers through builder.
- Kotlin default args or TS optional params when validation needs are light.
Anti-patterns to avoid
- Builder for 2-field objects — constructor is clearer.
- Mutable product after build() — defeats the pattern.
- Director class nobody uses — YAGNI.
Common mistakes
- Builder and Product fields drift — generate or co-locate.
- Partial builder reuse across threads — build fresh per request.
- Forgetting to validate null required fields at build().
Debugging tips
- Log built product JSON at API boundary — trace wrong filter bugs.
- Unit test build() throws on every invalid combo — table-driven tests.
Optimization strategies
- Reuse immutable products when inputs unchanged — cache keyed by builder state hash.
Common misconceptions
- Builder ≠ Fluent Interface always. Builder produces one product; fluent APIs can stream operations.
- Kotlin data classes often replace Builder for simple optional fields.
Advanced interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1BeginnerQuestionBuilder vs many-arg constructor?+
Answer
Follow-up
2IntermediateQuestionWhy validate in build() not setters?+
Answer
Follow-up
3AdvancedQuestionWhat is step builder?+
Answer
Follow-up
4AdvancedQuestionDirector necessary?+
Answer
Follow-up
Summary
You can refactor telescoping constructors to Builder, validate at build(), and know when Kotlin defaults suffice. Tell the swapped-booleans QueryRequest story — if you can do that, you own Builder.