skip to content

In a builder, how do you guarantee that required parameters were actually supplied, and what are the trade-offs between checking in build() at runtime and using a staged (step) builder that enforces it at compile time?

level: seniorimportance: should knowfreq 32%

answer

  1. required in builder ctor = compile-time, but telescopes
  2. build() check = flexible, runtime-only
  3. staged builder: each step returns the next interface
  4. staged fixes order, breaks conditional rules
  5. report all missing fields at once

basics

~20 s

Simplest: take required values in the builder's constructor or factory method, and check the rest in build(), throwing if something is missing. Stronger: a staged builder, where each step returns a different type that only exposes the next required step, so forgetting one will not compile.

solid answer

~60 s

Three escalating techniques. (1) **Required-in-constructor** — `Builder(id, name)` takes the mandatory values, step methods cover only optionals. Cheap and compile-time safe, but degrades to a telescoping constructor if 'required' grows and offers no protection for conditionally required fields. (2) **Runtime check in build()** — accumulate everything, then throw `IllegalStateException("pipeline: source is required")` listing what is missing. Flexible, handles cross-field and conditional rules, but the failure is at runtime, so it depends on tests or production traffic to surface. (3) **Staged/step builder** — each required step returns a *different interface* exposing only the next legal step; the terminal interface exposes `build()`. Omitting or reordering a required step becomes a compile error and IDE autocomplete becomes a guided path. Costs: one interface per stage (verbose, usually generated), a fixed step order, awkward encoding of "exactly one of A or B" or conditional requirements, and noisier types in errors. Pragmatic rule: constructor for the small stable required set, build() checks for conditional/cross-field rules, staged builders reserved for widely used public APIs where misuse is expensive.

code

pseudocode · 14 lines
pseudocode
// staged: build() is unreachable until the required steps are done
interface FromStep  { ToStep    from(Endpoint e) }
interface ToStep    { FinalStep to(Endpoint e) }
interface FinalStep { FinalStep retries(int n); Route build() }

route().from(a).to(b).retries(3).build()   // ok
route().from(a).build()                    // COMPILE ERROR: ToStep has no build()

// runtime alternative: flexible, but fails only when executed
Route build() {
  missing = [f for f in ["from","to"] if unset(f)]
  if missing: throw IllegalState("missing required: " + missing.join(", "))
  ...
}

go deeper

for a junior

Say required values can go in the builder's constructor, and that build() should throw a clear error if something mandatory is missing.

for a middle

Compare constructor-required vs build()-validated, and give a good error message shape that lists every missing field.

for a senior

Explain the staged/step builder mechanism (each step returns the next interface, build() only on the terminal one) and its costs: type explosion, fixed order, weak fit for conditional rules.

for a principal

Decide by blast radius: staged builders for widely consumed public APIs where misuse is costly, build() validation elsewhere, and consider whether the language's named/required parameters make the whole apparatus unnecessary.

### The gap Builder opens A plain constructor makes required parameters unavoidable — you cannot call `new Booking(start, end)` without both. A naive builder throws that away: every field is an optional-looking step, so `Booking.builder().start(t).build()` compiles happily and blows up later. Restoring the guarantee is a design decision, not an automatic property of the pattern. ### Technique 1 — required values enter through the builder's constructor/factory ``` Booking.builder(start, end) // mandatory .guest(g) // optional .note("late arrival") .build(); ``` - **Pros:** compile-time enforced, zero extra types, obvious. - **Cons:** as the required set grows you have reinvented the telescoping constructor inside the builder; same-typed required parameters can still be swapped positionally; it cannot express "required only if X is set". - Best when required fields are few (1–3) and stable. ### Technique 2 — validate in build() ``` Booking build() { var missing = new ArrayList<String>(); if (start == null) missing.add("start"); if (end == null) missing.add("end"); if (!missing.isEmpty()) throw new IllegalStateException("missing required: " + String.join(", ", missing)); if (!end.isAfter(start)) throw new IllegalArgumentException("end must be after start"); return new Booking(this); } ``` - **Pros:** handles everything — plain required fields, cross-field rules (`end > start`), conditional rules ("if `recurring`, then `frequency` is required"), mutual exclusion ("exactly one of `url` or `file`"). Error messages can be excellent: list *all* missing fields at once rather than failing on the first. - **Cons:** runtime failure. If the misuse sits on a rarely exercised code path, it ships. Mitigate by failing fast at startup — construct configuration objects during application bootstrap rather than lazily on first request — and by unit-testing the builder's rejection paths. - This is the workhorse. Most production builders use it. ### Technique 3 — staged (step, type-safe, "wizard") builder Each mandatory step returns a *different type* that exposes only what may legally come next: ``` interface FromStep { ToStep from(Endpoint e); } interface ToStep { FinalStep to(Endpoint e); } interface FinalStep { FinalStep retries(int n); // optionals loop back Route build(); } Routes.route() // returns FromStep .from(a) // returns ToStep — .build() is not even visible here .to(b) // returns FinalStep .retries(3) .build(); ``` Omitting `.to(b)` does not compile, because `ToStep` has no `build()`. The IDE's autocomplete becomes a guided wizard: at each point you see only legal continuations. This encodes a small state machine in the type system — sometimes called a *typestate* encoding. - **Pros:** compile-time enforcement of both presence *and* order; discoverability; impossible-to-misuse public APIs (routing DSLs, HTTP clients, query DSLs, test fixtures). - **Cons:** - One interface per stage — verbose; realistically you generate them or accept the boilerplate. - **Order is fixed.** Callers lose the order-independence that made fluent builders pleasant. With *n* mandatory fields, supporting every order needs a combinatorial number of interfaces. - Conditional requirements ("`frequency` required only if `recurring`") and "exactly one of" constraints are painful or impossible to encode; you fall back to build() checks anyway. - Type names leak into error messages, signatures and generics, hurting readability and refactoring. - Cannot express data-dependent rules known only at runtime (e.g. "port must be free"). ### Choosing | Situation | Reach for | |---|---| | 1–3 stable required fields | required-in-constructor | | Cross-field / conditional / mutual-exclusion rules | build() validation | | Public API, many consumers, misuse expensive or non-obvious | staged builder (+ build() checks for the rest) | | Language has named args with defaults and required params | often no builder at all | These combine: a staged builder still validates value ranges in `build()`, and a constructor-required builder still checks cross-field rules there. ### Adjacent techniques worth naming - **Optional/nullable-aware types** — making a step take a non-nullable type stops `null` sneaking in as a "supplied" value; in languages without null-safety, add explicit null checks in the step methods so the stack trace points at the caller, not at `build()`. - **Declarative validation** (annotation- or schema-driven) — for configuration objects, a validator run over the built object can replace hand-written checks, at the cost of failing after construction rather than before. - **Making build() one-shot** — throwing on a second `build()` prevents a subtly different failure: reusing a builder and silently inheriting earlier state. - **Error-message quality is part of the design** — report every missing field at once, name the fields exactly as the API names them, and prefer `IllegalStateException` (builder is in a bad state) over `NullPointerException` at some unrelated line.

  • Why not just make every required field a builder-constructor parameter and be done?
    It works for a small, stable required set, but it re-creates the telescoping-constructor problem once several fields are required — long positional lists, same-typed values that can be swapped, and no way to express 'required only when another field is set'. It also forces an API break every time a field becomes mandatory.
  • What does a staged builder handle badly?
    Anything not expressible as a fixed linear order: conditional requirements, 'exactly one of A or B', ordering that callers legitimately want to vary, and value-dependent rules. It also multiplies types — n required fields in arbitrary order would need a combinatorial number of interfaces — so real staged builders pick one canonical order and still validate the rest in build().
  • How should build() report multiple missing required fields?
    Collect them and throw once with all of them named, e.g. IllegalStateException("missing required: source, sink"). Failing on the first missing field forces callers into a fix-run-fix loop. Use a state exception rather than letting a NullPointerException surface from deep inside the product's constructor.

A visa application kiosk that will not print a 'next' button until the current mandatory field is filled is the staged builder; a clerk who takes the whole form and then reads back every missing box is build() validation.

saying these in an interview costs you the question

  • Assuming a builder automatically enforces required fields — it does not unless you design for it
  • Believing a staged builder removes the need for build() validation of ranges, cross-field and conditional rules
  • Claiming staged builders keep the steps order-independent — they deliberately fix the order
  • Throwing NullPointerException from the product's constructor instead of a clear 'missing required: …' from build()
  • Failing on the first missing field instead of reporting all of them

context