Java design pattern deep dive
Builder Pattern in Java: 100 interview questions with professional answers.
Master fluent object construction: telescoping constructors, staged builders, Lombok, records, immutability, thread safety, and how Builder shows up in AWS SDK, protobuf, Kafka, and Spring in production code.
What makes a good Builder pattern answer?
Interviewers want to see more than method chaining. They are listening for immutability, required-versus-optional field handling, validation timing, and whether you know when Builder is the wrong tool.
| Approach | Use when | Watch out for |
|---|---|---|
| Fluent internal builder | You want readable, chainable construction for a class with several optional fields. | Can grow into a "God Builder" if unrelated concerns pile onto one class. |
| Classic GoF Director + Builder | The same construction sequence must produce different representations behind a shared interface. | Adds ceremony most modern Java codebases don't need; rarely worth it for a single product type. |
| Staged / step builder | Required fields must be enforced by the compiler, not by a runtime check. | Interfaces multiply and generics can hurt IDE auto-complete and var inference. |
Lombok @Builder / @SuperBuilder | You want a fluent builder without hand-writing boilerplate, including for class hierarchies. | Generated code is invisible in the source; defaults and inheritance quirks need care. |
| Record with a compact constructor | The type is a simple immutable data carrier with few or no optional fields. | No native builder syntax; you must layer one on top for named-parameter ergonomics. |
Topics
Interview questions and answers
Each answer gives the implementation direction, the trade-off worth stating out loud, and the production detail that separates a memorized definition from real experience.
1. What core problem does the Builder pattern solve compared to a constructor with a dozen parameters?
A constructor with many parameters forces every caller to remember positional order, and adding an overload for each combination of optional arguments explodes combinatorially — this is the "telescoping constructor" problem. Builder replaces position with named, chained calls, so optional fields can be supplied in any order or skipped entirely, and the code reads like a description of the object rather than a guessing game about argument slots.
// Telescoping constructor problem
public class Pizza {
public Pizza(int size) { this(size, false); }
public Pizza(int size, boolean cheese) { this(size, cheese, false); }
public Pizza(int size, boolean cheese, boolean pepperoni) { this(size, cheese, pepperoni, false); }
public Pizza(int size, boolean cheese, boolean pepperoni, boolean mushroom) {
// which boolean was which, at the call site?
}
}
// call site: unreadable and error-prone
new Pizza(12, true, false, true);
2. How does the classic GoF Builder (Director + Builder + ConcreteBuilder + Product) differ from the fluent "internal builder" style, and when is the Director still worth keeping?
The GoF version separates a Director that knows the construction sequence from a Builder interface that different ConcreteBuilder implementations satisfy, so the same sequence of steps can yield different representations — for example rendering a document to HTML or to PDF from identical build instructions. Modern Java code almost always drops the Director and the separate interface, and instead uses a single static nested builder class with chained setters that returns the immutable product from build().
The Director earns its keep when you truly have multiple interchangeable builders that must be driven through the identical sequence of steps — for example one algorithm that assembles both a "compact" and a "verbose" report from the same ordered instructions. If there is only one concrete product type, the Director is pure ceremony and the fluent internal builder is the better default.
3. Walk through implementing a static nested Builder class for an immutable Order domain object, including compile-time versus runtime enforcement of mandatory fields.
The product class exposes only a private constructor that the nested builder calls, and every field on the product is final. Required fields are captured as constructor parameters on the builder itself (compile-time enforcement, since the builder cannot be instantiated without them), while optional fields are set through chained methods with sensible defaults, and any remaining invariant that spans multiple fields is checked once inside build() (runtime enforcement).
public final class Order {
private final String customerId;
private final List items;
private final String couponCode;
private Order(Builder b) {
this.customerId = b.customerId;
this.items = List.copyOf(b.items);
this.couponCode = b.couponCode;
}
public static Builder builder(String customerId) {
return new Builder(customerId);
}
public static final class Builder {
private final String customerId; // required, set via constructor
private final List items = new ArrayList<>();
private String couponCode; // optional
private Builder(String customerId) {
this.customerId = Objects.requireNonNull(customerId, "customerId");
}
public Builder addItem(String sku) { items.add(sku); return this; }
public Builder couponCode(String code) { this.couponCode = code; return this; }
public Order build() {
if (items.isEmpty()) throw new IllegalStateException("Order needs at least one item");
return new Order(this);
}
}
}
4. How would you design a Builder so required fields must be supplied before optional ones, using a staged/step-builder approach?
Instead of one builder class with every setter available at once, you split the API into a sequence of narrow interfaces, each exposing only the next required step, ending in a final interface that exposes the optional setters plus build(). A single implementation class implements every interface, but the compiler only lets the caller see the methods of the interface the previous step returned, so skipping a required field is a compile error, not a runtime surprise.
public interface NameStep { PriceStep name(String name); }
public interface PriceStep { BuildStep price(BigDecimal price); }
public interface BuildStep {
BuildStep discount(BigDecimal pct); // optional
Product build();
}
public final class ProductBuilder implements NameStep, PriceStep, BuildStep {
private String name;
private BigDecimal price;
private BigDecimal discount = BigDecimal.ZERO;
public static NameStep newBuilder() { return new ProductBuilder(); }
public PriceStep name(String name) { this.name = name; return this; }
public BuildStep price(BigDecimal price) { this.price = price; return this; }
public BuildStep discount(BigDecimal pct) { this.discount = pct; return this; }
public Product build() { return new Product(name, price, discount); }
}
// call site: order is enforced, optional step can be skipped
Product p = ProductBuilder.newBuilder().name("Mouse").price(new BigDecimal("29.99")).build();
5. Explain how Lombok's @Builder and @SuperBuilder generate builder code, and what subtle issues arise with class hierarchies.
@Builder generates a static nested Builder class with one field-backed setter per constructor parameter, a private all-args constructor on the target class, and a build() method that invokes it — functionally identical to what you would hand-write. @SuperBuilder extends this to class hierarchies by generating an abstract builder in the superclass and a generic self-typed subclass builder, so subclass builders can chain both parent and child setters and still return the correct subtype.
The subtle issues: every class in the hierarchy that participates must itself be annotated with @SuperBuilder — mixing plain @Builder and @SuperBuilder in the same hierarchy fails to compile; field initializers on the superclass are easy to lose if the constructor chain isn't set up correctly; and because the generated code is invisible in source, hierarchy refactors often produce confusing Lombok-generated compiler errors that don't point at your code directly.
@SuperBuilder can silently change the builder's fluent method names across every subclass, breaking callers with no visible diff in your own class.6. Describe how you would implement builder inheritance in plain Java using self-referential generics so a subclass builder's chained methods return the correct subtype.
The recursive generic bound — sometimes called the "curiously recurring generic pattern" — parameterizes the builder on its own concrete subtype: abstract class AbstractBuilder<T extends AbstractBuilder<T>>. Every chained setter in the base class returns self(), an abstract method each subclass implements to return this cast to its own type, so calling a base-class setter followed by a subclass-only setter still type-checks without any casts at the call site.
abstract class VehicleBuilder> {
protected String color;
public T color(String color) { this.color = color; return self(); }
protected abstract T self();
public abstract Vehicle build();
}
class CarBuilder extends VehicleBuilder {
private int doors;
public CarBuilder doors(int doors) { this.doors = doors; return this; }
protected CarBuilder self() { return this; }
public Car build() { return new Car(color, doors); }
}
// chaining across the hierarchy works with no casts
Car car = new CarBuilder().color("red").doors(4).build();
7. What is the difference between a mutable reusable Builder and a single-use Builder, and what bugs can occur if callers assume the wrong one?
A reusable builder leaves its internal state intact after build() returns, so calling build() again produces another product — useful for generating variants that share a base configuration. A single-use builder either clears its state or throws after the first build(), treating the builder as consumed. Neither is wrong, but the contract must be documented, because assuming reusability on a single-use builder throws an unexpected IllegalStateException, while assuming single-use on a reusable builder means a caller who mutates the builder after calling build() can accidentally affect an object they thought was already finalized and immutable.
8. Should a Builder validate object invariants incrementally on every setter call, or defer all validation to build()?
Per-setter validation catches mistakes immediately and gives precise error messages tied to one field, but it cannot check invariants that depend on the relationship between two fields set at different times — for example "end date must be after start date" can't be validated when only the start date has been set. Deferring all validation to build() handles cross-field invariants naturally and keeps the builder's intermediate state unconstrained, at the cost of pushing error discovery later in the flow.
public Builder maxAttempts(int n) {
if (n < 1) throw new IllegalArgumentException("maxAttempts must be >= 1"); // per-field, fails fast
this.maxAttempts = n;
return this;
}
public RetryPolicy build() {
if (maxAttempts > 1 && maxElapsedTime.isZero()) { // cross-field, only knowable at build()
throw new IllegalStateException("maxElapsedTime required when retries are enabled");
}
return new RetryPolicy(maxAttempts, maxElapsedTime);
}
In practice, the best answer combines both: cheap single-field checks (non-null, non-negative, format) run in the setter, while cross-field and business-rule checks run once in build().
9. Why is defensive copying critical when a Builder accepts a mutable List or Map from the caller?
If the builder simply stores the reference the caller passed in, the caller retains a live handle into what is supposed to be an immutable product's internals. After build() returns, the caller — or another thread holding the same reference — can add, remove, or clear elements, and every holder of the "immutable" object sees the mutation, silently violating the immutability contract the class advertises.
// bug: builder stores the caller's live list
public Builder items(List items) {
this.items = items; // no copy — caller can still mutate this list later
return this;
}
// fix: copy on the way in, and copy again on the way to the product
public Builder items(List items) {
this.items = new ArrayList<>(items);
return this;
}
// and in the product's constructor:
this.items = List.copyOf(builder.items);
10. How do you prevent a built immutable object from exposing its internal collections to external mutation even after the Builder finishes?
Two copies are usually necessary: once when the builder ingests the caller's collection (so later builder mutation doesn't leak backward into what the caller passed), and again when the product's constructor captures the builder's collection into an unmodifiable copy (so the returned object's getters can't be used to mutate its own internals). Returning Collections.unmodifiableList(list) alone is not sufficient, because that only wraps the same backing list — mutating the original backing list still shows through the wrapper.
public final class Config {
private final List tags;
private Config(Builder b) { this.tags = List.copyOf(b.tags); } // true immutable copy
public List tags() { return tags; } // already unmodifiable
}
11. Compare the Builder pattern to the Factory Method pattern, and give an example where you'd combine both.
Builder solves step-by-step assembly of one complex object from many parts, typically for a single product type with many optional fields. Factory Method solves polymorphic creation — deciding, often based on a discriminator, which concrete subclass to instantiate — and returns a fully-formed object from one call, not a multi-step process. They combine naturally when a builder needs to decide which concrete implementation to hand back: a PaymentBuilder could gather generic fields fluently, then internally call a factory method to choose between CreditCardPayment and WalletPayment based on which fields were populated, keeping the "which class" decision out of the fluent API surface.
12. Compare the Builder pattern to the Abstract Factory pattern, and explain why confusing the two is a common interview mistake.
Builder constructs one complex object incrementally, hiding the multi-step assembly process. Abstract Factory produces families of related objects — for example a UI toolkit's button, checkbox, and scrollbar that must all match one visual theme — without exposing their concrete classes, and it typically returns each object in a single call rather than building it up step by step. The confusion arises because both patterns hide concrete construction behind an interface and both are often demonstrated with fluent-looking code, but Builder's axis is "how do I assemble one thing," while Abstract Factory's axis is "which consistent family of things do I get."
13. When is a Builder overkill for a simple three-field DTO, and what heuristic decides between a plain constructor, a record, and a Builder?
A good heuristic: if every field is required, has an obvious order, and there are three or fewer fields, a plain constructor or a record is clearer and has less ceremony than a builder. Reach for a Builder once you have four or more optional fields, need named-parameter clarity to avoid boolean or numeric argument confusion, or need a construction-time invariant check that spans multiple fields. A three-field DTO with all-required, unambiguous fields — new Point(x, y, z) — gains nothing from a builder except extra classes to maintain.
14. How do Java records change the calculus for when you need a Builder, and how would you add builder-style construction on top of a record?
Records give you an immutable, final, all-fields-required data carrier for free, with generated equals(), hashCode(), and toString() — eliminating the main reason people reached for a Builder on simple value types. Records don't help once you have several optional fields or want named-parameter ergonomics, so you layer a conventional builder on top that ends by calling the record's canonical constructor, effectively using the record purely as the immutable storage layer.
public record HttpOptions(Duration timeout, int retries, boolean followRedirects) {
public static Builder builder() { return new Builder(); }
public static final class Builder {
private Duration timeout = Duration.ofSeconds(10);
private int retries = 0;
private boolean followRedirects = true;
public Builder timeout(Duration t) { this.timeout = t; return this; }
public Builder retries(int r) { this.retries = r; return this; }
public Builder followRedirects(boolean f) { this.followRedirects = f; return this; }
public HttpOptions build() { return new HttpOptions(timeout, retries, followRedirects); }
}
}
15. What memory and performance overhead does the Builder pattern introduce, and would JIT escape analysis typically eliminate it in a hot path?
Every builder use allocates one extra object (the builder itself) beyond the product, plus any intermediate defensive copies of collections. For a typical short-lived builder that is created, configured, and discarded within one method — never escaping to a field, another thread, or a return value — modern JIT escape analysis can often perform scalar replacement, allocating the builder's fields on the stack or in registers instead of the heap, which removes most of the overhead in practice.
Escape analysis is not guaranteed, though: it depends on method size, inlining decisions, and whether the builder reference genuinely never escapes the compiled method — passing the builder into another method that isn't inlined, or storing it, defeats the optimization. So the honest answer is "usually cheap in a hot path due to escape analysis, but don't assume it without profiling."
16. In a service processing millions of requests per second, would you avoid the Builder pattern for request or response objects?
Not categorically, but I would measure rather than assume. If escape analysis reliably eliminates the builder allocation (verified with JFR or async-profiler under real load, not guessed), the readability win is worth keeping. If profiling shows real allocation pressure and GC pause impact from millions of builder-plus-copy allocations per second — common when the builder crosses method boundaries or is stored — I would switch that specific hot path to direct field assignment, object pooling, or a plain constructor, while keeping Builder for the cold configuration paths of the same service.
17. How does StringBuilder differ conceptually from the GoF Builder pattern?
StringBuilder is a mutable accumulator: each append() call mutates internal state and returns this for chaining, and toString() produces the final immutable String — structurally similar to a fluent builder's setter-then-build() shape. It differs from the GoF pattern in that there is no separate Director, no interchangeable ConcreteBuilder implementations producing different representations, and the "product" (a character sequence) is far simpler than the multi-field domain objects Builder is usually used for. It's best described as the same mutable-accumulator idea applied to one primitive-like type rather than a full instance of the classic pattern.
18. Explain how OkHttp's Request.Builder or Java's HttpRequest.Builder is implemented, and what design choices support both required and optional configuration.
Both follow the same shape: a static factory (new Request.Builder() or HttpRequest.newBuilder()) starts with sensible defaults (GET method, no body, default headers), chained setters like url(), header(), and method() mutate internal fields and return this, and build() validates that the one truly required field — the URL — was set, throwing IllegalStateException if not. Headers use an internal mutable multimap-like structure that gets copied into an immutable form on build(), so the returned request is safe to share across threads for retries and redirects.
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/orders"))
.header("Authorization", "Bearer token")
.timeout(Duration.ofSeconds(5))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
19. Describe a scenario where you used a Builder to construct AWS SDK v2 client objects such as S3Client.builder(), and the advantages over a giant constructor.
Configuring an S3Client involves region, credentials provider, retry policy, HTTP client settings, and endpoint overrides for testing against LocalStack — a combination that varies per environment. The builder lets each environment set only what it needs: production supplies a specific region and a credentials chain, while integration tests override the endpoint and disable SSL verification, without either environment needing to pass placeholder values for settings the other cares about.
S3Client client = S3Client.builder()
.region(Region.US_EAST_1)
.credentialsProvider(DefaultCredentialsProvider.create())
.overrideConfiguration(c -> c.retryPolicy(RetryPolicy.builder().numRetries(3).build()))
.build();
Compared to a constructor, new optional configuration (a new retry strategy, a new interceptor type) can be added to the builder in a future SDK version without breaking any existing call site — a giant constructor would need a new overload or would break binary compatibility.
20. How would you design a query builder similar to JOOQ or Hibernate's Criteria API using the Builder pattern for type-safe SQL at runtime?
Each fluent method appends a fragment to an internal representation — a list of conditions, a list of joins, an ORDER BY clause — rather than a raw string, so the builder is really constructing an intermediate AST that gets rendered to SQL (or bound parameters) only inside build() or execute(). Type safety comes from parameterizing the builder on the table's generated metamodel class, so a condition like where(ORDERS.STATUS.eq("SHIPPED")) fails to compile if STATUS isn't a real, correctly-typed column, instead of failing at runtime the way string concatenation would.
List shipped = query
.select(ORDERS.ID, ORDERS.CUSTOMER_ID)
.from(ORDERS)
.where(ORDERS.STATUS.eq("SHIPPED"))
.orderBy(ORDERS.CREATED_AT.desc())
.fetch();
21. What common mistakes let a hand-rolled Builder produce partially-initialized objects that still pass validation?
The most common mistake is validating only "not null" on individual fields while never checking that the combination of fields makes business sense — for example accepting a discount percentage without checking it's between 0 and 100, or accepting an end date without checking it's after the start date. A second common mistake is forgetting to call the validation logic at all when there are multiple build()-like entry points (a convenience overload that bypasses the main build()), or validating in the product's constructor but then having a code path that constructs the product directly, bypassing the builder entirely.
22. Give an example of a bug from calling build() twice without resetting internal state, and how you'd fix it.
If the builder holds a mutable list field and passes the same reference into two separately built products instead of copying it each time, both products end up sharing the same backing list — mutating one via a later builder call (if the builder is reused) corrupts data the caller believed belonged to an already-finalized, separate object. This is especially dangerous in test-data builders that get reused across test cases with slight tweaks between them.
// bug: both products share the same list instance
public Order build() {
return new Order(customerId, items); // items is the live builder field
}
// fix: copy at build time, every time
public Order build() {
return new Order(customerId, List.copyOf(items));
}
23. Why is it an anti-pattern to let a Builder grow to twenty-plus unrelated optional setters, and how would you refactor a "God Builder"?
A builder with dozens of unrelated setters signals the product itself has too many responsibilities — connection settings, retry behavior, logging, and metrics tags all crammed into one "HttpClientConfig" is a maintainability and testability problem independent of the builder syntax. The fix is to decompose the product into cohesive sub-objects (a ConnectionSettings, a RetryPolicy, a MetricsConfig) each with its own small builder, and have the outer builder accept already-built sub-objects or nested builder callbacks rather than exposing every leaf field directly.
24. What happens if a Builder silently accepts null for a field the business logic assumes non-null, and where does the resulting NullPointerException surface?
The builder will happily construct and return a "valid-looking" product with a null field, since the type system doesn't distinguish "not yet set" from "explicitly set to null" for reference types. The NullPointerException then surfaces far downstream — often several method calls and possibly a different thread or async callback away from where the object was built — for example when a batch job calls .toUpperCase() on a null customer name hours after the order was built and persisted, making the root cause hard to trace back to the construction site.
Objects.requireNonNull(value, "fieldName") either at the setter or inside build() so the failure happens immediately, at the construction site, with a field name in the message.25. How would you unit test a Builder class in isolation, and what edge cases should the suite cover?
Test the builder directly rather than only through the product: assert that build() throws when a required field is missing, that it succeeds and produces correct field values for a fully-populated happy path, that optional fields fall back to documented defaults when omitted, and that boundary values (empty strings, zero, negative numbers, max-length inputs) are either accepted or rejected according to the documented contract. Also test that mutating a collection passed into the builder after calling a setter does not affect the built product, which directly verifies the defensive-copy behavior.
@Test
void buildThrowsWhenCustomerIdMissing() {
assertThrows(NullPointerException.class, () -> Order.builder(null).build());
}
@Test
void mutatingSourceListAfterBuilderCallDoesNotAffectProduct() {
List items = new ArrayList<>(List.of("SKU-1"));
Order order = Order.builder("cust-1").items(items).build();
items.add("SKU-2"); // mutate original after passing it in
assertEquals(1, order.items().size()); // product must be unaffected
}
26. How can Builders implement the Object Mother or test-data-builder pattern for JUnit fixtures, and what advantages does it have over fixed static test objects?
A test-data builder wraps a domain builder with realistic defaults for every field, so each test only overrides the one or two fields it actually cares about, keeping the test's intent visible instead of buried in a wall of setup code. Compared to a shared static TestFixtures.SAMPLE_ORDER constant, a builder-per-test avoids accidental coupling between tests that mutate a shared object, and it scales cleanly as the domain object grows new fields — old tests keep compiling because the builder just adds another default.
public final class OrderTestDataBuilder {
private String customerId = "cust-1";
private List items = new ArrayList<>(List.of("SKU-1"));
private String status = "PENDING";
public OrderTestDataBuilder withStatus(String status) { this.status = status; return this; }
public Order build() {
return Order.builder(customerId).items(items).status(status).build();
}
}
// test only states what matters for this case
Order shipped = new OrderTestDataBuilder().withStatus("SHIPPED").build();
27. Describe how you'd combine the Builder pattern with Bean Validation (JSR 380) annotations so build() triggers validation and throws a meaningful exception.
Annotate the immutable product's fields with @NotNull, @Size, @Positive, and similar constraints, then have build() construct the candidate object and immediately run it through a jakarta.validation.Validator before returning it, collecting all constraint violations into one exception rather than failing on the first field.
public Order build() {
Order candidate = new Order(customerId, items, couponCode);
Set> violations = VALIDATOR.validate(candidate);
if (!violations.isEmpty()) {
String message = violations.stream()
.map(v -> v.getPropertyPath() + " " + v.getMessage())
.collect(Collectors.joining("; "));
throw new IllegalStateException("Invalid Order: " + message);
}
return candidate;
}
28. Should build() throw a checked exception, an unchecked exception, or return a Result-style wrapper when validation fails?
An unchecked exception (typically IllegalStateException) is the conventional choice for Builder, because construction failures represent programmer error — a missing required field or an invalid combination — that should surface immediately in development and testing rather than forcing every caller to handle a checked exception for what is usually a bug, not an expected runtime condition. A checked exception adds ceremony to every call site and is rarely justified here. A Result-style wrapper (or Either) is worth considering only when building the object is genuinely part of expected control flow — for example validating user-submitted form data where failure is a normal, expected outcome the caller must branch on — in which case the "failure" is business logic, not a construction bug, and modeling it as a value is more honest than throwing.
29. How would you make a Builder thread-safe if multiple threads might configure the same instance concurrently, and is this a good idea?
Technically, you'd guard every setter and build() with a lock, or back fields with AtomicReference/volatile, or synchronize on the builder instance itself. In practice this is almost always the wrong design: a builder configured concurrently by multiple threads produces a race on which fields "win," an unpredictable final state, and no way for any single thread to reason about what the object will contain when built. The better fix is architectural — give each thread its own builder instance, or gate configuration behind a single coordinating thread that then calls build() once — rather than making a shared mutable builder safe to touch from everywhere.
30. Explain the difference between a copy-constructor-style toBuilder() and building fresh, and where toBuilder() is useful for modifying one field of an immutable object.
toBuilder() returns a new builder pre-populated with every field from an existing immutable instance, so a caller can override just the fields that change and call build() to get a new, independent instance — effectively a structural "with" operation without needing a dedicated wither method per field. Building fresh means starting from defaults and specifying every field you care about from scratch. toBuilder() shines for producing near-identical variants of a large configuration object — for example taking a base RetryPolicy and producing a per-endpoint override that changes only maxAttempts while keeping every other field identical to the base.
RetryPolicy base = RetryPolicy.builder().maxAttempts(3).backoff(Duration.ofMillis(200)).build();
RetryPolicy aggressive = base.toBuilder().maxAttempts(10).build(); // only one field changes
31. How do libraries like Immutables or AutoValue generate Builder classes from an abstract class or interface, compared to Lombok's @Builder?
Immutables and AutoValue take an abstract class (or interface, for Immutables) with abstract accessor methods and generate a concrete final implementation plus a builder class at compile time via annotation processing, deriving the field list from the accessor method signatures rather than from concrete fields you write. Lombok's @Builder instead annotates a concrete class with real fields and generates the builder around them. The practical difference: Immutables/AutoValue push you toward declaring the contract (interface/abstract methods) and let the tool own the implementation entirely, which plays well with generating multiple derived variants (JSON adapters, comparators), while Lombok integrates more directly with a class you otherwise write and debug normally, at the cost of the generated builder being less visible as a "pure" generated artifact.
32. What edge cases arise when a Builder field's default is itself a valid user-supplied value, and how do you distinguish unset from explicitly set?
If the default timeout is 30 seconds and a user explicitly calls .timeout(Duration.ofSeconds(30)), a builder that only tracks the field value can't tell "user wants the default" from "user never touched this field" — which matters if downstream logic needs to know whether a value was explicitly overridden (for example, to decide whether to log a deviation from policy, or whether an environment-specific default should still apply). The fix is to track presence separately from value, typically with a boolean flag or by wrapping the field in Optional internally until build() resolves it against the default.
private Duration timeout;
private boolean timeoutExplicitlySet = false;
public Builder timeout(Duration timeout) {
this.timeout = timeout;
this.timeoutExplicitlySet = true;
return this;
}
public HttpOptions build() {
Duration effective = timeoutExplicitlySet ? timeout : DEFAULT_TIMEOUT;
return new HttpOptions(effective, timeoutExplicitlySet);
}
33. How would you design a generic Builder for a generic class, such as a Pair or a Cache, while keeping the fluent API type-safe?
Parameterize the builder class with the same type variables as the product, and expose static factory methods that let type inference determine the type arguments from the first value supplied, rather than forcing callers to write out explicit type witnesses. Each setter that changes a type parameter (like setting the key type of a cache) needs to return a differently-parameterized builder type, which usually means that particular setter can't simply return this — it must return a new builder instance with the updated type parameter.
public final class Pair {
public static final class Builder {
private A first;
private B second;
public Builder first(A first) { this.first = first; return this; }
public Builder second(B second) { this.second = second; return this; }
public Pair build() { return new Pair<>(first, second); }
}
public static Builder builder() { return new Builder<>(); }
}
Pair p = Pair.builder().first("age").second(30).build();
34. How is the Builder pattern used inside Kafka Streams' Topology or StreamsBuilder to assemble a processing graph, and why does a builder fit this domain?
StreamsBuilder accumulates a directed graph of processing nodes — sources, transformations, joins, and sinks — as chained calls like .stream(), .filter(), .join(), and .to() are made, but nothing actually runs until build() produces a Topology that the Kafka Streams runtime executes. A builder fits well here because the graph's structure genuinely depends on an ordered sequence of steps (a filter after a join behaves differently than before it), and because the "product" — a runnable topology — is meaningless until every node and edge has been described, exactly the kind of complex, order-sensitive assembly Builder is designed for.
StreamsBuilder builder = new StreamsBuilder();
builder.stream("orders")
.filter((key, order) -> order.getAmount() > 0)
.mapValues(Order::toEnriched)
.to("enriched-orders");
Topology topology = builder.build();
35. How would you design an Android NotificationCompat.Builder-style API, and what does that reveal about designing builders for objects with many platform-specific optional fields?
The design centers on a small set of genuinely required fields (a small icon and a channel ID, without which the notification cannot render on modern Android), with dozens of optional fields — content title, big picture style, action buttons, priority, vibration pattern — each defaulting to platform-sensible behavior when omitted. The lesson for designing this kind of builder is to group related optional fields into sub-configuration objects (a "style" object for big-picture vs. inbox-style layouts) rather than flattening everything onto one builder, and to fail loudly only on the truly required fields while silently applying defaults everywhere else, since most callers only care about a handful of the available options.
36. What is the risk of a Builder holding a reference to a partially built product object internally rather than storing raw fields?
If a builder constructs the product object early and then mutates its fields directly across chained setter calls (rather than storing raw values and constructing the product once, at the end), it requires the product to expose package-private or reflective mutability, which undermines the immutability the pattern is supposed to deliver. Worse, if build() then returns that same mutated instance directly rather than a fresh copy, any further calls to the builder's setters — intentional or accidental, from stale code holding a reference to the builder — will retroactively mutate an object callers already believe is finished and immutable.
build() — never mutate a live product instance across multiple builder calls.37. Explain how you would implement a build-and-continue Builder that produces a second, slightly different object after the first build().
This requires the reusable-builder contract from Q7: build() must not clear or invalidate internal state, and any collections captured into the product must be defensively copied so the product's copy is independent of what the builder continues to hold. After the first build(), the caller simply calls more setters to change only the fields that differ, then calls build() again for the variant.
ExperimentConfig.Builder builder = ExperimentConfig.builder().name("checkout-v2").trafficPercent(50);
ExperimentConfig controlGroup = builder.variant("control").build();
ExperimentConfig treatmentGroup = builder.variant("treatment").build(); // only variant differs
38. How would you design a Builder that supports both a fluent chained API and a Consumer<Builder>-style lambda entry point?
Alongside the normal chained setters, expose a static factory or an overloaded constructor/method that accepts a Consumer<Builder>, internally creates a new builder, passes it to the consumer for configuration, then calls build() — this is exactly how Spring's RestClient.builder(Consumer<RestClient.Builder>)-style APIs and several AWS SDK v2 client methods work. It's especially convenient when the configuration block is nested inside another builder call, since a lambda avoids breaking the outer fluent chain with an intermediate local variable.
public static WebConfig build(Consumer configurer) {
Builder builder = new Builder();
configurer.accept(builder);
return builder.build();
}
WebConfig config = WebConfig.build(b -> b.timeout(Duration.ofSeconds(5)).retries(3));
39. What's the difference in ergonomics between a builder returning this for chaining versus a wither-style API on an already-immutable object?
A builder accumulates state across many calls before producing one final object in a single terminal build() call — ideal when you're assembling from scratch. A wither-style API (withTimeout(Duration), withRetries(int)) operates on an already-complete immutable object and returns a new immutable copy with one field changed — there's no intermediate "not yet valid" state, and every intermediate result is itself a fully valid, usable object. Prefer withers when you're deriving small variations of an existing valid object (see toBuilder() in Q30, which is really a builder acting as a batch wither); prefer a builder when constructing from nothing, especially when intermediate states genuinely aren't valid objects yet.
40. How does the Builder pattern interact with Jackson, and how would you configure it to deserialize JSON directly into a Builder-only class?
Jackson defaults to a no-arg constructor plus setters, or an all-args constructor with @JsonCreator, neither of which matches a class that only exposes a private constructor and a nested builder. The fix is @JsonDeserialize(builder = Product.Builder.class) on the product class, combined with @JsonPOJOBuilder(withPrefix = "") on the builder if your setters don't use a "with" prefix, which tells Jackson to instantiate the builder, call the matching setter for each JSON field, and finally call build() to produce the instance.
@JsonDeserialize(builder = Product.Builder.class)
public final class Product {
// fields...
@JsonPOJOBuilder(withPrefix = "")
public static final class Builder {
public Builder name(String name) { ... return this; }
public Product build() { ... }
}
}
41. Describe a production incident from a Builder that doesn't deep-copy a nested mutable configuration object.
A common real incident: a ServiceClientBuilder accepts a RetryConfig object (itself mutable, with a settable list of retryable status codes) and stores the reference directly rather than deep-copying it. Multiple services share one "default" RetryConfig instance passed into several client builders at startup. One service later mutates its copy of the retry codes list at runtime to add a custom status code — but because every client shares the same underlying object, every other service's client silently starts retrying on that new status code too, causing unexpected retry storms across unrelated services that nobody intentionally configured that way.
42. How would you design a step/staged builder with a sequence of interfaces so fields A, B, C are required (any order) before build() is callable, while D and E stay optional?
When required fields must be set in a strict order, chained interfaces work directly (Q4). When they can be set in any order but all must be present before build() is callable, a single sequence of interfaces can't express "any of these three, in any order" cleanly — the common workaround is a single interface exposing all three required setters plus the optional ones, where each required setter returns a narrower interface that drops itself but keeps the rest, until the final interface (once all three have been called) exposes build(). This gets unwieldy past two or three required fields in arbitrary order, which is itself a strong signal to fall back to a single builder with a runtime build() check instead of fighting the type system for an unordered requirement.
43. Explain building a complex object graph, such as an order with nested OrderLine builders — should the parent expose child builders or only accept fully-built children?
Accepting fully-built child objects (orderBuilder.addLine(OrderLine.builder().sku("A").qty(2).build())) keeps the parent builder simple and keeps each child's validation self-contained and independently testable. Exposing child builders directly on the parent (orderBuilder.addLine().sku("A").qty(2).endLine()) reads more fluently for deeply nested graphs but couples the parent and child builder APIs together and complicates the return-type chaining, especially with the self-referential generics from Q6. For most domain models, accepting already-built children is the more maintainable default; exposing nested child builders is worth the complexity mainly for UI-tree or AST-style builders where the nesting itself is the point (see Q84).
44. What's the difference between the Builder pattern and a mutable config POJO with public setters passed into a constructor, and why does the community favor Builder?
A mutable config POJO looks similar — setters, then pass the whole object somewhere — but nothing stops the POJO from being mutated again after it's been passed in and "used," since it's a regular class with public setters and no immutability guarantee. Builder enforces a one-way transition: mutable during configuration, then converted into a genuinely immutable, validated product via build(), after which no further mutation is possible unless the caller explicitly starts over via toBuilder(). The community favors Builder because it makes "configuration is done" an explicit, type-visible event rather than a convention callers must remember to respect.
45. How would you retrofit the Builder pattern onto a legacy class with a large public constructor, without breaking existing callers?
Keep the existing public constructor(s) untouched so current call sites keep compiling, then add a new static nested Builder class and a public static Builder builder() factory method that internally calls the same constructor once configured. Mark the old constructor(s) @Deprecated if you eventually want to steer new code toward the builder, but avoid removing them until a full migration pass — this is purely additive and carries no compatibility risk.
// legacy, unchanged
public LegacyReport(String title, String author, Date created, boolean draft) { ... }
// additive
public static Builder builder() { return new Builder(); }
public static final class Builder {
private String title, author;
private Date created = new Date();
private boolean draft = true;
// setters...
public LegacyReport build() { return new LegacyReport(title, author, created, draft); }
}
46. Discuss designing a Builder whose build() can be called exactly once, using an internal built flag or IllegalStateException on reuse.
Add a private boolean flag that starts false, gets checked and set at the top of build(), and causes any second call — or any setter call after the first build(), if you want to lock configuration entirely — to throw IllegalStateException with a message naming the actual problem, not a generic error.
public final class Config {
public static final class Builder {
private boolean built = false;
private String value;
public Builder value(String value) {
checkNotBuilt();
this.value = value;
return this;
}
public Config build() {
checkNotBuilt();
built = true;
return new Config(value);
}
private void checkNotBuilt() {
if (built) throw new IllegalStateException("Builder already used to build a Config instance");
}
}
}
47. How would you handle optional fields represented as Optional<T> inside the built object versus using Optional only as a return type on the builder's getters?
Effective Java's guidance is that Optional should generally not be used as a field type — it adds an extra allocation and doesn't implement Serializable, which matters for objects that get serialized. The better pattern is to store the raw nullable reference internally on the product, and expose it to callers wrapped in Optional only at the accessor method boundary, so the internal representation stays simple while the public API communicates optionality clearly.
public final class Order {
private final String couponCode; // nullable internally
public Optional couponCode() { return Optional.ofNullable(couponCode); } // Optional at the boundary
}
48. What issues arise combining Builder with reflection-based frameworks like Hibernate/JPA that expect a no-arg constructor plus setters?
JPA entities traditionally require a no-arg (often protected) constructor and mutable fields so Hibernate can instantiate the entity via reflection and populate it column by column, which directly conflicts with an immutable, Builder-only domain object that has no no-arg constructor and only final fields. The common reconciliation is to keep the JPA entity itself as a separate, framework-facing mutable class (with the required no-arg constructor and setters, often package-private to limit misuse), and map it to and from a genuinely immutable Builder-constructed domain model at the repository or service boundary — accepting the mapping overhead as the cost of keeping the domain model clean of persistence framework requirements.
49. Explain how gRPC/Protobuf-generated Java classes use the Builder pattern, and constraints like mergeFrom and repeated fields impose on the API.
Every protobuf message class generates a companion Builder with a setter per field, a build() that produces the immutable message, and — uniquely to protobuf — a mergeFrom(Message other) method that copies set fields from another message into the builder, which is essential for protobuf's "unset means default, not distinguishable from explicitly-default" semantics in proto3. Repeated fields (protobuf's lists) don't get a plain setter; instead the generated builder exposes addFoo(x), addAllFoo(iterable), and setFoo(index, x), because a repeated field is accumulated incrementally rather than replaced wholesale, which is a direct consequence of the wire format being a sequence of appended entries.
OrderProto order = OrderProto.newBuilder()
.setCustomerId("cust-1")
.addAllItems(items)
.mergeFrom(defaultsMessage) // fills in anything not already set
.build();
50. How would you design a builder for a Kafka ProducerRecord-style message envelope with headers, key, value, and partition, balancing required versus optional metadata?
Topic and value are the only truly required fields for most producers; key, explicit partition, timestamp, and headers are all optional with well-defined "let Kafka decide" fallbacks (null key means round-robin/sticky partitioning, no explicit partition means the partitioner chooses, no timestamp means "now"). The builder should require topic and value through its factory method or constructor, expose chained optional setters for the rest, and validate at build() that an explicit partition (if set) is non-negative and that headers don't contain null keys, which the underlying client would reject anyway but with a much less informative error.
Post a Comment
Add