The Builder Pattern separates object construction from representation using a fluent, step-by-step API
Required fields go in the Builder's constructor; optional fields are chained methods returning this
build() is the single validation gate — all cross-field rules checked here
Performance: Builder adds ~3-5% overhead vs telescoping constructor, but eliminates entire classes of runtime bugs
Production insight: a half-built object escaping due to mutable setters is a real concurrency bug — Builder prevents it
Biggest mistake: forgetting return this in setter methods — breaks the entire fluent chain
Plain-English First
Imagine you're ordering a custom pizza. You don't hand the chef one giant note with every possible topping pre-decided — you tell them one thing at a time: large crust, tomato sauce, extra cheese, mushrooms, done. The Builder Pattern is exactly that: instead of cramming every option into one monstrous constructor call, you set each piece of your object step-by-step, in any order you like, and then say 'build it' when you're ready. It keeps your code readable and your objects clean.
Every Java developer eventually hits the wall: you're building an object that has ten fields, some optional, some required, and suddenly your constructor looks like a phone number with no spaces. You call new User("Alice", null, null, 25, true, false, null, "admin") and nobody — not even you — knows what the seventh argument means without counting on their fingers. This is the moment the Builder Pattern was born to solve.
The Builder Pattern is a creational design pattern that separates the construction of a complex object from its final representation. Instead of one bloated constructor (or five overloaded ones), you get a fluent, self-documenting way to assemble an object piece by piece. It also prevents partially-built objects from ever escaping into your system, which is a subtle but serious safety guarantee.
By the end of this article you'll understand not just how to write a Builder, but why it's structured the way it is, when to reach for it instead of alternatives like Lombok or static factories, and exactly what interviewers are probing when they bring it up. You'll have a full working implementation you can drop into a real project today.
The Problem Builder Pattern Solves — Telescoping Constructors
Before looking at the solution, you need to feel the pain it fixes. The classic anti-pattern is called the Telescoping Constructor — a chain of overloaded constructors, each one calling the next with a default value plugged in.
It starts innocently. A User needs a name and email. Then product adds a phone number field. Then optional timezone. Then role. Before long you have six constructors, each delegating to the next, and callers have no idea which one to use or what null in position four actually means.
The alternative many developers try first — a JavaBean with setters — solves readability but creates a new problem: the object is mutable during construction. Another thread could observe a half-built User object between calls to setName() and setEmail(). That's a real concurrency bug.
The Builder Pattern eliminates both problems. You get named, readable field assignment AND a single atomic moment — the build() call — where the final immutable object snaps into existence.
TelescopingConstructorProblem.javaJAVA
1
2
3
4
// The PROBLEM: telescoping constructors — hard to read, easy to mess up
public class TelescopingConstructorProblem {\n\n public static void main(String[] args) {\n\n // Which argument is which? You have to go look at the constructor EVERY time.\n // Is true the 'isAdmin' flag or the 'isVerified' flag? Nobody knows without checking.\n User alice = new User(\"Alice\", \"alice@example.com\", null, 25, true, false);\n\n // This compiles fine but is completely unreadable.\n System.out.println(alice);\n }\n}\n\nclass User {\n private final String name;\n private final String email;\n private final String phone; // optional — forced to pass null\n private final int age;\n private final boolean isAdmin;\n private final boolean isVerified;\n\n // The full constructor — callers must remember argument order perfectly\n public User(String name, String email, String phone,\n int age, boolean isAdmin, boolean isVerified) {\n this.name = name;\n this.email = email;\n this.phone = phone;\n this.age = age;\n this.isAdmin = isAdmin;\n this.isVerified = isVerified;\n }\n\n // Overloaded convenience constructor — now there are TWO to keep in sync\n public User(String name, String email, int age) {\n this(name, email, null, age, false, false); // delegates upward\n }\n\n @Override\n public String toString() {\n return \"User{name='\" + name + \"', email='\" + email +\n \"', phone='\" + phone + \"', age=\" + age +\n \", isAdmin=\" + isAdmin + \", isVerified=\" + isVerified + \"}\";\n }\n}","output": "User{name='Alice', email='alice@example.com', phone='null', age=25, isAdmin=true, isVerified=false}"
}
Building the Builder — A Complete, Runnable Implementation
Here's the pattern in its canonical form. The outer class (UserProfile) is immutable — all fields are final and there's no public constructor. The only way to get a UserProfile is through its nested Builder class.
The Builder holds the same fields but as mutable state. Each setter-style method on the Builder returns this — the Builder itself — which is what enables the fluent chaining syntax. When you call build(), the Builder validates the required fields and then passes itself into the private UserProfile constructor in one shot.
This design makes three guarantees that the telescoping approach cannot: fields are named at the call site (readable), the object is only ever created whole (safe), and required fields can be enforced at build-time rather than silently defaulting to null (correct).
Notice where validation lives — inside build(), not scattered across setters. That's intentional. You want all your validation logic in one place, and you want it to fire at the last possible moment, when the object is about to be created.
UserProfileBuilder.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// Full runnable Builder Pattern implementation — copy-paste and run this directlypublicclassUserProfileBuilder {
publicstaticvoidmain(String[] args) {
// Build a full user profile — every field is named, order doesn't matterUserProfile adminUser = newUserProfile.Builder(
"alice@example.com", // email is required — goes in Builder constructor
"AliceNguyen" // name is required — goes in Builder constructor
)
.age(28)
.phoneNumber("+1-555-0192")
.role("ADMIN")
.isVerified(true)
.build(); // validation fires here — object is created atomicallySystem.out.println("Admin user created:");
System.out.println(adminUser);
System.out.println();
// Build a minimal user — optional fields are simply omitted, no nulls forcedUserProfile guestUser = newUserProfile.Builder("guest@example.com", "Guest User")
.role("GUEST")
.build();
System.out.println("Guest user created:");
System.out.println(guestUser);
System.out.println();
// This will throw — demonstrates required-field validationtry {
UserProfile broken = newUserProfile.Builder("", "No Email").build();
} catch (IllegalStateException ex) {
System.out.println("Caught expected error: " + ex.getMessage());
}
}
}
// The final, immutable product — no public constructor, no setters
class UserProfile {\n\n // All fields are final — this object cannot change after build()\n private final String email;\n private final String fullName;\n private final int age;\n private final String phoneNumber; // optional — may be null\n private final String role;\n private final boolean isVerified;\n\n // Private constructor — only the Builder can call this\n private UserProfile(Builder builder) {\n this.email = builder.email;\n this.fullName = builder.fullName;\n this.age = builder.age;\n this.phoneNumber = builder.phoneNumber;\n this.role = builder.role;\n this.isVerified = builder.isVerified;\n }// ── Getters only — no setters, keeping the object immutable ──────────────publicStringgetEmail() { return email; }
publicStringgetFullName() { return fullName; }
publicintgetAge() { return age; }
publicStringgetPhoneNumber() { return phoneNumber; }
publicStringgetRole() { return role; }
publicbooleanisVerified() { return isVerified; }
@OverridepublicStringtoString() {
return"UserProfile{" +
"email='" + email + "'" +
", fullName='" + fullName + "'" +
", age=" + age +
", phoneNumber='" + phoneNumber + "'" +
", role='" + role + "'" +
", isVerified=" + isVerified +
"}";
}
// ── Static nested Builder class ───────────────────────────────────────────
public static class Builder {\n\n // Required fields — set via Builder constructor so they can't be forgotten\n private final String email;\n private final String fullName;\n\n // Optional fields — sensible defaults applied here\n private int age = 0;\n private String phoneNumber = null;\n private String role = \"USER\"; // default role if not specified\n private boolean isVerified = false;\n\n // Required fields go in the Builder's constructor — makes them mandatory\n public Builder(String email, String fullName) {\n this.email = email;\n this.fullName = fullName;\n }// Each method sets one field and returns 'this' — enabling fluent chainingpublicBuilderage(int age) {
this.age = age;
return this; // <-- this is what makes .age(28).phoneNumber(...) chain work
}
publicBuilderphoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
returnthis;
}
publicBuilderrole(String role) {
this.role = role;
returnthis;
}
publicBuilderisVerified(boolean isVerified) {
this.isVerified = isVerified;
returnthis;
}
// build() is the single moment of truth — validate here, then constructpublicUserProfilebuild() {
// Guard: email is required and must not be blankif (email == null || email.trim().isEmpty()) {
thrownewIllegalStateException(
"Cannot build UserProfile: email is required and cannot be empty");
}
// Guard: name is requiredif (fullName == null || fullName.trim().isEmpty()) {
thrownewIllegalStateException(
"Cannot build UserProfile: fullName is required");
}
// All checks passed — hand 'this' (the Builder) to the private constructorreturnnewUserProfile(this);
}
}
}
Caught expected error: Cannot build UserProfile: email is required and cannot be empty
Pro Tip:
Put truly required fields in the Builder's own constructor (not as chained methods). This makes it physically impossible to call build() without them — no validation needed for those fields, and IDEs will surface them immediately as constructor arguments.
Production Insight
If you put required fields as chained methods instead of Builder constructor args, you risk 'forgotten field' bugs.
A team in a logistics app deployed with a missing shipmentId — the object had null in a critical field.
Required fields in Builder constructor force callers to provide them.
Optional fields with sensible defaults reduce boilerplate.
Validation in build() catches cross-field combinations and mandatory checks.
Real-World Builder — Building an HTTP Request Object
Textbook examples always use Person or Pizza. Let's use something you'll actually encounter: constructing an outgoing HTTP request configuration. This is almost identical to how libraries like OkHttp and Retrofit build their Request objects internally.
An HTTP request has a URL (required), a method (default GET), optional headers, an optional body, a timeout, and retry settings. Some combinations are invalid — you can't have a body on a GET request. The Builder's build() method is the perfect place to enforce that cross-field rule.
This example also shows a real pattern you'll see in production code: returning a copy of the Builder for thread-safe reuse. If you want to fire the same base request to multiple endpoints, you can store a partially-configured Builder, then call .url("...").build() in a loop without any shared mutable state problems.
HttpRequestBuilder.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
// Realistic example: building outgoing HTTP request configurationspublicclassHttpRequestBuilder {
publicstaticvoidmain(String[] args) {
// A POST request with headers, body, and custom timeoutHttpRequest loginRequest = new HttpRequest.Builder("https://api.example.com/auth/login")
.method("POST")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Client-Version", "2.4.1")
.body("{\\\"username\\\": \\\"alice\\\", \\\"password\\\": \\\"secret\\\"}\")\n .timeoutMillis(5000)\n .retryCount(3)\n .build();\n\n System.out.println(\"Login request built:\");\n System.out.println(loginRequest);\n System.out.println();\n\n // A simple GET request — minimal config, defaults fill the rest\n HttpRequest healthCheck = new HttpRequest.Builder(\"https://api.example.com/health\")\n .header(\"X-Client-Version\", \"2.4.1\")\n .build(); // method defaults to GET, no body needed\n\n System.out.println(\"Health check request built:\");\n System.out.println(healthCheck);\n System.out.println();\n\n // Cross-field validation: GET with a body should be rejected\n try {\n HttpRequest badRequest = new HttpRequest.Builder(\"https://api.example.com/users\")\n .method(\"GET\")\n .body(\"{\\\"shouldNotBeHere\\\": true}\") // invalid combination\n .build();\n } catch (IllegalStateException ex) {\n System.out.println(\"Caught cross-field validation error: \" + ex.getMessage());\n }\n }\n}\n\n// Immutable HTTP request object — safe to share across threads once built\nfinal class HttpRequest {\n\n private final String url;\n private final String method;\n private final Map<String, String> headers; // unmodifiable after build\n private final String body;\n private final int timeoutMillis;\n private final int retryCount;\n\n private HttpRequest(Builder builder) {\n this.url = builder.url;\n this.method = builder.method;\n // Defensive copy — caller's map changes won't affect this object\n this.headers = Collections.unmodifiableMap(new HashMap<>(builder.headers));\n this.body = builder.body;\n this.timeoutMillis = builder.timeoutMillis;\n this.retryCount = builder.retryCount;\n }\n\n public String getUrl() { return url; }\n public String getMethod() { return method; }\n public Map<String, String> getHeaders() { return headers; }\n public String getBody() { return body; }\n public int getTimeoutMillis() { return timeoutMillis; }\n public int getRetryCount() { return retryCount; }\n\n @Override\n public String toString() {\n return \"HttpRequest{\" +\n \"method='\" + method + \"'\" +\n \", url='\" + url + \"'\" +\n \", headers=\" + headers +\n \", body='\" + (body != null ? body : \"<none>\") + \"'\" +\n \", timeoutMillis=\" + timeoutMillis +\n \", retryCount=\" + retryCount +\n \"}\";\n }\n\n public static class Builder {\n\n private final String url; // required — in constructor\n private String method = \"GET\"; // sensible default\n private Map<String, String> headers = new HashMap<>();\n private String body = null; // optional\n private int timeoutMillis = 3000; // default 3 second timeout\n private int retryCount = 0; // default: no retries\n\n public Builder(String url) {\n if (url == null || url.isBlank()) {\n throw new IllegalArgumentException(\"URL is required and cannot be blank\");\n }\n this.url = url;\n }\n\n public Builder method(String method) {\n this.method = method.toUpperCase(); // normalise so 'post' == 'POST'\n return this;\n }\n\n // Each call to header() ADDS a header rather than replacing all headers\n public Builder header(String name, String value) {\n this.headers.put(name, value);\n return this;\n }\n\n public Builder body(String body) {\n this.body = body;\n return this;\n }\n\n public Builder timeoutMillis(int timeoutMillis) {\n if (timeoutMillis <= 0) {\n throw new IllegalArgumentException(\"Timeout must be positive\");\n }\n this.timeoutMillis = timeoutMillis;\n return this;\n }\n\n public Builder retryCount(int retryCount) {\n this.retryCount = retryCount;\n return this;\n }\n\n public HttpRequest build() {\n // Cross-field validation: GET and HEAD requests must not have a body\n if ((\"GET\".equals(method) || \"HEAD\".equals(method)) && body != null) {\n throw new IllegalStateException(\n \"HTTP \" + method + \" requests must not have a request body\");\n }\n // POST and PUT should have a body — warn but don't fail hard here\n if ((\"POST\".equals(method) || \"PUT\".equals(method)) && body == null) {\n System.out.println(\"[WARN] Building a \" + method +\n \" request with no body — is that intentional?\");\n }\n return new HttpRequest(this);\n }\n }\n}",
"output": "Login request built:\nHttpRequest{method='POST', url='https://api.example.com/auth/login', headers={X-Client-Version=2.4.1, Content-Type=application/json, Accept=application/json}, body='{\"username\": \"alice\", \"password\": \"secret\"}', timeoutMillis=5000, retryCount=3}\n\nHealth check request built:\nHttpRequest{method='GET', url='https://api.example.com/health', headers={X-Client-Version=2.4.1}, body='<none>', timeoutMillis=3000, retryCount=0}\n\nCaught cross-field validation error: HTTP GET requests must not have a request body"
}
UML Class Diagram of the Builder Pattern
Understanding the Builder Pattern's structure visually makes it easier to see how the pieces connect. The class diagram below shows the canonical Builder Pattern with its four main participants: the Product (the complex object being built), the Builder (abstract interface or concrete builder), the ConcreteBuilder (implements the builder), and the Director (optional — orchestrates the construction sequence).
In the classic Gang of Four version, there's an abstract Builder interface with methods like buildPartA(), buildPartB(), etc., and a getResult() method. A ConcreteBuilder implements those steps. The Director holds a Builder reference and calls the steps in a specific order. The client instantiates the Director, gives it a ConcreteBuilder, and calls construct() to build the product.
However, in modern Java practice — especially for simple POJOs — the Director is often omitted, and the Builder is a static nested class inside the Product. This version is simpler: the Builder has methods to set each field, and build() returns the fully constructed Product. The static nested class has access to the Product's private constructor, enabling immutability.
The diagram below shows both variants: the classic abstract Builder with Director on the left, and the more common Java-specific variant on the right.
Key Insight:
In most Java codebases, the Builder is a static nested class inside the Product, eliminating the need for a separate Director or abstract Builder interface. The Director's role is implicitly handled by the client code that chains the setter methods in a specific order. Use the full Director pattern only when you have multiple variations of build sequences (e.g., different meal combos in a restaurant ordering system).
Production Insight
The Director is rarely used in enterprise Java applications. Most teams find that the fluent chain in the client code is sufficient to orchestrate construction. Introducing a Director often adds unnecessary abstraction. Reserve it for cases where you need to reuse a construction sequence across multiple products (e.g., building different types of documents from the same template steps).
Key Takeaway
The classic Builder Pattern has four participants: Product, Builder interface, ConcreteBuilder, and Director. The Java variant simplifies by nesting the Builder inside Product, which provides private constructor access and eliminates the need for a separate Director in most cases.
When to Use the Builder vs Other Patterns
The Builder Pattern isn't always the right choice. For simple objects with 2-3 fields, a well-named constructor or static factory method is cleaner. Use Builder when:
An object has 5+ fields (especially same-type parameters like String or boolean)
Some fields are optional and you don't want to force nulls
The construction involves validation rules that involve multiple fields
You want the object to be immutable and created atomically
Alternatives to consider
Constructor with default parameters (Java doesn't have this natively, but Kotlin does)
Static factory method with named builder-like methods (e.g., Person.createWithNameAndAge("Alice", 30))
Lombok's @Builder — great for simple POJOs, but limited for complex validation
JavaBean pattern — setters after no-arg constructor — breaks immutability and thread safety
WhenToUseBuilder.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
// Example: 3 fields — static factory is simpler than BuilderpublicclassPoint {
privatefinalint x, y;
privatefinalString label;
privatePoint(int x, int y, String label) {
this.x = x; this.y = y; this.label = label;
}
public static Pointof(int x, int y) {\n return new Point(x, y, \"\");\n }\n public static Pointlabeled(int x, int y, String label) {\n return new Point(x, y, label);\n }\n}\n\n// Example: 7+ fields with validation — Builder wins\npublic class PaymentOrder {\n private final String orderId; // required\n private final String currency; // required\n private final BigDecimal amount; // required\n private final String description; // optional\n private final boolean isRecurring; // optional, default false\n private final int retryCount; // optional, default 0\n private final List<String> tags; // optional, default empty\n\n private PaymentOrder(Builder b) { /* assign fields */ }\n\n public static class Builder {\n private final String orderId; // required — in constructor\n private final String currency; // required\n private final BigDecimal amount; // required\n private String description;\n private boolean isRecurring;\n private int retryCount;\n private List<String> tags = new ArrayList<>();\n\n public Builder(String orderId, String currency, BigDecimal amount) {\n this.orderId = orderId;\n this.currency = currency;\n this.amount = amount;\n }\n // ... fluent setters\n public PaymentOrder build() {\n if (orderId == null || orderId.isBlank())\n throw new IllegalStateException(\"orderId required\");\n if (amount.compareTo(BigDecimal.ZERO) <= 0)\n throw new IllegalStateException(\"amount must be positive\");\n return new PaymentOrder(this);\n }\n }\n}","output": ""
}
Pros and Cons of the Builder Pattern
Like every design pattern, the Builder Pattern comes with trade-offs. Understanding these helps you decide when to use it and when a simpler alternative would suffice.
Pros: - Readability: Named parameters in method chains make the object construction self-documenting. Compare new User.Builder("alice@example.com").name("Alice").age(28).build() to new User("alice@example.com", "Alice", 28, null, null). - Immutability: The product object can be made fully immutable because all fields are set once via the Builder and never exposed via setters. - Validation gate: The build() method serves as a single place to enforce required fields, cross-field rules, and invariants before the object exits. - Step-by-step construction: Each setter is simple and focused. Complex logic can be added per field without bloating a constructor. - Thread safety during construction: The Builder is local to the thread that creates it; the product object, once built, is immutable and safe to share.
Cons: - Boilerplate: Hand-written Builder adds approximately twice as many lines of code as the product class itself. This is intentional — the code is explicit, but it's still repetition. - Performance overhead: Creating a Builder object and calling chain methods adds ~3-5% overhead compared to a direct constructor call. In most applications this is negligible, but in high-throughput loops (e.g., constructing millions of objects per second) it may matter. - Not necessary for simple objects: For classes with 2-3 fields, a static factory or constructor is simpler and more performant. - Forgetting return this is a common bug that breaks the chain. - Cannot enforce construction order: The fluent chain allows any order of method calls. If your construction requires a specific sequence (e.g., must set address before city), the Builder doesn't enforce it — you'd need a builder variant or state machine.
Aspect
Benefit
Drawback
Code clarity
Named fields eliminate argument confusion
More lines of code
Immutability
Easy to make product immutable
Extra class (Builder)
Validation
Centralized in build()
Must remember to validate
Performance
Safe for most apps
3-5% overhead in tight loops
Flexibility
Fields can be set in any order
Cannot enforce order constraints
Decision Matrix:
Use Builder when: object has 5+ fields, especially same-type parameters, or requires cross-field validation. Skip Builder when: object has 2-3 fields and is simple, or when you need maximum performance in object creation loops.
Production Insight
The performance overhead of Builder is almost never the bottleneck in real applications. I've worked on systems creating 100,000 objects per second — the Builder overhead was under 1ms per 10k objects. The real cost is development time and boilerplate maintenance. Use Lombok's @Builder to reduce boilerplate if validation is minimal.
Key Takeaway
Builder trades additional code and slight performance overhead for significant gains in readability, immutability, and validation clarity. Use it when the complexity of construction justifies the extra lines.
Lombok @Builder Comparison Example
Project Lombok's @Builder annotation generates a Builder class automatically at compile time. It's widely used because it eliminates boilerplate code for simple data classes. However, it has limitations compared to a hand-written Builder, especially around validation and required fields.
Let's compare the same UserProfile class built two ways: one with a hand-written Builder (full control) and one with Lombok @Builder (convenience).
Lombok @Builder example:
When you annotate a class with @Builder, Lombok generates a static inner Builder class with a setter method for every non-static field and a build() method that calls the all-args constructor. By default, all fields are optional — there's no way to make a field required via the annotation alone. You must either use @NonNull on the field (which adds null checks in the generated setter but the setter is still optional, it just throws NPE if null is passed) or rely on @Builder.Default for defaults.
Key differences: - Required fields: Hand-written: you put required fields in Builder constructor, making them mandatory at compile time. Lombok: all fields are set via optional methods; no compile-time enforcement. - Validation: Hand-written: full if blocks in build() with custom error messages. Lombok: limited to @NonNull (throws NullPointerException) and @Builder.Default (with custom logic but still can't cross-validate). - Defensive copies: Hand-written: you control copying mutable collections. Lombok: by default passes references without copying; you need @Builder.Default and custom getters. - Immutability: Both can produce immutable objects if you make fields final and Lombok generates a constructor that sets them. However, Lombok generates setters on the Builder, not on the product.
When to use Lombok @Builder: - For simple POJOs (DTOs, configuration holders) with no or minimal validation. - When you don't need required fields enforced at compile time. - When you're already using Lombok in the project.
When to hand-write: - When you need required fields in the builder constructor. - When you have complex cross-field validation. - When you need defensive copies of mutable collections. - When you need custom method names or builder logic.
The code below demonstrates both approaches for the same UserProfile class.
LombokVsHandwrittenBuilder.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import lombok.Builder;
import lombok.Value;
import java.util.Collections;
import java.util.List;
/**
* EXAMPLE1: Lombok @Builder — minimal code, but all fields are optional.
* No compile-time enforcement of required fields.
* No cross-field validation in build().
*/
@Builder
@Value// makes fields private final, generates getters, equals, hashCode, toString
public class UserProfileLombok {\n String email; // intended as required, but Lombok makes it optional via setter\n String fullName; // intended as required\n int age; // optional, defaults to 0\n String role; // optional, defaults to null\n List<String> tags; // mutable list — no defensive copy\n\n public static void main(String[] args) {\n // Lombok allows building without required fields — no error until runtime if validation added manually\n UserProfileLombok user = UserProfileLombok.builder()\n .email(\"alice@example.com\")\n .fullName(\"Alice\")\n .build();\n System.out.println(user);\n }\n}\n\n/* === Equivalent hand-written Builder with required fields and validation === */\npublic class UserProfileHandWritten {\n private final String email;\n private final String fullName;\n private final int age;\n private final String role;\n private final List<String> tags;\n\n private UserProfileHandWritten(Builder builder) {\n this.email = builder.email;\n this.fullName = builder.fullName;\n this.age = builder.age;\n this.role = builder.role;\n // Defensive copy\n this.tags = Collections.unmodifiableList(\n builder.tags == null ? List.of() : List.copyOf(builder.tags));\n }\n\n public static class Builder {\n private final String email; // required — in constructor\n private final String fullName; // required\n private int age = 0;\n private String role = \"USER\";\n private List<String> tags = List.of();\n\n public Builder(String email, String fullName) {\n this.email = email;\n this.fullName = fullName;\n }\n public Builder age(int age) { this.age = age; return this; }\n public Builder role(String role) { this.role = role; return this; }\n public Builder tags(List<String> tags) { this.tags = tags; return this; }\n\n public UserProfileHandWritten build() {\n if (email == null || email.isBlank()) {\n throw new IllegalStateException(\"email is required\");\n }\n if (fullName == null || fullName.isBlank()) {\n throw new IllegalStateException(\"fullName is required\");\n }\n return new UserProfileHandWritten(this);\n }\n }\n}","output": "UserProfileLombok(email=alice@example.com, fullName=Alice, age=0, role=null, tags=null)"
}
Common Pitfalls and How to Avoid Them
Even experienced developers make mistakes with the Builder Pattern. Here are the most frequent ones and how to prevent them.
1. Forgetting return this — This is the most common. If a setter returns void, the chain breaks. Always return the Builder instance.
2. Making the Builder a top-level class — The Builder should be a static nested class inside the product. If it's separate, it can't access the private constructor, forcing you to make the constructor package-private or public, breaking immutability.
3. Sharing a Builder across threads — Builders are mutable. If two threads call setter methods on the same Builder, you get a race condition. Always create a new Builder per object per thread.
4. Putting validation only in setters — Setters see only one field at a time. Cross-field rules (like 'GET cannot have a body') must be in build().
5. Not copying mutable collections in the constructor — If the Builder holds a List, the caller retains a reference. After build(), the caller can modify that list, breaking the product's immutability. Always make defensive copies.
BuilderPitfallsFix.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// WRONG: mutable list not copied — caller can modify after buildpublicclassBadBuilder {
privateList<String> tags;
publicBadBuildertags(List<String> tags) { this.tags = tags; returnthis; }
publicProductbuild() {
return new Product(this); // Product stores the reference as-is
}
}
// RIGHT: defensive copy in product constructorpublicclassGoodBuilder {
privateList<String> tags = newArrayList<>();
publicGoodBuildertags(List<String> tags) { this.tags = tags; returnthis; }
publicProductbuild() {
returnnewProduct(this);
}
}
classProduct {
privatefinalList<String> tags;
Product(GoodBuilder b) {
this.tags = Collections.unmodifiableList(new ArrayList<>(b.tags)); // copy
}
}
// Thread safety: Never share Builder across threads// WRONG:// Builder shared = new Builder(...);// Thread1: shared.age(28);// Thread2: shared.role("ADMIN"); // race condition!// RIGHT: Each thread creates its own Builder// Thread1: new Builder(...).age(28).build();// Thread2: new Builder(...).role("ADMIN").build();
Watch Out:
A shared Builder across threads is a time bomb. It's mutable state, and concurrency bugs in builders are notoriously hard to reproduce — the race window is tiny and intermittent. Create a fresh Builder per use.
Production Insight
A production incident: a shared Builder in a request-scoped service caused users to see each other's roles.
The Builder was stored in an instance variable and reused across threads in a web container.
Root cause: two threads called .role("ADMIN") and .role("GUEST") at the same time — final objects had mixed roles.
Key Takeaway
Always return this in setter methods.
Nest the Builder as a static inner class.
Never share Builder instances across threads — create one per object.
Always make defensive copies of mutable fields in the product constructor.
● Production incidentPOST-MORTEMseverity: high
The Null Argument Bug: How a Telescoping Constructor Caused Silent Data Corruption
Symptom
Transactions appeared in the database with merchant_id = NULL for a subset of customers. No exception thrown. Manual inspection revealed the null came from a constructor call: new PaymentTransaction("TXN123", null, ...) — the developer passed null for merchantId thinking it was the optional couponCode.
Assumption
The team assumed that because the constructor compiled without errors, the arguments were in the correct order and the object was valid. They also assumed that having overloaded constructors with sensible defaults would prevent such mistakes.
Root cause
Six-parameter telescoping constructor with two boolean flags and two optional strings. Human pattern-matching failed: the developer saw null in the position for couponCode (optional) but actually wrote it in the merchantId position. Compilation succeeded because both are String. The object was created with a null required field.
Fix
Replaced the telescoping constructors with a Builder. merchantId is required — placed in the Builder's constructor, so it cannot be omitted. All optional fields use chained methods. The build() method validates that merchantId is not null and throws an IllegalStateException with a clear message if it is.
Key lesson
Never rely on positional parameters for constructors with more than 3 arguments.
Required fields must be impossible to skip — use Builder constructor parameters, not chained methods.
Always validate critical fields in build() with explicit error messages.
Treat 'compiles fine' as 'type-safe but not semantics-safe' when dealing with same-type parameters.
Production debug guideDiagnose builder-related issues fast4 entries
Symptom · 01
Fluent chain throws NullPointerException: .age(28).role("ADMIN") fails with NPE
→
Fix
Check that every setter method returns this (the Builder). Look for void return type or missing return this;. This is the #1 mistake.
Symptom · 02
build() throws IllegalStateException with validation message
→
Fix
Read the exception message — it tells you which field failed validation. Fix the missing or invalid field in the chain before calling build().
Symptom · 03
Object has default values despite setting fields in the chain
→
Fix
Did you call build()? If you forgot, you have a Builder instance, not the product. Also check if the setter actually modifies the Builder field — typo in field name is common.
Symptom · 04
Compile error: 'cannot find symbol' on .method() in chain
→
Fix
The return type of the previous method is wrong. Either it returns void, or the chain broke because of a compiler oversight. Verify each method returns Builder (the class itself, not a parent).