Early Access: 87 spots left.

Claim

Course content

No content available

The Builder pattern separates the construction of an object from its final representation. Instead of pushing a long argument list through a constructor, you talk to a temporary helper object, the builder, and tell it one field at a time what you want. When you have set everything that matters, you call build() and the builder hands you back a fully formed, immutable instance of the target class.

The usual call site looks like this: EmailMessage.builder().to("a@x.com").from("b@x.com").subject("Hi").body("Hello").build(). Every method on the builder returns the builder itself, so you chain them into one fluent expression. Required fields are validated inside build(), optional fields fall back to sensible defaults, and once the object exists nobody can mutate it. The page starts from the constructor this pattern exists to replace.

The constructor is technically correct: it validates required fields, defaults the optional ones, and produces an immutable object. The problem is everything around it.

First, the call site is unreadable. true, true at the end of the second example is two booleans in a row; the only way to know which is which is to look up the constructor signature. If a future engineer adds a third boolean (high-priority, draft, and so on), every existing call breaks silently, because the compiler is happy with false, false, false regardless of which boolean got renamed.

Second, optional fields are passed as null. The team has to know that null for cc means "no CCs" while null for to would be a bug. There is no syntactic difference at the call site, so the reader has to read the constructor body to learn the rules. Defaults belong inside the construction logic, not in every caller's head.

Third, this constructor will keep growing. The next product requirement is "add a delivery-receipt option" or "support inline images" or "set a custom Reply-To". Each one adds a parameter, breaks every existing caller, and forces another overload that just delegates with a default. Within a year you have four constructors, two factory methods, and a null-padded call site that nobody trusts. The smell is the constructor signature itself.

What this costs in production

A draft boolean was added to the EmailMessage constructor as the tenth parameter. Every existing call already ended in false, false, so the compiler happily accepted false, false, false everywhere, even where the new flag was meant to be true. The result: scheduled campaign emails went out immediately instead of staying as drafts, because nobody could see which boolean was which at the call site.

A 9-parameter constructor is one instance of a construction API that has outgrown positional arguments. In review, the symptoms look like these.

SmellWhat it looks likeWhy a builder is the answer
Constructor with five or more parameters, especially mixing types and optionalitynew Email(to, from, subject, body, cc, bcc, attachments, important, receipt)Positional arguments lose meaning; nulls and false get smuggled through anonymous slots
Telescoping constructors stacked on top of each otherEmail(to, from, subject) calls Email(to, from, subject, "", null, null, ...)Each new optional field requires another overload; the explosion is combinatorial
Calls passing literal null for optional collectionsnew Order(item, customer, null, null, null)The reader cannot tell which nulls are 'no value' and which are bugs; defaults belong with the constructor logic, not the call site
Mutable POJO with setters that the team treats as immutable by conventionOrder order = new Order(); order.setX(...); order.setY(...);Convention is not enforced; one careless setter call after construction produces a corrupt object
Multiple constructors that exist solely to handle different combinations of optional fieldspublic Pizza(Size s, boolean cheese) { ... } public Pizza(Size s, boolean cheese, boolean pepperoni) { ... }The combinatorics scale with 2^N for N optional booleans; one builder replaces all of them
Required-vs-optional split is invisible from the constructor signaturenew Email("", "", "", "", null, null, null) compiles and instantly throws at runtimeBuilder.build() validates required fields in one place; missing-required becomes a clear, named exception

Three things changed and they are all related.

The first is that every value at the call site is now named. important(true) cannot be confused with readReceiptRequested(true) because the field name is right there. Adding a tenth field is a brand-new builder method; existing callers compile and run unchanged. The combinatorial explosion is gone: you no longer pay for optional fields you do not set, and you no longer break every caller when a new field arrives.

The second is that defaults live in one place. The Builder initialises body to the empty string and the three lists to empty. Callers who want defaults simply do not call those methods. There is no null smuggling, and the resulting EmailMessage is guaranteed not to hold a null collection; getCc() always returns an empty list, never null. This eliminates an entire class of NullPointerException bugs at the boundary.

The third is that immutability is enforced at construction. The EmailMessage constructor is private; the only way to make one is builder().build(), and build() ends by taking a defensive copy of every collection (List.copyOf in Java, tuple(...) in Python, the copy in the C++ constructor). After build() returns, the builder's state is irrelevant: the user can keep mutating the builder and create more emails, and none of those mutations leak into emails that were already built. Every EmailMessage is final, fully populated, and validated; the construction process is fluent, named, and incremental; and the two are decoupled.

chained settersbuild()CallerBuilderEmailMessage
Each setter returns the Builder so calls chain fluently, and build() validates the required fields before producing the immutable EmailMessage. A new optional field adds one method, not another constructor parameter.

With the refactor in hand, it is worth pinning down what Builder is not, because three other approaches to multi-field construction look superficially similar.

Builder vs. telescoping constructors: a telescoping constructor is the "add one parameter and call the bigger one" pattern: Pizza(size), Pizza(size, cheese), Pizza(size, cheese, pepperoni), and so on. It scales miserably. Two booleans in the middle of a 9-parameter constructor become indistinguishable at the call site (new Email("a", "b", "c", "d", true, false, false, true, false): quick, which one is read-receipt?). Builder names every value you pass in.

Builder vs. JavaBean setters: the setter approach (new Pizza(); p.setSize(...); p.setCheese(...)) loses immutability, since anyone with a reference can mutate the object after construction. It also leaves the object in an invalid intermediate state between calls, so any code that touches it before all the setters have run sees something half-formed. Builder keeps the half-formed state inside the builder object; the final object is built atomically and is final-fielded.

Builder vs. Factory Method: a factory creates one of several types (ShapeFactory.create("circle") vs. "square"); the choice is which subclass to return. Builder creates one specific type but with many configuration choices; the choice is which fields to populate. Factories pick a class, builders configure an instance. They compose fine: a builder's build() can itself delegate to a factory if the type is conditional.

StringBuilder is the canonical Builder in the JDK: new StringBuilder().append("a").append("b").toString() is the same shape as the email example, just for strings. Modern Java added the full-dress version wherever construction got complicated. HttpRequest.newBuilder().uri(...).header("Authorization", token).GET().build() is how Java 11 code constructs an HTTP request without a third-party library, HttpClient.newBuilder() configures the client the same way, and Stream.builder(), Locale.Builder, and DateTimeFormatterBuilder follow the pattern.

In production Java you usually meet the pattern generated rather than hand-written. Lombok's @Builder annotation produces the whole thing at compile time: the static builder() method, the nested Builder class, and a build() that calls a private constructor. @Builder.Default carries defaults and @Singular accumulates collections one element at a time. Spring is builder-heavy at every seam (WebClient.builder(), RestTemplateBuilder, MockMvcRequestBuilders.get(...), the HttpSecurity configuration DSL), and AWS SDK v2 standardised on Client.builder().region(...).build() for every service client.

The same shape exists in the other ecosystems where it is needed. Protocol Buffers generates immutable message classes with a companion builder in both Java and C++, which is Builder as a code-generation target. C++ codebases hand-roll fluent builders for objects with real invariants to enforce, and LLVM's IRBuilder is a famous industrial example of fluent, stateful construction. Python's fluent chaining wears a different costume: SQLAlchemy's select(...).where(...).order_by(...) and pandas method chains accumulate a description step by step and materialise it at the end, which is the builder idea applied to queries and pipelines.

The pattern is also a lens on language design, because Builder exists to patch a specific gap: Java has neither named nor default arguments. Python has both, so the idiomatic Python fix for a 9-parameter constructor is not a builder at all; it is keyword-only parameters with defaults, or a frozen dataclass, which give you named call sites, defaults, and immutability for free. Kotlin's named-and-default parameters do the same, which is why idiomatic Kotlin rarely writes builders. C++20's designated initializers (EmailMessage{.to = "a@x.com", .important = true}) cover the readability half for simple aggregates, while fluent builders remain the C++ tool when build() must enforce invariants. Knowing which gap the pattern fills tells you when a language has already filled it, and saying that out loud is a strong interview move.

ProsCons
Self-documenting call sites: every value is named, no positional ambiguityMore boilerplate up front (a private constructor, a Builder class, one method per field)
Required-vs-optional is enforced inside build() with a clear named exceptionRequired validation runs at runtime, not compile time: a missing required field fails on build(), not in the IDE
Adding a new field is purely additive: one new builder method, existing callers untouchedTwo places to maintain: the target class field and the Builder field. Lombok's @Builder erases this cost; hand-written code does not
Final, immutable target object after build() returns: no half-constructed state ever escapesBuilder objects themselves are mutable and not thread-safe: sharing a builder across threads is a bug
Defaults live in the Builder class, not in every call site: no more null-padded constructor callsObject allocation cost is slightly higher (one Builder per object), although usually negligible compared to the work being configured
Plays well with Lombok's @Builder, @Singular for collections, and step-builders for compile-time required-field enforcementStep-builder variants (separate interfaces for each required field) get verbose quickly; useful when required-at-compile-time is critical, otherwise overkill

Builder pays off when you have many fields, optional fields, or a desire for immutability. If you have a class with two required fields and nothing else, a regular constructor is cleaner; new Point(x, y) is unambiguous and Point.builder().x(x).y(y).build() is just noise. The rule of thumb in Effective Java Item 2 is roughly: more than four parameters, or more than two optional ones, and Builder starts to earn its keep; below that, a regular constructor wins.

Builder is also the wrong tool when the construction process needs to make decisions that depend on the values being set. If email.body(...) should sometimes route through a Markdown renderer and sometimes not, that conditional logic does not belong in the Builder; the Builder should stay a passive accumulator of values. The decision belongs either in build() (if it can be made from the accumulated state) or in the caller. If you find Builder methods doing real work, you have probably re-invented a half-baked Director (the rarely-used Builder co-pattern), and you would be better off pulling that logic into a factory or a service.

Finally, Builder is overkill when the target object is genuinely mutable (a session, a stateful socket, a connection pool). If the whole point of the object is that it changes after construction, the immutability guarantee that Builder gives you is wasted, and the mutable POJO with setters is a cleaner match. Builder is for things that should be born final.

Three flavours of question recur. The first is design: "design a Pizza class" or "design a configuration object for an HTTP client". The interviewer is checking whether you reach for Builder when there are five-plus fields with mixed required/optional, and whether you know to make the target class immutable with a private constructor. The high-signal moves are: a static builder() method on the target class, a Builder static nested class, fluent setters that return this, validation of required fields in build(), and List.copyOf (or equivalent) on collections so the caller cannot mutate the built object through a retained reference to the original list.

The second is comparison: "Builder vs. Factory", "Builder vs. constructors with defaults", "why not just use setters?". The factory-vs-builder distinction is type-vs-configuration: factories pick which class to instantiate, builders configure one specific class with many options. The constructor-with-defaults question is about Java specifically: Java has no named or default parameters, so a Builder is how you simulate them, while in Kotlin or Python you would just use named parameters with defaults and skip the Builder entirely. The setters question is about immutability and atomic construction: Builder gives you a final object after build(), setters leave the door open to half-built or post-mutated state.

The third is implementation depth: "how do you enforce required fields at compile time?". The honest answer is that a plain Builder cannot; build() throws at runtime if required fields are missing. To get compile-time enforcement, you use a step-builder: separate interfaces for each required field, where each with... method returns the next interface in the chain, so the compiler refuses to call build() until you have walked the whole chain. The trade-off is verbosity (one interface per required field) and the answer signals that you know the limits of the basic pattern. Lombok-aware candidates can also note that @Builder generates everything you would write by hand, and that @Singular is the idiomatic way to accumulate collections one element at a time.

The companion exercise gives you an EmailMessage class with a 9-parameter constructor and asks you to refactor it into the Builder pattern. The validator checks that the target class has no public constructor, that EmailMessage.builder() returns a usable Builder, that fluent setters return the Builder, that build() validates required fields, that optional fields fall back to documented defaults, and that the built EmailMessage is immutable, including that mutating the original collections passed in does not corrupt the built object.

Back to Low Level Design