An order has an ID, a customer, a list of products, a quantity, an express-delivery flag, a note and several other optional fields. If you write one constructor per combination, the calling code is hard to read. If you use setters, the object sits in a half-built state. The Builder pattern solves exactly this: you assemble the object step by step, check the constraints once, and only then return a complete, immutable object. This article goes from the Builder pattern in Joshua Bloch's Effective Java to Lombok @Builder, then to the Java 16 record, and compares when to use each. Every code sample was compiled and run, and the output is pasted verbatim.
This article is part of the Java & Design Patterns series. If you have read Factory Method and Abstract Factory: separating object creation from logic, you will see that Builder belongs to the same creational family but solves a different problem: a Factory chooses the type of object, while a Builder handles how a multi-part object is assembled. The underlying principles are in SOLID principles in Java with practical examples, especially Single Responsibility.
The problem: telescoping constructors and JavaBeans
According to the Refactoring.Guru description of Builder, when an object has many optional attributes, people usually end up overloading constructors with a growing number of parameters (a telescoping constructor), or cramming every parameter into one huge constructor (another option is to create a subclass for each configuration). Both approaches make calls hard to read, and most parameters often go unused.
Here is the first approach applied to our order (code in code/s6-04, class Telescoping). Can you tell what the trailing true, false mean?
new Telescoping("A01", "Lan", null, 2, true, false);Two adjacent booleans are a classic source of bugs: swap them and the compiler stays silent. The second approach, JavaBeans, reads better but is not the Builder pattern, and it costs you consistency. The object is created empty and then each field is set, so between calls it is in an invalid state:
BeanDonHang bean = new BeanDonHang();
bean.setMa("A02");
System.out.println("midway: " + bean);
bean.setKhach("Minh"); bean.setSoLuong(3);
System.out.println("done : " + bean);Actual output:
--- 1. Telescoping constructor ---
Telescoping[A01,Lan,null,2,true,false]
--- 2. JavaBeans: half-built state ---
midway: BeanDonHang[A02,null,0]
done : BeanDonHang[A02,Minh,3]The "midway" line shows the customer is null and the quantity is 0. If the object has been shared with another thread before all setters run, that thread can read wrong data. You also cannot make the class immutable while setters exist. Bloch gives this as the main reason to choose a builder in Item 2 of Effective Java, 3rd Edition: "Consider a builder when faced with many constructor parameters".
Structure of the Builder pattern
In the original GoF definition, the Builder pattern has four components: Builder, ConcreteBuilder, Director and Product; there is also the Client, the code that uses them. The table below maps the roles of the Builder pattern to typical Java code.
| Role | Responsibility | In this article's example |
|---|---|---|
| Product | The final object, usually immutable | DonHang |
| Builder and ConcreteBuilder | Declare and implement the build steps; the Effective Java variant merges the two roles into one nested class | DonHang.Builder (static nested class) |
| Director (optional) | Calls the steps following a fixed "recipe" | Director.donGiaoNhanhMotMon(...) |
| Client (the calling code, not one of the four GoF components) | Uses the builder or director to get the product | Main |
The original GoF version has a separate Builder interface so that the same construction process can produce different kinds of product (for example, building the same document as HTML or as PDF). In everyday Java, a much more common variant of the Builder pattern is a Builder nested inside the product class itself, usually called the Effective Java variant. We look at that variant first.
Implementing the Builder pattern the Effective Java way
The key design decisions of the Builder pattern here: the product's constructor is private and takes the builder, required parameters go into the static factory builder(ma, khach), optional parameters have default values, and build() is the only place that checks constraints.
public final class DonHang {
private final String ma;
private final String khach;
private final List<String> sanPham;
private final int soLuong;
private final boolean giaoNhanh;
private final String ghiChu;
private DonHang(Builder b) {
this.ma = b.ma;
this.khach = b.khach;
this.sanPham = Collections.unmodifiableList(new ArrayList<>(b.sanPham)); // defensive copy
this.soLuong = b.soLuong;
this.giaoNhanh = b.giaoNhanh;
this.ghiChu = b.ghiChu;
}
public static Builder builder(String ma, String khach) { return new Builder(ma, khach); }
public static final class Builder {
private final String ma;
private final String khach;
private final List<String> sanPham = new ArrayList<>();
private int soLuong = 1;
private boolean giaoNhanh;
private String ghiChu;
private Builder(String ma, String khach) {
this.ma = Objects.requireNonNull(ma, "ma");
this.khach = Objects.requireNonNull(khach, "khach");
}
public Builder themSanPham(String sp) { sanPham.add(sp); return this; }
public Builder soLuong(int n) { this.soLuong = n; return this; }
public Builder giaoNhanh(boolean v) { this.giaoNhanh = v; return this; }
public Builder ghiChu(String s) { this.ghiChu = s; return this; }
public DonHang build() {
if (sanPham.isEmpty()) throw new IllegalStateException("An order must have at least 1 product");
if (soLuong < 1) throw new IllegalStateException("soLuong must be >= 1, got " + soLuong);
if (giaoNhanh && sanPham.size() > 3) throw new IllegalStateException("Express delivery allows at most 3 products");
return new DonHang(this);
}
}
}Two subtle points are worth mentioning. First, sanPham is copied in the product's constructor, so if the client keeps the builder and modifies it further, the object that was already built is not affected. Second, a constraint that spans several fields (express delivery only for at most 3 products) can be checked only once all the data is available. Setters cannot do this, while the Builder pattern has a natural place for it: build().
DonHang d = DonHang.builder("A03", "Hoa").themSanPham("Phone case").themSanPham("20W charger").soLuong(2).giaoNhanh(true).build();
System.out.println(d);
System.out.println(Director.donGiaoNhanhMotMon("A04", "Nam", "Earbuds"));
try { DonHang.builder("A05", "Tu").build(); } catch (IllegalStateException e) { System.out.println("build() error: " + e.getMessage()); }
try { d.sanPham().add("Hack"); } catch (UnsupportedOperationException e) { System.out.println("Product list is immutable: UnsupportedOperationException"); }--- 3. Effective Java style Builder ---
DonHang[ma=A03, khach=Hoa, sanPham=[Phone case, 20W charger], soLuong=2, giaoNhanh=true, ghiChu=null]
DonHang[ma=A04, khach=Nam, sanPham=[Earbuds], soLuong=1, giaoNhanh=true, ghiChu=Priority]
build() error: An order must have at least 1 product
Product list is immutable: UnsupportedOperationExceptionReading the Builder pattern call, you immediately know which value belongs to which field; no more guessing true, false. The second line is the result of the Director: a static method that packages the recipe "express order with one item", so the calling code does not repeat the sequence of steps. The Director is optional and only worth writing when a few configurations repeat often.
The Builder pattern in the JDK itself
You use the Builder pattern every day and may not notice. JDK 11 and later has java.net.http.HttpRequest.newBuilder(); Locale.Builder has existed since Java 7 and Stream.builder() since Java 8. Even StringBuilder, despite the similar name, is really a mutable buffer and has little to do with this pattern. Here is the output of the corresponding snippet:
--- 6. Builder in the JDK ---
GET https://httpbin.org/get timeout=PT5S
Locale: vi-VN
Stream.Builder: [a, b]
StringBuilder: 24 redrONote the last line: StringBuilder here only concatenates a string and reverses it. It never produces an object of a different type, so it is an example of a name, not of the design pattern. With HttpRequest we see the real spirit: the builder is mutable, while the HttpRequest is immutable after build(). The API documentation states this clearly in HttpRequest.Builder (Java SE 17). The example above only builds the object and sends no request over the network.
The Builder pattern with Lombok @Builder
Writing the Builder pattern by hand for every class is repetitive. Project Lombok generates it at compile time through annotation processing. According to the documentation, you put @Builder on a class, constructor or method; Lombok creates a static nested class, setter-style methods that return the builder itself, build() and toString(). Here is the same order, written as a Builder pattern with Lombok:
@Builder(toBuilder = true)
@ToString
public class LombokDonHang {
@NonNull private final String ma;
@NonNull private final String khach;
@Singular private final List<String> sanPhams; // generates sanPham(String) and sanPhams(Collection)
@Builder.Default private final int soLuong = 1;
private final boolean giaoNhanh;
}Three helper annotations are worth knowing. @Singular is for collections: instead of one setter that takes the whole list, Lombok generates a method that adds one element, a method that adds a whole collection, and a clear method; the collection it creates is immutable. Lombok guesses the singular form from English plural names, so a field name like sanPhams is the easiest to guess (Lombok drops the trailing "s"). @Builder.Default sets a default value; without it, an unset field is 0 or null and the initializer is ignored. toBuilder = true generates a toBuilder() method that creates a builder from an existing object, very handy for the "copy with one field changed" style.
LombokDonHang l = LombokDonHang.builder().ma("L1").khach("Bao").sanPham("Charger").sanPham("Cable").giaoNhanh(true).build();
System.out.println(l);
System.out.println(l.toBuilder().soLuong(5).build());
try { LombokDonHang.builder().ma("L2").build(); } catch (NullPointerException e) { System.out.println("Lombok @NonNull: " + e.getMessage()); }--- 5. Lombok @Builder ---
LombokDonHang(ma=L1, khach=Bao, sanPhams=[Charger, Cable], soLuong=1, giaoNhanh=true)
LombokDonHang(ma=L1, khach=Bao, sanPhams=[Charger, Cable], soLuong=5, giaoNhanh=true)
Lombok @NonNull: khach is marked non-null but is nullTo see what Lombok actually generates, run javap -p on the compiled builder class. The result below (with the constructor and toString omitted) shows that the builder has the fields soLuong$set and soLuong$value: that is the mechanism that tells "not set" apart from "set to 0", which is how @Builder.Default works correctly. You can also see the sanPham(String), sanPhams(Collection) and clearSanPhams() methods generated by @Singular.
public class LombokDonHang$LombokDonHangBuilder {
private java.lang.String ma;
private java.lang.String khach;
private java.util.ArrayList<java.lang.String> sanPhams;
private boolean soLuong$set;
private int soLuong$value;
private boolean giaoNhanh;
public LombokDonHang$LombokDonHangBuilder ma(java.lang.String);
public LombokDonHang$LombokDonHangBuilder khach(java.lang.String);
public LombokDonHang$LombokDonHangBuilder sanPham(java.lang.String);
public LombokDonHang$LombokDonHangBuilder sanPhams(java.util.Collection<? extends java.lang.String>);
public LombokDonHang$LombokDonHangBuilder clearSanPhams();
public LombokDonHang$LombokDonHangBuilder soLuong(int);
public LombokDonHang$LombokDonHangBuilder giaoNhanh(boolean);
public LombokDonHang build();
}An important caveat when using Lombok: a multi-field constraint such as "express delivery allows at most 3 products" has nowhere to live, because build() is generated by Lombok. The solution is to put @Builder on a hand-written constructor (that constructor contains the checks) instead of on the class. With Jackson, the Lombok documentation suggests using @Jacksonized. Also, Lombok must match your JDK version; update Lombok whenever you upgrade the JDK.
Records and the Builder pattern: when you need both
record was finalized in Java 16 through JEP 395. It generates final fields, accessors, the canonical constructor, equals, hashCode and toString. For data with few fields, a record nearly replaces the Builder pattern. One thing to remember: a record's immutability is shallow; if a component is a List, you must make a defensive copy in the compact constructor.
public record DonHangRecord(String ma, String khach, List<String> sanPham, int soLuong, boolean giaoNhanh) {
public DonHangRecord {
Objects.requireNonNull(ma);
Objects.requireNonNull(khach);
sanPham = List.copyOf(sanPham); // defensive copy + truly immutable
if (sanPham.isEmpty()) throw new IllegalArgumentException("At least 1 product is required");
if (soLuong < 1) throw new IllegalArgumentException("soLuong >= 1");
}
public DonHangRecord withGiaoNhanh(boolean v) { return new DonHangRecord(ma, khach, sanPham, soLuong, v); }
}The compact constructor plays the same role as build(): the single place that checks constraints. The withGiaoNhanh method is a "with-er": it returns a new instance instead of modifying in place, replacing toBuilder() for a few fields. As the number of optional fields grows, you still need the Builder pattern; a record has no builder out of the box, and JEP 395 does not set that as a goal. You can write a nested Builder class inside the record yourself (see DonHangRecord.java; the output lines below come from Main.java, which calls DonHangRecord.builder().ma("R1").khach("Vy").sanPham(source.toArray(String[]::new)).build() and then soLuong(0) to trigger the error), or use Lombok's @Builder on the record. Run result:
--- 4. Record ---
DonHangRecord[ma=R1, khach=Vy, sanPham=[Cable], soLuong=1, giaoNhanh=false] (source changed but the record did not: [Cable, Battery])
DonHangRecord[ma=R1, khach=Vy, sanPham=[Cable], soLuong=1, giaoNhanh=true]
Record error: soLuong >= 1The first line proves the defensive copy: after the source list gains "Battery", the record still holds only "Cable". The third line is the error from the compact constructor.
Which to choose: the Builder pattern or a record
| Criterion | Constructor / record | Hand-written Builder | Lombok @Builder |
|---|---|---|---|
| Number of parameters | Up to about 4-5 | Many, many optional | Many, many optional |
| Boilerplate | Very little with a record | A lot | Very little |
| Cross-field validation | Compact constructor | build() | Needs a custom constructor |
| Immutability | Yes (collections need copying) | Yes | Yes if fields are final |
| Extra dependency | None | None | Lombok, must match the JDK |
| API control | High | Highest | Medium |
Our pragmatic rule: with 1-4 parameters, mostly required, use a record or a constructor. With about 5 or more parameters and many optional ones, or with cross-field constraints, use the Builder pattern. If the project already uses Lombok, @Builder on a constructor is a good balance. A public library, where the API lives long and needs precise control, should have a hand-written Builder pattern.
When not to use the Builder pattern
This is the part many articles skip when they talk about the Builder pattern. The Builder pattern costs an extra class, and Refactoring.Guru also lists increased overall complexity as a drawback. Do not use it when:
- The class has only 2-3 fields and all are required: a constructor or record is clearer.
- Performance on a hot path matters: every build creates an extra builder object. Most applications do not care, but a loop running millions of times does.
- The object is genuinely mutable by business rules: if the state must change throughout its lifetime, immutability is not the goal.
- A framework requires a default constructor or setters: some ORM tools and serializers need them; adding a Builder may require extra configuration.
Common mistakes
- Forgetting validation in
build(): the Builder pattern loses its biggest advantage if constraints are still scattered across call sites. - Not making a defensive copy of collections: the "immutable" object can still be changed through the source list. The Record example above shows the fix.
- Reusing one builder for several objects without resetting it:
themSanPhamaccumulates acrossbuild()calls. Either create a new builder each time or provideclear. - Using
@Builderwith an initializer but no@Builder.Default: the initial value is ignored and you get 0 ornull; newer Lombok versions warn about it. - Assuming a record has a builder automatically: it does not. Write one yourself or let Lombok do it.
- Sharing a builder between threads: a builder is not thread-safe. Build in one thread and share only the immutable product.
Checklist
- With 5 or more parameters and many optional ones, consider the Builder pattern instead of a telescoping constructor.
- Required parameters go into the static factory or the builder's constructor; optional ones have defaults.
- Check cross-field constraints in
build()(or in the record's compact constructor). - Defensively copy every collection when building an immutable product.
- Lombok:
@Singularfor collections,@Builder.Defaultfor default values,toBuilderfor modified copies. - For data with few fields, use a
recordinstead of the Builder pattern, and add a "with-er" if you need modified copies.
Test environment: a 16 GB RAM machine running macOS; the code was compiled at Java 17 language level (javac --release 17) with Lombok 1.18.x. The Builder pattern features used in this article (@Singular, @Builder.Default, toBuilder, @Builder on a record) have existed since Lombok 1.18.20-1.18.24, before the publication date; the actual output was produced with 1.18.34 because the compiler used to run it requires Lombok 1.18.30 or later. All source code and run commands are in the code/s6-04 folder.
Next article
Prototype pattern: clone, copy constructors and deep copies. Builder constructs an object from scratch; the next article looks at cloning an existing object, and why Cloneable is considered a hard-to-use design.



