Builder Pattern cho object nhiều tham số trong Java
1. Tổng quan
Khi một class có sáu, bảy tham số, trong đó vài tham số là tuỳ chọn, constructor thông thường nhanh chóng trở nên khó đọc và dễ truyền nhầm. Builder Pattern giải quyết vấn đề này. Bạn đặt tên rõ ràng cho từng tham số, có giá trị mặc định, và kiểm tra hợp lệ tại một chỗ duy nhất.
Bài viết đi qua bốn phần. Đầu tiên là vấn đề của constructor nhiều tham số. Sau đó bạn sẽ tự viết một Builder hoàn chỉnh và kiểm thử nó bằng JUnit 5. Cuối cùng là cách dùng Lombok @Builder và cách kết hợp Builder với record trong Java 21.
2. Vấn đề của constructor nhiều tham số
2.1. Telescoping constructor
Giả sử bạn có class Order với các trường: customerId, danh sách items, shippingAddress, couponCode, giftWrap và priority. Cách làm phổ biến là viết nhiều constructor nạp chồng, mỗi constructor thêm một tham số. Kiểu này gọi là telescoping constructor:
Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);
Đọc dòng này, bạn khó biết true là gì và 0 là gì. Tệ hơn, shippingAddress và couponCode cùng kiểu String. Nếu đảo vị trí hai tham số này, code vẫn compile bình thường, và lỗi chỉ lộ ra lúc chạy.
2.2. JavaBeans với setter
Cách thứ hai là dùng constructor rỗng và gọi setter:
Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// quên setItems(...) — compiler không báo gì
Code dễ đọc hơn nhưng có ba nhược điểm:
- Object tồn tại ở trạng thái “nửa vời” giữa các lần gọi setter.
- Không thể khai báo các trường là
final, nên object không immutable. - Không có chỗ tự nhiên để kiểm tra ràng buộc giữa các trường.
Builder giữ được ưu điểm dễ đọc của setter và khắc phục cả ba nhược điểm trên.
3. Tự cài đặt Builder
3.1. Cấu trúc cơ bản
Builder thường là một static nested class. Nó chứa bản sao các trường của class ngoài, có các method trả về chính nó (return this) để gọi nối chuỗi, và một method build() để tạo object cuối cùng:
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class Order {
private final String customerId;
private final List<String> items;
private final String shippingAddress;
private final String couponCode;
private final boolean giftWrap;
private final int priority;
private Order(Builder builder) {
this.customerId = builder.customerId;
this.items = List.copyOf(builder.items);
this.shippingAddress = builder.shippingAddress;
this.couponCode = builder.couponCode;
this.giftWrap = builder.giftWrap;
this.priority = builder.priority;
}
public static Builder builder(String customerId) {
return new Builder(customerId);
}
public String getCustomerId() { return customerId; }
public List<String> getItems() { return items; }
public String getShippingAddress() { return shippingAddress; }
public String getCouponCode() { return couponCode; }
public boolean isGiftWrap() { return giftWrap; }
public int getPriority() { return priority; }
@Override
public String toString() {
return "Order[customerId=%s, items=%s, address=%s, coupon=%s, giftWrap=%s, priority=%d]"
.formatted(customerId, items, shippingAddress, couponCode, giftWrap, priority);
}
public static final class Builder {
private final String customerId;
private final List<String> items = new ArrayList<>();
private String shippingAddress;
private String couponCode;
private boolean giftWrap = false;
private int priority = 0;
private Builder(String customerId) {
this.customerId = Objects.requireNonNull(customerId, "customerId không được null");
}
public Builder item(String sku) {
this.items.add(Objects.requireNonNull(sku));
return this;
}
public Builder shippingAddress(String shippingAddress) {
this.shippingAddress = shippingAddress;
return this;
}
public Builder couponCode(String couponCode) {
this.couponCode = couponCode;
return this;
}
public Builder giftWrap(boolean giftWrap) {
this.giftWrap = giftWrap;
return this;
}
public Builder priority(int priority) {
this.priority = priority;
return this;
}
public Order build() {
if (items.isEmpty()) {
throw new IllegalStateException("Order phải có ít nhất một item");
}
if (priority < 0 || priority > 5) {
throw new IllegalStateException("priority phải nằm trong khoảng 0..5");
}
return new Order(this);
}
}
}
Một số điểm đáng chú ý trong đoạn code trên:
- Constructor của
Orderlàprivate. Cách duy nhất để tạoOrderlà đi qua Builder, nên mọi object đều đã được kiểm tra trongbuild(). - Tham số bắt buộc nằm trong
builder(customerId). Bạn không thể quêncustomerId, vì thiếu nó thì code không compile. Chỉ đưa vào đây những tham số thực sự bắt buộc, tối đa một hai cái. Nếu nhiều hơn, bạn lại quay về vấn đề telescoping. - Giá trị mặc định nằm trong Builder (
giftWrap = false,priority = 0), không rải rác ở nơi gọi. List.copyOf(builder.items)tạo một bản sao không sửa được. Nếu builder bị dùng tiếp sau khibuild(),Orderđã tạo vẫn không bị ảnh hưởng.- Mọi ràng buộc nằm trong
build(). Đây là chỗ hợp lý để kiểm tra các điều kiện liên quan đến nhiều trường cùng lúc.
3.2. Sử dụng Builder
public class OrderDemo {
public static void main(String[] args) {
Order order = Order.builder("C001")
.item("SKU-1")
.item("SKU-2")
.shippingAddress("12 Lý Thường Kiệt, Hà Nội")
.giftWrap(true)
.build();
System.out.println(order);
}
}
Mỗi giá trị đi kèm tên của nó, nên bạn không cần nhớ thứ tự tham số. couponCode và priority không được gọi, vì vậy chúng nhận giá trị mặc định:
Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]
Nếu bạn quên gọi item(...), build() ném lỗi ngay:
Exception in thread "main" java.lang.IllegalStateException: Order phải có ít nhất một item
Lỗi xuất hiện ngay lúc tạo object, không phải ba tầng sau khi một service nào đó gặp danh sách rỗng.
4. Kiểm thử Builder
Bạn nên test ba hành vi: giá trị mặc định, validation, và tính bất biến của object đã build. Thêm dependency test nếu project chưa có. Với Spring Boot, spring-boot-starter-test đã kèm sẵn JUnit 5 và AssertJ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrderBuilderTest {
@Test
void givenOnlyRequiredFields_whenBuild_thenDefaultsApplied() {
Order order = Order.builder("C001").item("SKU-1").build();
assertThat(order.isGiftWrap()).isFalse();
assertThat(order.getPriority()).isZero();
assertThat(order.getCouponCode()).isNull();
}
@Test
void givenNoItems_whenBuild_thenThrowsException() {
assertThatThrownBy(() -> Order.builder("C001").build())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("ít nhất một item");
}
@Test
void givenInvalidPriority_whenBuild_thenThrowsException() {
assertThatThrownBy(() -> Order.builder("C001").item("SKU-1").priority(9).build())
.isInstanceOf(IllegalStateException.class);
}
@Test
void givenBuilderReused_whenAddMoreItems_thenBuiltOrderUnchanged() {
Order.Builder builder = Order.builder("C001").item("SKU-1");
Order first = builder.build();
builder.item("SKU-2");
assertThat(first.getItems()).containsExactly("SKU-1");
assertThatThrownBy(() -> first.getItems().add("SKU-3"))
.isInstanceOf(UnsupportedOperationException.class);
}
}
Test cuối cùng là test quan trọng nhất. Nếu bạn thay List.copyOf(...) bằng this.items = builder.items, first.getItems() sẽ chứa cả SKU-2 và test sẽ fail. Đây là lỗi rất phổ biến khi tự viết Builder.
5. Dùng Lombok @Builder
Viết Builder bằng tay khá dài dòng. Trong thực tế, nhiều team dùng Lombok để sinh code này. Thêm dependency như sau (Spring Boot parent đã quản lý version):
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.Singular;
import lombok.ToString;
import java.util.List;
@Getter
@ToString
@Builder(toBuilder = true)
public class Customer {
@NonNull
private final String email;
private final String fullName;
@Builder.Default
private final String tier = "STANDARD";
@Singular
private final List<String> tags;
}
Customer customer = Customer.builder()
.email("lan@example.com")
.fullName("Nguyễn Thị Lan")
.tag("newsletter")
.tag("early-adopter")
.build();
Customer vip = customer.toBuilder().tier("VIP").build();
System.out.println(customer);
System.out.println(vip);
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=STANDARD, tags=[newsletter, early-adopter])
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=VIP, tags=[newsletter, early-adopter])
Mỗi annotation đảm nhận một việc:
@Buildersinh methodCustomer.builder(), các method đặt giá trị vàbuild().@NonNullsinh đoạn kiểm tra null trong constructor. Nếu thiếuemail,build()némNullPointerException.@Builder.Defaultgiữ giá trị khởi tạo"STANDARD". Nếu thiếu annotation này, Lombok bỏ qua giá trị khởi tạo, vàtiersẽ lànull.@Singularsinh methodtag(...)để thêm từng phần tử. Lombok cũng tự tạo list immutable khi build.toBuilder = truecho phép tạo bản sao có chỉnh sửa từ một object sẵn có. Cách này rất tiện khi làm việc với object immutable.
Lưu ý: Lombok không có chỗ dễ dàng cho validation nhiều trường như
build()tự viết. Nếu cần ràng buộc phức tạp, bạn có thể tự khai báo constructor có kiểm tra và đặt@Builderlên constructor đó, hoặc quay lại cách viết thủ công.
Nếu dùng Jackson để deserialize JSON vào class có @Builder, bạn cần thêm @Jacksonized. Thiếu nó, Jackson không biết cách tạo object vì class không có constructor rỗng.
6. Builder kết hợp với record
Từ Java 16, record là lựa chọn tự nhiên cho data class immutable. Bạn vẫn có thể thêm Builder khi record có nhiều trường tuỳ chọn. Validation đặt trong compact constructor, nên nó chạy kể cả khi ai đó gọi constructor trực tiếp:
import java.math.BigDecimal;
import java.util.Objects;
public record PaymentRequest(String orderId, BigDecimal amount, String currency, String note) {
public PaymentRequest {
Objects.requireNonNull(orderId, "orderId");
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException("amount phải lớn hơn 0");
}
currency = currency == null ? "VND" : currency;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private String orderId;
private BigDecimal amount;
private String currency;
private String note;
public Builder orderId(String orderId) { this.orderId = orderId; return this; }
public Builder amount(BigDecimal amount) { this.amount = amount; return this; }
public Builder currency(String currency) { this.currency = currency; return this; }
public Builder note(String note) { this.note = note; return this; }
public PaymentRequest build() {
return new PaymentRequest(orderId, amount, currency, note);
}
}
}
PaymentRequest request = PaymentRequest.builder()
.orderId("ORD-42")
.amount(new BigDecimal("150000"))
.build();
System.out.println(request);
PaymentRequest[orderId=ORD-42, amount=150000, currency=VND, note=null]
Với cách này, Builder chỉ lo phần dễ đọc, còn record lo phần bất biến và kiểm tra hợp lệ. Hai trách nhiệm được tách bạch. Lombok @Builder cũng đặt được trực tiếp lên record nếu bạn muốn gọn hơn.
7. Lỗi hay gặp và khi nào không nên dùng
Các lỗi hay gặp:
- Rò rỉ collection mutable. Gán thẳng list của builder vào object khiến object “immutable” vẫn bị sửa được. Hãy luôn dùng
List.copyOf,Set.copyOfhoặcMap.copyOf. - Quên gọi
build(). Bạn nhận vềBuilderthay vìOrder. Compiler thường bắt được lỗi này vì kiểu không khớp, nhưng trong code generic hoặc khi dùngvarthì lỗi có thể lọt qua. - Validation rải rác. Kiểm tra trong từng setter của builder không bắt được ràng buộc giữa các trường. Hãy gom về
build()hoặc compact constructor. - Dùng chung builder giữa nhiều thread. Builder là object mutable và không thread-safe. Mỗi lần tạo object, hãy tạo một builder mới.
@Buildertrên JPA entity. Hibernate cần constructor không tham số.@Buildermột mình sẽ làm mất constructor đó, vì vậy bạn phải thêm@NoArgsConstructor(access = AccessLevel.PROTECTED)và@AllArgsConstructor. Nhìn chung, nên hạn chế dùng Builder cho entity.
Lưu ý: Đừng dùng Builder cho class chỉ có hai, ba tham số bắt buộc, ví dụ
Money(amount, currency). Constructor hoặc static factory method nhưMoney.of(amount, currency)ngắn gọn hơn. Builder cũng không phù hợp khi mọi tham số đều bắt buộc, vì nó chuyển lỗi “thiếu tham số” từ lúc compile sang lúc chạy.
Quy tắc ngắn gọn: cân nhắc Builder khi class có từ bốn tham số trở lên, phần lớn là tuỳ chọn, hoặc có nhiều tham số cùng kiểu dễ bị truyền nhầm.
8. Kết luận
Builder Pattern giúp tạo object nhiều tham số theo cách dễ đọc, có giá trị mặc định rõ ràng và có một điểm validation duy nhất. Khi tự viết Builder, hãy giữ constructor private, đưa tham số bắt buộc vào method builder(...), sao chép collection, và kiểm tra ràng buộc trong build().
Lombok @Builder giúp bạn bỏ phần code lặp lại, nhưng cần nhớ @Builder.Default và @Singular. Với Java 21, kết hợp record và Builder cho bạn object bất biến kèm validation ngay trong compact constructor. Cuối cùng, chỉ dùng Builder khi số tham số thực sự gây khó khăn. Với class nhỏ, constructor hoặc static factory vẫn là lựa chọn tốt hơn.