Design Patterns Tutorial 0/70 lessons ~6 min read Lesson 20

    Builder Pattern

    Builder turns long, error-prone constructor calls into readable, validated, step-by-step assembly.

    Course progress0%
    Focus
    21 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    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:

    ts
    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:

    Builder assembly
    Product.builder()
    Start fluent chain
    Named setters
    withArchived()
    build()
    Cross-field validate
    Immutable Product
    No setters
    Validation at build() sees final state — not partial setters.
    Builder vs constructor
    ≤3 required fields?
    Constructor OK
    Optional fields?
    Builder candidate
    Cross-field rules?
    build() validates
    Immutability?
    Builder + readonly
    Kotlin default args cover 80% of Builder value — reach for Builder for validation + immutability.
    Step builder (advanced)
    Mandatory fields
    Must set first
    Step1 interface
    Only legal next
    Step2 interface
    Type-enforced order
    build()
    Can't omit required
    Step builder when omitting a required field silently produces invalid product.

    Informative example

    Before / after — positional constructor to fluent Builder:

    ts
    // ❌ Positional booleans — swapped args compile
    new QueryRequest("orders", true, false, 0, 50, null, null, true)
    // ✅ Named fluent API — self-documenting
    QueryRequest.builder()
    .source("orders")
    .withArchived()
    .page(0, 50)
    .sortBy("createdAt", "desc")
    .build()
    // Cross-field validation at build()
    class DateRangeBuilder {
    private from?: Date
    private to?: Date
    fromDate(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

    1Builder lifecycle
    1 / 4

    Create builder

    Product.builder() — never raw constructor from callers.

    Constructor private or protected.

    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.

    4 questions
    1BeginnerQuestionBuilder vs many-arg constructor?+

    Answer

    Named methods make purpose explicit. Two booleans become withArchived() and withDeleted() — can't swap silently.

    Follow-up

    What besides telescoping?
    2IntermediateQuestionWhy validate in build() not setters?+

    Answer

    Cross-field rules need both fields set. build() sees final state and fails once with clear message.

    Follow-up

    When validate in setter too?
    3AdvancedQuestionWhat is step builder?+

    Answer

    One interface per step — type system exposes only legal next methods. Worth it when omitting required field produces invalid product silently.

    Follow-up

    Why plain Builder can't guarantee?
    4AdvancedQuestionDirector necessary?+

    Answer

    Rarely today. Useful when same construction recipe reused across many builders — usually lives in one factory method instead.

    Follow-up

    Where Director still helps?

    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.

    Ready to mark this lesson complete?Track your journey across the entire course.