Một đơn hàng có mã, khách, danh sách sản phẩm, số lượng, cờ giao nhanh, ghi chú và nhiều trường tuỳ chọn khác. Viết constructor cho từng tổ hợp thì code gọi khó đọc, còn dùng setter thì đối tượng tồn tại ở trạng thái dở dang. Builder pattern giải quyết đúng chỗ đó: dựng đối tượng từng bước, kiểm tra ràng buộc một lần, rồi mới trả về một đối tượng hoàn chỉnh và bất biến. Bài này đi từ Builder pattern trong sách Effective Java của Joshua Bloch, sang Lombok @Builder, rồi tới record của Java 16, và so sánh khi nào dùng cái nào. Mọi đoạn code đều đã biên dịch và chạy thật, output dán nguyên văn.
Bài nằm trong series Java & Design Pattern. Nếu bạn đã đọc Factory Method và Abstract Factory: tách việc tạo đối tượng khỏi logic thì sẽ thấy Builder cùng nhóm creational nhưng giải bài toán khác: Factory chọn loại đối tượng, còn Builder lo cách lắp ráp một đối tượng nhiều thành phần. Nguyên lý nền ở bài Nguyên lý SOLID trong Java qua ví dụ thực tế, đặc biệt Single Responsibility.
Vấn đề: telescoping constructor và JavaBeans
Theo mô tả Builder của Refactoring.Guru, khi đối tượng có nhiều thuộc tính tuỳ chọn, người ta thường phải nạp chồng nhiều constructor với số tham số tăng dần (telescoping constructor), hoặc dồn mọi tham số vào một constructor khổng lồ (cách khác nữa là tạo subclass cho từng cấu hình). Các cách này khiến lời gọi khó đọc và phần lớn tham số thường không dùng đến.
Đây là cách thứ nhất với đơn hàng của chúng ta (mã trong code/s6-04, lớp Telescoping). Bạn đoán được true, false cuối cùng nghĩa là gì không?
new Telescoping("A01", "Lan", null, 2, true, false);Hai boolean liền nhau là kiểu lỗi kinh điển: đổi chỗ chúng, trình biên dịch vẫn im lặng. Cách thứ hai, JavaBeans, đọc dễ hơn nhưng chưa phải Builder pattern, và trả giá bằng tính nhất quán. Đối tượng được tạo rỗng rồi set từng trường, nên giữa các lời gọi nó ở trạng thái không hợp lệ:
BeanDonHang bean = new BeanDonHang();
bean.setMa("A02");
System.out.println("giữa chừng: " + bean);
bean.setKhach("Minh"); bean.setSoLuong(3);
System.out.println("xong : " + bean);Output thực tế:
--- 1. Telescoping constructor ---
Telescoping[A01,Lan,null,2,true,false]
--- 2. JavaBeans: trạng thái dở dang ---
giữa chừng: BeanDonHang[A02,null,0]
xong : BeanDonHang[A02,Minh,3]Dòng "giữa chừng" cho thấy khách là null và số lượng là 0. Nếu đối tượng đã được chia sẻ cho luồng khác trước khi set xong, luồng đó đọc được dữ liệu sai. Bạn cũng không thể làm lớp này bất biến vì còn setter. Bloch gọi đây là lý do chính để chọn Builder trong Item 2 của Effective Java, 3rd Edition: "Consider a builder when faced with many constructor parameters".
Cấu trúc của Builder pattern
Trong định nghĩa gốc của GoF, Builder pattern có bốn thành phần là Builder, ConcreteBuilder, Director và Product; ngoài ra còn có Client là đoạn mã sử dụng. Bảng dưới đối chiếu các vai trò của Builder pattern với code Java thường gặp.
| Vai trò | Nhiệm vụ | Trong ví dụ của bài |
|---|---|---|
| Product | Đối tượng cuối cùng, thường bất biến | DonHang |
| Builder và ConcreteBuilder | Khai báo và thực hiện các bước dựng; biến thể Effective Java gộp hai vai trò này thành một lớp lồng | DonHang.Builder (lớp lồng static) |
| Director (tuỳ chọn) | Gọi các bước theo một "công thức" cố định | Director.donGiaoNhanhMotMon(...) |
| Client (mã sử dụng, không thuộc bốn thành phần của GoF) | Dùng builder hoặc director để lấy sản phẩm | Main |
Bản GoF nguyên thủy có interface Builder riêng để cùng một quy trình tạo ra nhiều dạng sản phẩm khác nhau (ví dụ dựng cùng một tài liệu thành HTML hoặc PDF). Trong Java hằng ngày, biến thể Builder pattern phổ biến hơn nhiều là Builder lồng trong chính lớp sản phẩm, thường được gọi là biến thể Effective Java. Ta xem biến thể này trước.
Cài đặt Builder pattern kiểu Effective Java
Các quyết định thiết kế quan trọng của Builder pattern: constructor của sản phẩm là private và nhận builder, tham số bắt buộc đi vào static factory builder(ma, khach), tham số tuỳ chọn có giá trị mặc định, và build() là nơi duy nhất kiểm tra ràng buộc.
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)); // sao chép phòng thủ
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("Đơn hàng phải có ít nhất 1 sản phẩm");
if (soLuong < 1) throw new IllegalStateException("soLuong phải >= 1, nhận " + soLuong);
if (giaoNhanh && sanPham.size() > 3) throw new IllegalStateException("Giao nhanh tối đa 3 sản phẩm");
return new DonHang(this);
}
}
}Hai điểm tinh tế đáng nhắc. Thứ nhất, sanPham được sao chép trong constructor của sản phẩm, nên nếu client giữ builder và sửa tiếp, đối tượng đã build không bị ảnh hưởng. Thứ hai, ràng buộc liên quan nhiều trường (giao nhanh chỉ cho tối đa 3 sản phẩm) chỉ kiểm tra được khi đã có đủ dữ liệu. Setter không làm được điều này, còn Builder pattern thì có chỗ tự nhiên: build().
DonHang d = DonHang.builder("A03", "Hoa").themSanPham("Ốp lưng").themSanPham("Sạc 20W").soLuong(2).giaoNhanh(true).build();
System.out.println(d);
System.out.println(Director.donGiaoNhanhMotMon("A04", "Nam", "Tai nghe"));
try { DonHang.builder("A05", "Tú").build(); } catch (IllegalStateException e) { System.out.println("Lỗi build(): " + e.getMessage()); }
try { d.sanPham().add("Hack"); } catch (UnsupportedOperationException e) { System.out.println("Danh sách sản phẩm bất biến: UnsupportedOperationException"); }--- 3. Builder kiểu Effective Java ---
DonHang[ma=A03, khach=Hoa, sanPham=[Ốp lưng, Sạc 20W], soLuong=2, giaoNhanh=true, ghiChu=null]
DonHang[ma=A04, khach=Nam, sanPham=[Tai nghe], soLuong=1, giaoNhanh=true, ghiChu=Ưu tiên]
Lỗi build(): Đơn hàng phải có ít nhất 1 sản phẩm
Danh sách sản phẩm bất biến: UnsupportedOperationExceptionĐọc dòng gọi Builder pattern, ta biết ngay giá trị nào thuộc trường nào, không còn đoán true, false. Dòng thứ hai là kết quả của Director: một hàm tĩnh đóng gói công thức "đơn giao nhanh một món", để mã gọi không phải lặp lại chuỗi bước. Director là phần tuỳ chọn, chỉ đáng viết khi có vài cấu hình lặp đi lặp lại.
Builder pattern trong chính JDK
Bạn dùng Builder pattern hằng ngày mà có thể không để ý. JDK 11 trở đi có java.net.http.HttpRequest.newBuilder(); Locale.Builder có từ Java 7 và Stream.builder() từ Java 8. Ngay cả StringBuilder tuy cùng tên nhưng thật ra là một bộ đệm có thể sửa đổi, ít liên quan tới pattern này. Kết quả chạy đoạn tương ứng:
--- 6. Builder trong JDK ---
GET https://httpbin.org/get timeout=PT5S
Locale: vi-VN
Stream.Builder: [a, b]
StringBuilder: 24 nơĐChú ý dòng cuối: StringBuilder ở đây chỉ nối chuỗi rồi đảo ngược, không hề tạo ra đối tượng khác kiểu, nên nó là ví dụ về cái tên chứ không phải về mẫu thiết kế. Với HttpRequest, ta thấy đúng tinh thần: builder có thể thay đổi, còn HttpRequest sau khi build() là bất biến. Tài liệu API ghi rõ điều này trong HttpRequest.Builder (Java SE 17). Ví dụ trên chỉ dựng đối tượng, không gửi request nào ra mạng.
Builder pattern với Lombok @Builder
Viết Builder pattern bằng tay cho mỗi lớp rất lặp lại. Project Lombok sinh nó lúc biên dịch qua annotation processing. Theo tài liệu, @Builder đặt trên lớp, constructor hoặc method; Lombok tạo lớp lồng static, các method đặt giá trị trả về chính builder, build() và toString(). Cùng đơn hàng, viết Builder pattern bằng Lombok:
@Builder(toBuilder = true)
@ToString
public class LombokDonHang {
@NonNull private final String ma;
@NonNull private final String khach;
@Singular private final List<String> sanPhams; // sinh sanPham(String) và sanPhams(Collection)
@Builder.Default private final int soLuong = 1;
private final boolean giaoNhanh;
}Ba annotation phụ đáng biết. @Singular dùng cho collection: thay vì một setter nhận cả danh sách, Lombok sinh method thêm từng phần tử, method thêm cả collection và method clear; collection tạo ra là bất biến. Lombok đoán dạng số ít từ tên số nhiều tiếng Anh, vì vậy field tiếng Việt như sanPhams là cách dễ đoán nhất (Lombok bỏ chữ "s" cuối). @Builder.Default đặt giá trị mặc định; không có nó, field chưa set sẽ là 0 hoặc null, bỏ qua initializer. toBuilder = true sinh method toBuilder() để tạo builder từ một đối tượng có sẵn, rất tiện cho kiểu "bản sao có sửa một trường".
LombokDonHang l = LombokDonHang.builder().ma("L1").khach("Bảo").sanPham("Sạc").sanPham("Cáp").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=Bảo, sanPhams=[Sạc, Cáp], soLuong=1, giaoNhanh=true)
LombokDonHang(ma=L1, khach=Bảo, sanPhams=[Sạc, Cáp], soLuong=5, giaoNhanh=true)
Lombok @NonNull: khach is marked non-null but is nullĐể biết Lombok thực sự sinh gì, dùng javap -p trên lớp builder đã biên dịch. Kết quả dưới đây (đã lược constructor và toString) cho thấy builder có field soLuong$set và soLuong$value: đó là cơ chế để phân biệt "chưa set" với "set bằng 0", nhờ vậy @Builder.Default hoạt động đúng. Cũng thấy method sanPham(String), sanPhams(Collection) và clearSanPhams() do @Singular sinh.
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();
}Lưu ý quan trọng khi dùng Lombok: ràng buộc nhiều trường kiểu "giao nhanh tối đa 3 sản phẩm" không có chỗ để viết vì build() do Lombok sinh. Giải pháp là đặt @Builder lên một constructor tự viết (constructor đó chứa phần kiểm tra), thay vì đặt lên lớp. Với Jackson, tài liệu Lombok gợi ý dùng @Jacksonized. Ngoài ra, Lombok phải khớp phiên bản JDK; hãy cập nhật Lombok mỗi khi nâng JDK.
Record và Builder pattern: khi nào cần cả hai
record được chốt ở Java 16 qua JEP 395. Nó tự sinh field final, accessor, canonical constructor, equals, hashCode và toString. Với dữ liệu ít trường thì record gần như thay thế Builder pattern. Điểm cần nhớ: bất biến của record là nông; nếu component là List thì ta phải sao chép phòng thủ trong 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); // sao chép phòng thủ + bất biến thật sự
if (sanPham.isEmpty()) throw new IllegalArgumentException("Cần ít nhất 1 sản phẩm");
if (soLuong < 1) throw new IllegalArgumentException("soLuong >= 1");
}
public DonHangRecord withGiaoNhanh(boolean v) { return new DonHangRecord(ma, khach, sanPham, soLuong, v); }
}Compact constructor đóng vai trò tương tự build(): chỗ duy nhất kiểm tra ràng buộc. Method withGiaoNhanh là kiểu "with-er", trả bản mới thay vì sửa tại chỗ, thay thế toBuilder() cho vài trường. Khi số trường tuỳ chọn tăng lên, bạn vẫn cần Builder pattern; record không có builder sẵn, và JEP 395 cũng không đặt mục tiêu này. Ta tự viết lớp Builder lồng trong record (xem DonHangRecord.java; các dòng output dưới đây lấy từ Main.java, nơi gọi DonHangRecord.builder().ma("R1").khach("Vy").sanPham(nguon.toArray(String[]::new)).build() rồi soLuong(0) để gây lỗi), hoặc dùng @Builder của Lombok trên record. Kết quả chạy:
--- 4. Record ---
DonHangRecord[ma=R1, khach=Vy, sanPham=[Cáp], soLuong=1, giaoNhanh=false] (nguon đổi nhưng record không đổi: [Cáp, Pin])
DonHangRecord[ma=R1, khach=Vy, sanPham=[Cáp], soLuong=1, giaoNhanh=true]
Lỗi record: soLuong >= 1Dòng đầu chứng minh sao chép phòng thủ: sau khi danh sách nguồn thêm "Pin", record vẫn chỉ có "Cáp". Dòng ba là lỗi từ compact constructor.
Chọn cách nào: Builder pattern hay record
| Tiêu chí | Constructor / record | Builder tự viết | Lombok @Builder |
|---|---|---|---|
| Số tham số | Tới khoảng 4–5 | Nhiều, nhiều tuỳ chọn | Nhiều, nhiều tuỳ chọn |
| Code lặp | Rất ít với record | Nhiều | Rất ít |
| Kiểm tra liên trường | Compact constructor | build() | Cần constructor riêng |
| Bất biến | Có (cần sao chép collection) | Có | Có nếu field final |
| Phụ thuộc thêm | Không | Không | Lombok, cần khớp JDK |
| Kiểm soát API | Cao | Cao nhất | Trung bình |
Quy tắc thực dụng của chúng tôi: từ 1–4 tham số, hầu hết bắt buộc, dùng record hoặc constructor. Từ khoảng 5 tham số trở lên với nhiều tuỳ chọn, hoặc có ràng buộc liên trường, dùng Builder pattern. Dự án đã dùng Lombok thì @Builder trên constructor là cân bằng tốt. Thư viện công khai, nơi API sống lâu và cần kiểm soát chính xác, nên viết Builder pattern bằng tay.
Khi nào không nên dùng Builder pattern
Đây là chỗ nhiều bài viết bỏ qua khi nói về Builder pattern. Builder pattern tốn thêm một lớp, và Refactoring.Guru cũng nêu nhược điểm là tăng độ phức tạp tổng thể. Đừng dùng khi:
- Lớp chỉ có 2–3 trường và đều bắt buộc: constructor hoặc record rõ ràng hơn.
- Hiệu năng ở đường nóng: mỗi lần dựng sinh thêm một đối tượng builder. Hầu hết ứng dụng không bận tâm, nhưng vòng lặp hàng triệu lần thì có.
- Đối tượng thật sự mutable theo nghiệp vụ: nếu trạng thái cần thay đổi suốt vòng đời, bất biến không phải mục tiêu.
- Framework yêu cầu constructor mặc định hoặc setter: một số công cụ ORM và serializer cần chúng; thêm Builder có thể phải cấu hình bổ sung.
Lỗi thường gặp
- Quên kiểm tra trong
build(): Builder pattern mất lợi thế lớn nhất nếu mọi ràng buộc vẫn nằm rải rác ở nơi gọi. - Không sao chép phòng thủ collection: đối tượng "bất biến" nhưng vẫn bị sửa qua danh sách nguồn. Ví dụ Record ở trên đã chứng minh cách sửa.
- Dùng lại một builder cho nhiều đối tượng mà không đặt lại:
themSanPhamcộng dồn qua các lầnbuild(). Hoặc tạo builder mới mỗi lần, hoặc cung cấpclear. - Dùng
@Builderkèm initializer mà thiếu@Builder.Default: giá trị khởi tạo bị bỏ qua và bạn nhận 0 hoặcnull; Lombok cảnh báo ở phiên bản mới. - Tưởng record tự có builder: không có. Hãy tự viết hoặc nhờ Lombok.
- Builder chia sẻ giữa các luồng: builder không an toàn luồng. Dựng ở một luồng, chỉ chia sẻ sản phẩm bất biến.
Checklist
- Từ 5 tham số và nhiều tuỳ chọn trở lên, cân nhắc Builder pattern thay telescoping constructor.
- Tham số bắt buộc vào static factory hoặc constructor của builder; tuỳ chọn có mặc định.
- Ràng buộc liên trường kiểm tra trong
build()(hoặc compact constructor của record). - Sao chép phòng thủ mọi collection khi dựng sản phẩm bất biến.
- Lombok:
@Singularcho collection,@Builder.Defaultcho giá trị mặc định,toBuildercho bản sao có sửa. - Dữ liệu ít trường: dùng
recordthay vì Builder pattern, thêm "with-er" nếu cần bản sao có sửa.
Môi trường chạy thử: máy 16 GB RAM chạy macOS; code biên dịch ở mức ngôn ngữ Java 17 (javac --release 17) với Lombok 1.18.x. Các tính năng của Builder pattern dùng trong bài (@Singular, @Builder.Default, toBuilder, @Builder trên record) đã có từ Lombok 1.18.20–1.18.24, trước ngày đăng; output thực tế chạy với 1.18.34 vì trình biên dịch dùng để chạy đòi hỏi Lombok từ 1.18.30 trở lên. Toàn bộ mã nguồn và lệnh chạy nằm trong thư mục code/s6-04.
Bài tiếp theo
Prototype pattern: clone, copy constructor và sao chép sâu. Builder dựng đối tượng từ đầu; bài sau xem cách nhân bản đối tượng sẵn có, và vì sao Cloneable bị coi là một thiết kế khó dùng.



