When would you choose Builder over a plain constructor or static factory, and when would Prototype beat both?
answer
- Builder: many optionals, immutable product, validate at build()
- Director + different Builders = different representations
- named/default args shrink Builder's need
- Prototype: expensive construction, unknown class, template registry
- clone pitfalls: shallow vs deep, cycles, IDs and open resources
basics
~20 sUse Builder when an object has many optional or order-dependent parts and a constructor would become a long, unreadable parameter list. Use Prototype when creating a fresh object is expensive or its configuration is easier to copy from an existing instance than to specify again.
solid answer
~50 s**Constructor / static factory** is the default: few parameters, all required, one representation. Cheapest to read and debug. **Builder** earns its cost when construction is *complex*: many optional parameters (avoiding telescoping constructors and boolean-soup argument lists), named steps that make call sites self-documenting, validation of the whole object at `build()` time so the product can be immutable and never exist half-initialized, or the same construction process producing different representations (GoF's original framing: a Director drives steps, different Builders yield HTML vs plain text). It also naturally builds recursive/nested structures. **Prototype** earns its cost when you already have an instance whose state is the specification: constructing from scratch is expensive (deep parsing, remote calls, heavy defaults), the exact class is unknown at the call site (you clone whatever you were handed), or you need many near-identical variations — clone a registered template and tweak. Its price is copy semantics: shallow vs deep copy, cyclic references, identity/ID fields that must not be duplicated.
code
pseudocode · 14 lines// Builder: optional parts, validated once, product immutable
val req = Request.builder()
.url("https://api")
.header("Accept", "json")
.timeout(30)
.build() // throws if url missing; Request has no setters
// Prototype: copy a configured instance instead of rebuilding it
val template = registry.get("warning-dialog") // parsed + styled once
val dialog = template.deepCopy() // no class named here
dialog.title = "Disk full"
// copy-with: prototype semantics with builder validation
val slower = req.toBuilder().timeout(60).build()go deeper
Say Builder is for objects with many optional parts so you avoid huge constructors, and Prototype is copying an existing object instead of building a new one.
Add validation-at-build with an immutable product, mention that named/default arguments cover the simple case, and name shallow-vs-deep copy as Prototype's main hazard.
Bring in the GoF Director framing (same process, different representations), staged builders for compile-time required fields, prototype registries, and cycle/identity handling in deep copies.
Discuss construction as an API design decision: what the type system should guarantee about validity, immutability as the way to make copying trivial, and the maintenance cost of hand-written builders versus generated or language-native copy-with.
## Baseline: why constructors sometimes fail A constructor is the simplest creation mechanism and should be the default. It fails in specific ways: 1. **Telescoping constructors** — `Pizza(size)`, `Pizza(size, cheese)`, `Pizza(size, cheese, olives)`, … an exponential set of overloads. 2. **Unreadable call sites** — `new Server("a", 8080, true, false, true, null, 30)`: the reader cannot tell what the booleans mean. 3. **Invalid intermediate states** — if you instead use setters after a no-arg constructor, the object is observable while incomplete and cannot be immutable. 4. **Cross-field validation** — "if retries > 0 then a backoff must be set" has no natural home in a setter-based flow. Note: languages with **named and default arguments** (Kotlin, Python, C#, Swift, and Java records with a compact canonical constructor) solve problems 1 and 2 without a Builder. That materially shrinks Builder's territory — a fact worth saying out loud in an interview. ## Builder **Intent (GoF):** separate the construction of a complex object from its representation, so the same construction process can create different representations. Two common shapes: - **Fluent/telescoping-killer builder** (the common modern usage): `Request.builder().url(u).header(k,v).timeout(30).build()`. Accumulates state, validates once, returns an immutable product. - **Classic GoF builder with a Director**: a `Director` knows the *sequence* of steps (`buildHeader()`, `buildBody()`, `buildFooter()`); different concrete Builders (`HtmlBuilder`, `MarkdownBuilder`) yield different representations from the identical sequence. This is the part most people forget. Use it when: - Many optional parameters, especially of the same type (easy to transpose positionally). - The product should be **immutable** and fully validated at `build()`. - Construction proceeds in **steps**, possibly conditional (`if (secure) b.tls(cfg)`). - You build **nested/recursive** structures (a document tree, a query AST). - The same steps must produce different **representations/formats**. Costs and pitfalls: - Duplicated field list in builder and product (mitigated by codegen: Lombok `@Builder`, records + copy methods). - Required fields are enforced at **runtime** in `build()`, not by the compiler — unless you use a *staged/step builder* (each step returns a different interface so the compiler enforces order and completeness). - Reused builder instances leaking state between products. - A builder for a 3-field value object is pure ceremony. ## Prototype **Intent (GoF):** specify the kinds of objects to create using a prototypical instance, and create new objects by copying that prototype. Use it when: - **Construction is expensive** relative to copying: state loaded from a database, a parsed grammar, a warmed configuration, a heavy default graph. - **The concrete class is unknown** at the call site: you hold `Shape` and call `shape.clone()`; a plugin-supplied subclass copies itself correctly. - **Many near-identical variants** are needed: keep a **prototype registry** (`registry.get("warning-dialog").clone()`), then tweak the copy. This replaces a subclass-per-variant explosion with configured instances. - **Undo/snapshot** flows need a point-in-time copy (adjacent to Memento). Costs and pitfalls: - **Shallow vs deep copy** is the whole game. A shallow copy shares referenced sub-objects, so mutating the clone can corrupt the original. A deep copy must recurse — and handle **cycles** (usually with an identity map of already-copied objects). - **Identity fields**: database primary keys, UUIDs, timestamps, version counters and open resources (sockets, file handles) must be reset or re-created, not copied. - **Immutable objects** need no deep copy at all — sharing is safe — which is why prototype pain largely disappears in immutable designs. - Some languages' built-in clone facilities are notoriously awkward (Java's `Cloneable`/`Object.clone()`); a copy constructor or explicit `copy()` method is usually preferable. - Serialize-then-deserialize is a common brute-force deep copy: correct but slow and dependent on everything being serializable. ## Combining them They are complementary. A frequent modern idiom is **copy-with**: take an existing instance, get a builder pre-populated from it, change one field, build a new immutable instance (`existing.toBuilder().timeout(60).build()`, or Kotlin's data-class `copy(timeout = 60)`). That is Prototype's "start from a configured instance" with Builder's "controlled, validated assembly" — and it avoids clone semantics entirely because the product is immutable. ## Decision summary | Situation | Choose | |---|---| | 2–4 required parameters, one representation | constructor | | Need to hide the concrete class / cache / pick by argument | static factory method | | Many optional params, immutability, cross-field validation, stepwise or nested assembly | Builder | | Same steps must yield different output representations | Builder with a Director | | Expensive-to-construct state already exists in an instance | Prototype | | Concrete class unknown at the call site but an instance is in hand | Prototype | | Many variants of a configured template | Prototype + registry | | Small tweak to an existing immutable object | copy-with (Prototype ∩ Builder) |
- Kotlin and Python have named arguments with defaults. Does that make Builder obsolete?It removes the most common motivation (avoiding telescoping constructors and unreadable positional call sites), but not the pattern. Builder still wins for stepwise/conditional assembly, nested or recursive structures, the same process producing different representations, incremental accumulation across code paths, and staged builders that make required fields a compile-time constraint.
- What is the difference between a shallow and a deep copy, and when does the difference bite?A shallow copy duplicates the top-level object but shares the objects its fields reference; a deep copy recursively duplicates the referenced graph. It bites when the copy is mutated and the original changes too. Deep copying must handle cycles (track already-copied objects by identity), skip or regenerate identity fields such as database IDs, and re-establish rather than duplicate resources like sockets. With fully immutable objects, sharing is safe and a shallow copy suffices.
- How would you enforce at compile time that a builder's required fields are set?Use a staged (step) builder: each mandatory step returns a different interface exposing only the next legal call, and only the final stage exposes `build()`. Alternatively, take required fields in the builder's constructor/factory and leave only the optional ones as fluent methods.
Builder is ordering a custom sandwich by naming each topping — the order is readable and the sandwich is only handed over when complete. Prototype is pointing at what someone else is eating and saying "the same, but no onions" — cheaper when the reference already exists.
saying these in an interview costs you the question
- Generating a builder for every class, including 2-field value objects
- Claiming Builder's only purpose is avoiding long parameter lists — GoF's stated intent is separating construction from representation
- Reusing a single mutable builder instance across products and leaking state
- Assuming a clone is deep by default — most language-provided clones are shallow
- Copying identity fields (primary keys, UUIDs) into a clone and then saving it
- Confusing Builder with Factory: a Factory returns a finished product from one call; a Builder accumulates state across calls before producing it