Tung Bui Thanh (TungDaDev)· (updated 6 months ago)·11 mins read
Trong lập trình hướng đối tượng, tạo một object phức tạp với cả chục thuộc tính (có trường bắt buộc, có trường tuỳ chọn) lúc nào cũng là chuyện đau đầu.
Trước khi có Builder, người ta hay dùng hai cách, và cả hai đều có vấn đề:
Telescoping Constructor Anti-Pattern: viết cả loạt constructor nạp chồng với số tham số tăng dần. Class có 8 trường là bạn sẽ có những constructor 6-7 tham số, nhiều tham số cùng kiểu String hoặc int. Truyền nhầm thứ tự (timeoutMs, retryCount) là chuyện rất dễ xảy ra, mà hai tham số cùng kiểu int thì compiler chẳng có lý do gì để cảnh báo.
JavaBeans Pattern (No-arg Constructor + Setters): tạo object rỗng rồi gọi một loạt setXxx(). Cách này còn tệ hơn: object bị lộ ra ở trạng thái nửa vời (đã tạo nhưng chưa set đủ trường) trong lúc khởi tạo, ai cầm tham chiếu lúc đó là thấy object chưa hoàn chỉnh. Có setter thì cũng không thể làm object bất biến (immutable), mà object không bất biến thì chia sẻ giữa nhiều thread phải tự lo đồng bộ.
Builder Pattern trong cuốn Effective Java của Joshua Bloch xử lý được cả hai chuyện trên. Nhưng nếu nghĩ kỹ hơn ở góc độ thiết kế, mình còn muốn nhiều hơn thế: làm sao để thiếu tham số bắt buộc là báo lỗi ngay lúc compile chứ không đợi tới runtime? Và Builder đi chung với Java Records thế nào?
# so sánh các cách khởi tạo object
# điểm yếu của Builder cổ điển và lời giải "staged Builder"
Với Builder thông thường (kể cả @Builder của Lombok), hàm set nào cũng là tuỳ chọn. Giả sử PaymentOrder bắt buộc phải có orderId, amount và currency, mà dev lỡ quên gọi .amount(...). Nếu build() có kiểm tra thì lỗi nổ ra lúc runtime; còn @Builder của Lombok không tự kiểm tra gì, object được tạo ra êm ru với amount = null và lỗi chỉ lộ ra ở chỗ nào đó xa hơn:
// Builder thông thường: Compile thành công nhưng CRASH tại Runtime!PaymentOrder order = PaymentOrder.builder() .orderId("ORD-001") // Quên set amount! .currency(Currency.getInstance("VND")) .build(); // 🔴 build() tự validate thì ném IllegalStateException lúc runtime, Lombok @Builder thì trả về object có amount = null
# staged Builder pattern (step Builder): an toàn ngay lúc compile
Ý tưởng là mỗi bước là một interface riêng, và hàm của bước này trả về interface của bước kế tiếp (interface chaining). Kiểu trả về quyết định gọi được hàm nào tiếp theo, nên compiler ép dev đi đủ các trường bắt buộc theo đúng thứ tự. Chỉ ở bước cuối .build() mới xuất hiện, cả trong gợi ý của IDE lẫn với compiler.
# cài đặt staged Builder
package com.company.payment.domain;import java.math.BigDecimal;import java.util.Currency;import java.util.Objects;import java.util.Optional;public final class PaymentOrder { // Immutable Fields private final String orderId; private final BigDecimal amount; private final Currency currency; private final String description; private final String callbackUrl; private PaymentOrder(Builder builder) { this.orderId = builder.orderId; this.amount = builder.amount; this.currency = builder.currency; this.description = builder.description; this.callbackUrl = builder.callbackUrl; } // Các Bước Ép Buộc (Stages) public interface OrderIdStep { AmountStep orderId(String orderId); } public interface AmountStep { CurrencyStep amount(BigDecimal amount); } public interface CurrencyStep { BuildStep currency(Currency currency); } public interface BuildStep { BuildStep description(String description); BuildStep callbackUrl(String callbackUrl); PaymentOrder build(); } // Entry point duy nhất public static OrderIdStep stagedBuilder() { return new Builder(); } // Builder Implementation ẩn bên trong private static class Builder implements OrderIdStep, AmountStep, CurrencyStep, BuildStep { private String orderId; private BigDecimal amount; private Currency currency; private String description; private String callbackUrl; @Override public AmountStep orderId(String orderId) { this.orderId = Objects.requireNonNull(orderId, "orderId không được null"); return this; } @Override public CurrencyStep amount(BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("amount phải lớn hơn 0"); } this.amount = amount; return this; } @Override public BuildStep currency(Currency currency) { this.currency = Objects.requireNonNull(currency, "currency không được null"); return this; } @Override public BuildStep description(String description) { this.description = description; return this; } @Override public BuildStep callbackUrl(String callbackUrl) { this.callbackUrl = callbackUrl; return this; } @Override public PaymentOrder build() { // Chốt chặn cuối: compile-time chỉ đảm bảo đã gọi đủ bước, // còn ai cố tình ép kiểu (BuildStep) để nhảy cóc thì vẫn bị chặn ở đây Objects.requireNonNull(orderId, "orderId không được null"); Objects.requireNonNull(amount, "amount không được null"); Objects.requireNonNull(currency, "currency không được null"); return new PaymentOrder(this); } } // Getters bảo vệ tính đóng gói public String getOrderId() { return orderId; } public BigDecimal getAmount() { return amount; } public Currency getCurrency() { return currency; } public Optional<String> getDescription() { return Optional.ofNullable(description); } public Optional<String> getCallbackUrl() { return Optional.ofNullable(callbackUrl); }}
# gõ code sướng hơn hẳn
Giả sử dev gõ:
PaymentOrder.stagedBuilder() .orderId("TX-12345") .amount(BigDecimal.valueOf(500000)) // Kiểu lúc này là CurrencyStep: chỉ gọi được .currency(...) // Gọi .build() ở đây là lỗi compile: cannot find symbol
Quên một tham số bắt buộc là bị chặn ngay lúc viết code, không lọt xuống tới test hay production. Cái giá là mỗi trường bắt buộc tốn thêm một interface, và thứ tự gọi bị cố định. Với class có 2-3 trường bắt buộc thì đáng; có cả chục trường bắt buộc thì số interface phình ra khá mệt, lúc đó nên xem lại có tách được object nhỏ hơn không.
Cũng nên nhớ compile-time chỉ đảm bảo dev đã gọi đủ bước, không đảm bảo giá trị hợp lệ: .orderId(null) vẫn compile được, nên các hàm set vẫn phải tự kiểm tra như code ở trên. Thêm nữa, Builder cài đặt cùng lúc mọi interface, nên ai cố tình ép kiểu (PaymentOrder.BuildStep) PaymentOrder.stagedBuilder() là gọi được build() ngay. Vì thế build() vẫn giữ một lớp kiểm tra cuối.
# xử lý collection bất biến (defensive copying)
Lỗi rất hay gặp khi viết Builder là gán thẳng tham chiếu List, Set hoặc Map từ Builder sang object kết quả. Làm vậy thì object "bất biến" và builder dùng chung một collection: builder (hay chính collection mà caller đã truyền vào builder) bị sửa sau build() là object đã tạo đổi theo, mà mình không hay:
// Anti-Pattern: Gán trực tiếp tham chiếuprivate User(Builder builder) { this.roles = builder.roles; // 🔴 User và builder dùng chung một Set: builder.role("SUPERADMIN") sau build() là User cũ cũng có quyền SUPERADMIN!}// Hardened Pattern: Sao chép phòng thủ (Defensive Copy)private User(Builder builder) { // Tạo bản sao bất biến không thể sửa đổi this.roles = builder.roles == null ? Set.of() : Set.copyOf(builder.roles);}
Set.copyOf, List.copyOf, Map.copyOf (Java 10+) trả về collection không sửa được, và nếu đầu vào vốn đã là collection bất biến loại này thì chúng trả lại luôn, không tốn công copy. Có điều chúng ném NullPointerException nếu có phần tử null, nên dữ liệu nào cho phép null bên trong thì phải lọc trước. Còn Collections.unmodifiableSet(...) thì chỉ là một lớp "vỏ" chỉ-đọc bọc lấy collection gốc: gốc đổi thì vỏ đổi theo, nên nó không thay được bản copy.
# chọn bên nào: Lombok @Builder vs Java Records (Java 17+)
Java Records chính thức có từ Java 16 (JEP 395), và Java 17 là bản LTS đầu tiên có nó. Viết class dữ liệu bất biến gọn hơn rất nhiều. Nhưng record chỉ bất biến "nông": field là final, còn nếu component là List thì list đó vẫn sửa được, muốn chặn thì phải List.copyOf(...) trong compact constructor. Record cũng chỉ có một constructor theo vị trí tham số, nên với nhiều trường thì lại quay về bệnh của telescoping constructor. Vì vậy Records và Builder bổ sung cho nhau chứ không thay thế nhau:
# mấy cái bẫy của Lombok @Builder nên biết
Bẫy giá trị mặc định (@Builder.Default): Nếu bạn khai báo giá trị mặc định:
@Builderpublic class HttpClientConfig { @Builder.Default private int timeoutMs = 5000; // BẮT BUỘC PHẢI CÓ @Builder.Default, nếu không Lombok sẽ set về 0!}
Lý do là builder do Lombok sinh ra có field riêng, không biết gì về giá trị khởi tạo = 5000 của class. Không có @Builder.Default thì HttpClientConfig.builder().build() cho timeoutMs = 0 (Lombok có in cảnh báo lúc compile, nhưng warning thì hay bị lướt qua).
Bẫy Jackson deserialize:@Builder sinh ra một constructor đủ tham số, và class không còn constructor rỗng nữa. Muốn Jackson deserialize JSON vào class đó thì phải thêm @Jacksonized (có từ Lombok 1.18.14) hoặc tự cấu hình @JsonDeserialize(builder = ...), không thì Jackson báo lỗi kiểu Cannot construct instance of ... (no Creators, like default constructor, exist).
Bẫy với JPA entity: Lý do tương tự: Hibernate cần constructor không tham số để tạo entity khi đọc từ DB. Dùng @Builder trên entity thì thường phải kèm @NoArgsConstructor(access = AccessLevel.PROTECTED) và @AllArgsConstructor.
# bảng so sánh tổng hợp
Tiêu chí
Telescoping Constructor
JavaBeans (Setters)
Effective Java Builder
Staged Builder
Java Record
Bất biến (Immutability)
🟢 Có
🔴 Không
🟢 Hoàn toàn
🟢 Hoàn toàn
🟢 Mặc định (nông)
An toàn lúc compile
🟡 Kém (dễ nhầm thứ tự)
🔴 Rất kém
🟡 Check lúc runtime
🟢 Ép gọi đủ bước
🟡 Theo vị trí constructor
Dễ đọc (Readability)
🔴 Rất khó đọc
🟢 Tốt
🟢 Rất cao (Fluent)
🟢 Rất cao
🟢 Cao
Kiểm soát trạng thái
Tốt
🔴 Object nửa vời
Tốt
Tốt nhất
Tốt
Lượng code phải viết (Boilerplate)
Trung bình
Thấp
Cao (hoặc dùng Lombok)
Khá cao
Rất ít
# tổng kết
Builder Pattern đâu chỉ để code "dễ nhìn" hơn nhờ gọi nối chuỗi (method chaining). Cái lợi lớn hơn là nó cho phép object bất biến mà vẫn tạo dễ: mọi kiểm tra dồn vào một chỗ trước khi object ra đời, xong rồi thì không ai sửa được, chia sẻ giữa các thread cũng yên tâm (nhớ copy collection).
Staged Builder đi thêm một bước: dùng type system của Java để biến quy tắc "trường này bắt buộc" thành ràng buộc lúc compile, dẹp được cả loạt lỗi runtime vớ vẩn. Đổi lại là thêm interface và thứ tự gọi cố định, nên theo mình chỉ đáng dùng cho những object nghiệp vụ quan trọng (lệnh thanh toán, config hệ thống), còn DTO thông thường thì record hoặc Lombok @Builder là đủ.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!