Builder Design Pattern In Java
Why object creation deserves a pattern
The Builder pattern exists for one reason: in a production system, an object should never exist in a half-built or invalid state. Creating an object looks trivial — call new, pass some values, done. But once a class has five, ten or fifteen fields, some required, some optional, each with its own rules, the plain new keyword starts to leak bugs into your system.
Think about the objects your backend creates every second: a User being registered, an HttpRequest being sent, a DatabaseConnection being opened, an Order being placed. If any of these is created with a missing or wrong value, the failure rarely happens at creation time. It happens later — in a query, a network call, or a payment — far away from the line that caused it.
In this article we will first look at the three common ways developers create such objects without a Builder, and why each one breaks in production. Then we will build a User class with the Builder pattern step by step, fix the gaps most tutorials leave open, and finish with advanced variants, real-world usage and interview questions.
Goal of the Builder pattern: separate the construction of a complex object from its representation, so the object is assembled step by step in a temporary place and only released to the rest of the system once it is complete and valid.
Problem 1: The telescoping constructor
The first instinct is to pass everything through the constructor. When some fields are optional, you end up writing one constructor per combination:
public User(String firstName, String email) { ... }
public User(String firstName, String lastName, String email) { ... }
public User(String firstName, String lastName, int age, String email) { ... }
public User(String firstName, String lastName, int age, String email, String password) { ... }This is called the telescoping constructor anti-pattern, because each constructor is a slightly longer version of the previous one. Now look at the call site:
User user = new User("Verma", "Sarthak", 24, "sarthak@example.com", "Secure123");Can you spot the bug? First name and last name are swapped. Both are String, so the compiler is perfectly happy. With five positional arguments of the same type, the code is hard to read and easy to get wrong, and the bug only surfaces when a customer sees "Dear Verma Sarthak" in an email.
The number of constructors also explodes. Every new optional field doubles the number of possible combinations, and you cannot even have two constructors with the same parameter types — User(String firstName, String email) and User(String lastName, String email) cannot both exist.
Problem 2: Setters create half-built objects
The second approach is the JavaBeans style: an empty constructor plus a setter for each field. It reads nicely, and it feels safe because you can put validation inside each setter:
class User {
private String firstName;
private String email;public User() { } public void setFirstName(String firstName) {
if (firstName == null || firstName.trim().length() < 2) {
throw new IllegalArgumentException("Invalid first name");
}
this.firstName = firstName.trim();
} public void setEmail(String email) {
// validation...
this.email = email;
}
}User user = new User(); // object already exists here
user.setFirstName("Sarthak"); // firstName is set
// email is still null!
userRepository.save(user); // a broken user goes to the database
The validation in the setter only protects the value when the setter is called. It cannot protect the object from a setter that is never called. The moment new User() runs, a real User object exists in memory with every field set to null or 0. Between that line and the last setter call, the object is in an inconsistent state — and nothing stops another part of the code from using it during that window.
The database connection analogy
Imagine the same design for a database connection:
DatabaseConnection conn = new DatabaseConnection();
conn.setUsername("admin");
conn.setPassword("secret");
// someone forgot setUrl(...)
conn.connect(); // NullPointerException, or worse, connects to a default hostThe object was created successfully, so every code review and every compiler check passed. The failure only appears at runtime, often in production, with a stack trace pointing at connect() instead of the place where the URL was forgotten. The same thing happens with User: a user with a null email gets saved, and you only find out when the password-reset mail fails a week later.
Setters cause two more production problems:
- Mutability. Anyone holding a reference can call
setEmail()at any time, so the object you validated yesterday may not be the object you have today. - Thread safety. If one thread is still calling setters while another thread reads the object, the reader can see a partially initialized
User. Making a mutable object thread-safe needs locks; an immutable object is thread-safe for free.
The root problem: with setters, construction and use are not separated. The object is visible to the system before it is complete.
Problem 3: Validating everything in one giant constructor
The natural fix for half-built objects is to make the object immutable and validate everything in the constructor. Now the object either exists completely or does not exist at all:
public User(String firstName, String lastName, int age,
String email, String password) {
if (firstName == null || firstName.trim().length() < 2)
throw new IllegalArgumentException("Invalid first name");
if (lastName == null || lastName.trim().length() < 2)
throw new IllegalArgumentException("Invalid last name");
if (age < 18 || age > 120)
throw new IllegalArgumentException("Invalid age");
if (email == null || !email.matches(EMAIL_REGEX))
throw new IllegalArgumentException("Invalid email");
if (password == null || password.length() < 8)
throw new IllegalArgumentException("Weak password");
// ...uppercase, lowercase, digit checks... this.firstName = firstName.trim();
// ...assign the rest...
}This is correct, but it does not scale:
- The constructor becomes overwhelming. It is now 40+ lines of validation followed by assignments. Every new field makes it longer, and every rule change touches the same method.
- The telescoping problem is still there. The caller still passes five positional arguments, so the swapped first name and last name bug from Problem 1 is still possible.
- Optional fields are still painful. Callers pass
nullfor fields they do not have, and the constructor must decide whethernullmeans "not provided" or "invalid". - It mixes two jobs. A constructor’s job is to set up a valid object. Collecting input step by step, applying defaults and checking each value is a different job.
So we are stuck between two bad options. Setters give readable, step-by-step construction but allow invalid objects. A big constructor guarantees valid objects but is unreadable and rigid. We want both: step-by-step construction and a guarantee that the final object is valid.
The idea: build in a temporary object, release only when complete
The Builder pattern solves this by introducing a temporary, mutable staging object — the Builder — whose only job is to collect values. The real object is created once, at the very end, in one atomic step.
A good analogy is ordering a custom burger. You tell the counter “add cheese”, “no onions”, “extra patty” one at a time. The kitchen notes each choice on a ticket. Nobody hands you a burger after the first instruction. Only when you say “that’s it” does the kitchen make the burger, and it comes out complete. The ticket is the Builder; the burger is the final object.
The flow looks like this:
- The client creates a
Builder— a cheap, temporary object. NoUserexists yet. - The client calls one method per field:
.firstName(...),.email(...)and so on. Each method validates its own value and stores it in the Builder. A bad value fails immediately, at the exact line that caused it. - The client calls
.build(). This is the single gate: it can check rules that involve several fields together, and then calls the private constructor ofUser. - A fully formed, validated, immutable
Useris returned. The Builder can now be thrown away.
This gives us everything we wanted:
- Telescoping constructor: no half-built object, but the call site is unreadable, arguments are easy to swap, and optional fields are painful.
- Setters: readable and flexible, but the object is visible half-built, it stays mutable, and it is not thread-safe.
- Big validating constructor: valid and immutable, but the call site is still positional, optional fields are still painful, and all validation sits in one overwhelming method.
- Builder: readable named steps, easy optional fields, validation organized per field, no half-built object ever visible, and an immutable, thread-safe result.
The key insight: the half-built state still exists, but it lives inside the Builder, not inside User. The rest of the system can only ever see a User, and every User is complete.
Building it: the User example, piece by piece
Here is a User class built with the Builder pattern. Let us look at each design decision separately.
1. The private constructor
class User {
private String firstName;
private String lastName;
private int age;
private String email;
private String password; private User(Builder builder) {
this.firstName = builder.firstName;
this.lastName = builder.lastName;
this.age = builder.age;
this.email = builder.email;
this.password = builder.password;
}The constructor is private and takes the Builder itself. That means the only way to get a User is through Builder.build(). Nobody can call new User() and skip validation. Notice there are no setters on User — once created, it cannot change.
2. The static nested Builder
static class Builder {
private String firstName;
private String lastName;
private int age;
private String email;
private String password;The Builder mirrors the fields of User. It is a static nested class, so you can create it without an existing User instance: new User.Builder(). Because it is nested, User's private constructor can read the Builder's private fields directly.
3. Fluent setter methods with fail-fast validation
public Builder firstName(String firstName) {
if (firstName == null || firstName.trim().length() < 2) {
throw new IllegalArgumentException(
"First name must contain at least 2 characters");
}
this.firstName = firstName.trim();
return this;
}Two things happen here. First, the value is validated the moment it is supplied, so an invalid first name throws an exception pointing at the exact line that passed it. Second, the method returns this, the Builder itself. That is what makes method chaining — the fluent interface — possible.
The other methods follow the same shape, each owning its own rules: age() checks the 18–120 range, email() checks a regex, and password() checks length, uppercase, lowercase and a digit. Instead of one 40-line constructor, validation is split into small, focused methods that are easy to read and unit-test.
4. The build() method
public User build() {
return new User(this);
}build() is the only door from the Builder to the real object. It hands itself to the private constructor and returns a finished User.
5. The client code
User user = new User.Builder()
.firstName("Sarthak")
.lastName("Verma")
.age(24)
.email("sarthak@example.com")
.password("Secure123")
.build();Compare this with new User("Verma", "Sarthak", 24, ...). Every value is labelled, so swapping first and last name is now obvious. Fields can be supplied in any order. And until .build() returns, no User exists anywhere in the system.
Making it production-ready: the gaps most tutorials skip
The version above is a solid start, but it still has one serious hole. Try this:
User user = new User.Builder().build();It compiles and runs. You now have a User with firstName = null, email = null and age = 0 — exactly the half-built object we were trying to prevent. Per-field validation only runs for fields that are set; it says nothing about fields that were never set. Here are the fixes.
Fix 1: Enforce required fields in build()
build() is the one place guaranteed to run before the object exists, so it is where completeness is checked:
public User build() {
List<String> missing = new ArrayList<>();
if (firstName == null) missing.add("firstName");
if (email == null) missing.add("email");
if (password == null) missing.add("password");
if (!missing.isEmpty()) {
throw new IllegalStateException("Missing required fields: " + missing);
}
return new User(this);
}Note the exception type: IllegalArgumentException means "this value is wrong", while IllegalStateException means "the builder is not ready yet". Using the right one makes logs much easier to read.
Get Sarthak Verma’s stories in your inbox
Join Medium for free to get updates from this writer.
An alternative is to put required fields in the Builder’s constructor — new User.Builder("Sarthak", "sarthak@example.com") — and keep only optional fields as chained methods. This is checked at compile time, but brings back positional arguments, so it works best when there are only one or two required fields.
Fix 2: Cross-field rules belong in build()
Some rules involve more than one field, and a field method cannot check them because the other field may not be set yet. For example, “the password must not contain the user’s first name”:
if (password.toLowerCase().contains(firstName.toLowerCase())) {
throw new IllegalArgumentException("Password must not contain your name");
}A simple rule of thumb: single-field rules go in the field method (fail fast); multi-field rules and required-field checks go in build().
Fix 3: Make the result truly immutable
Mark every field in User as final. This lets the compiler guarantee nothing changes them after construction, and Java's memory model guarantees that other threads see the fully initialized values once the constructor finishes.
Fix 4: Never keep the raw password
A domain object should not carry a plain-text password around in memory, logs or toString(). Hash it while building, so the User only ever holds the hash:
this.passwordHash = passwordEncoder.encode(builder.password);(In a Spring app this would be BCryptPasswordEncoder; the Builder validates the rules, the encoder handles storage.)
The production-ready version
import java.util.ArrayList;
import java.util.List;public final class User {
private final String firstName;
private final String lastName; // optional
private final int age; // optional, 0 = not provided
private final String email;
private final String passwordHash; private User(Builder b) {
this.firstName = b.firstName;
this.lastName = b.lastName;
this.age = b.age;
this.email = b.email;
this.passwordHash = PasswordHasher.hash(b.password);
} public static Builder builder() { return new Builder(); } public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public String getEmail() { return email; } @Override
public String toString() {
return "User{firstName='" + firstName + "', email='" + email + "'}";
} public static final class Builder {
private String firstName;
private String lastName;
private int age;
private String email;
private String password; private Builder() { } public Builder firstName(String v) {
if (v == null || v.trim().length() < 2)
throw new IllegalArgumentException("First name must contain at least 2 characters");
this.firstName = v.trim();
return this;
} public Builder lastName(String v) {
if (v == null || v.trim().length() < 2)
throw new IllegalArgumentException("Last name must contain at least 2 characters");
this.lastName = v.trim();
return this;
} public Builder age(int v) {
if (v < 18 || v > 120)
throw new IllegalArgumentException("Age must be between 18 and 120");
this.age = v;
return this;
} public Builder email(String v) {
if (v == null || !v.matches("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"))
throw new IllegalArgumentException("Invalid email address");
this.email = v.trim().toLowerCase();
return this;
} public Builder password(String v) {
if (v == null || v.length() < 8)
throw new IllegalArgumentException("Password must contain at least 8 characters");
if (!v.matches(".*[A-Z].*") || !v.matches(".*[a-z].*") || !v.matches(".*\\d.*"))
throw new IllegalArgumentException(
"Password needs an uppercase letter, a lowercase letter and a digit");
this.password = v;
return this;
} public User build() {
List<String> missing = new ArrayList<>();
if (firstName == null) missing.add("firstName");
if (email == null) missing.add("email");
if (password == null) missing.add("password");
if (!missing.isEmpty())
throw new IllegalStateException("Missing required fields: " + missing); if (password.toLowerCase().contains(firstName.toLowerCase()))
throw new IllegalArgumentException("Password must not contain your first name"); return new User(this);
}
}
}
Usage stays just as readable, now through a static factory method:
User user = User.builder()
.firstName("Sarthak")
.lastName("Verma")
.age(24)
.email("sarthak@example.com")
.password("Secure123")
.build();Advanced concepts
Defensive copies for mutable fields
Immutability breaks silently if a field holds a mutable object. Suppose User has a List<String> roles:
List<String> roles = new ArrayList<>(List.of("USER"));
User user = User.builder()./* ... */roles(roles).build();
roles.add("ADMIN"); // the caller just changed the "immutable" userCopy collections on the way in and expose them read-only on the way out:
this.roles = List.copyOf(builder.roles); // in User's constructorThe same applies to Date (prefer Instant or LocalDate, which are immutable) and arrays.
toBuilder(): changing an immutable object
Immutable objects cannot be edited, but you often need “the same user with a new email”. Add a method that pre-fills a Builder from an existing object:
public Builder toBuilder() {
Builder b = new Builder();
b.firstName = this.firstName;
b.lastName = this.lastName;
b.age = this.age;
b.email = this.email;
return b;
}User updated = user.toBuilder().email("new@example.com").build();The original user is untouched, and updated goes through the same validation as any new object. (For User specifically, the password hash would need its own handling, which is a good reminder that toBuilder() should only be added where it makes sense.)
Step Builder: required fields enforced by the compiler
The build() check catches missing fields at runtime. A Step Builder catches them at compile time by returning a different interface after each required step, so build() is only reachable after every required field is set:
public interface FirstNameStep { EmailStep firstName(String v); }
public interface EmailStep { PasswordStep email(String v); }
public interface PasswordStep { OptionalStep password(String v); }
public interface OptionalStep {
OptionalStep lastName(String v);
OptionalStep age(int v);
User build();
}// The Builder implements all four interfaces;
// User.builder() returns it typed as FirstNameStep.User user = User.builder() // FirstNameStep
.firstName("Sarthak") // EmailStep
.email("sarthak@example.com") // PasswordStep
.password("Secure123") // OptionalStep
.age(24)
.build();
Calling .build() right after .firstName() is now a compile error. The trade-off is more code and a fixed order for required fields, so it is worth it for public APIs and SDKs, and usually overkill for internal classes.
Builders and inheritance
If AdminUser extends User, a plain builder breaks chaining: AdminUser.builder().firstName("A") returns a User.Builder, which has no adminLevel() method. The standard fix is a self-typed generic builder (abstract static class Builder<T extends Builder<T>> with an abstract self() method), described in Effective Java, Item 2. It works, but it is a sign to ask whether composition would be simpler than inheritance.
Lombok: generating the builder
In real Java projects you rarely hand-write builders. Lombok generates the whole thing:
@Builder(toBuilder = true)
@Getter
public final class User {
private final String firstName;
private final String lastName;
private final int age;
private final String email;
@Singular private final List<String> roles;
}The catch: Lombok’s generated setters do no validation, and its build() checks nothing. Put your rules in the constructor (annotate a private constructor with @Builder instead of the class) or in a validation layer such as Jakarta Bean Validation (@NotNull, @Email, @Size).
Java records
Since Java 16, records give you a concise immutable class with a compact constructor for validation:
public record User(String firstName, String lastName, int age, String email) {
public User {
if (firstName == null || firstName.isBlank())
throw new IllegalArgumentException("First name required");
}
}Records solve immutability and boilerplate, but not the positional-argument problem. For three or four fields a record is enough; past that, a builder (hand-written or via Lombok’s @Builder on the record) still pays off.
Builder vs Factory vs Director
- Factory answers “which object should I create?” (
PaymentFactory.create("UPI")returns aUpiPayment). It is a single call. - Builder answers “how do I assemble this one complex object?” It is many calls, then
build(). - Director is an optional piece of the original Gang of Four pattern: a class that holds a common recipe, such as
UserDirector.createGuestUser(builder), so the same build steps are not repeated across the codebase.
Where you already use Builders
You have almost certainly used this pattern without noticing. A few well-known examples:
- JDK
StringBuilder:new StringBuilder().append("a").append("b").toString()assembles aStringstep by step. - JDK 11+
HttpRequest:HttpRequest.newBuilder().uri(uri).header("Accept", "application/json").GET().build()creates an immutable HTTP request. - JDK 11+
HttpClient:HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()creates a configured HTTP client. - JDK
Stream.builder():Stream.builder().add(1).add(2).build()creates aStream. - OkHttp (Android):
new Request.Builder()./service/https://medium.com/url(url).post(body).build()creates a network request. - Spring
ResponseEntity:ResponseEntity.ok().header("X-Id", id).body(dto)creates an HTTP response. - Spring WebFlux
WebClient:WebClient.builder().baseUrl(url).build()creates a configured web client. - Protocol Buffers:
UserProto.newBuilder().setName("A").build()creates an immutable generated message.
Notice that most of these are exactly the cases from the beginning of this article: network clients, requests and connections — objects with many optional settings that must be complete and valid before they are used. HttpRequest is a perfect real-world answer to the database connection problem: you cannot send a request without a URI, because build() refuses to create one.
When not to use a Builder
A Builder adds a second class and doubles the field declarations, so it should earn its place. Skip it when:
- The class has two or three fields, all required. A constructor or a record is clearer.
- The object is a simple, short-lived data carrier inside one method.
- The object genuinely needs to change after creation (for example, a JPA entity managed by Hibernate, which needs a no-args constructor and setters).
A useful rule from Effective Java: consider a Builder when a constructor would have four or more parameters, especially if several are optional or share the same type.
Common interview questions
Why is the Builder a static nested class? So it can be created without an existing outer instance (new User.Builder()), and so the outer class's private constructor can read its fields.
Why is the outer constructor private? To force all creation through build(), so no one can bypass validation.
Where should validation go? Single-field rules in the field methods to fail fast; required-field checks and cross-field rules in build().
Is the Builder itself thread-safe? No, and it does not need to be. A builder is used by one thread, then discarded. The object it produces is immutable and therefore thread-safe.
How is Builder different from Factory? A Factory decides which type to create in one call; a Builder controls how one complex object is assembled over several calls.
How do you enforce required fields at compile time? With a Step Builder, or by taking required fields in the Builder’s constructor.
Summary
- Plain constructors become telescoping and error-prone once a class has many fields.
- Setters let an object exist half-built, like a database connection with a
nullURL, and validation in setters cannot catch a setter that was never called. - A giant validating constructor is safe but overwhelming, rigid and still positional.
- The Builder moves the half-built state into a temporary staging object and releases the real object only through
build(), complete, validated and immutable. - For production use: check required and cross-field rules in
build(), mark fieldsfinal, copy mutable fields, never hold raw passwords, and reach for Lombok or a Step Builder when they fit.
The next time you write new with five arguments, or a chain of setters before a save(), ask yourself: can this object exist for even one line in an invalid state? If the answer is yes, it is time for a Builder.






