Builder — object phức tạp không cần constructor 12 tham số
GoF gặp vấn đề với object có quá nhiều optional field. Builder tách quá trình tạo object thành từng bước. Lombok @Builder thì sao?
HMS có class Appointment. Ngoài patientId, doctorId, và scheduledAt là mandatory, còn có notes, referralCode, insuranceClaimId, followUpFromId, priorityLevel, roomPreference… Một số field cần nhau theo từng use case khác nhau.
Mày viết constructor. Sau khi thêm field thứ 5, constructor trông như thế này:
// ❌ Telescoping constructor — kinh điển
Appointment apt = new Appointment(
patientId,
doctorId,
scheduledAt,
null, // notes — không có
null, // referralCode — không có
"INS-789", // insuranceClaimId
null, // followUpFromId
Priority.NORMAL,
null // roomPreference
);
Caller phải đếm position. null thứ ba là gì? Không biết nếu không mở class ra nhìn. Thêm một field mới vào giữa thì mọi chỗ gọi constructor này đều bị break.
GoF gặp bài toán gì
Đây là “telescoping constructor” problem — đặt tên bởi Joshua Bloch trong Effective Java. GoF gọi cái giải pháp của họ là Builder: tách quá trình tạo một object phức tạp thành các bước riêng biệt, cho phép tạo những biểu diễn khác nhau của cùng một loại object từ cùng một process.
Định nghĩa nghe có vẻ phức tạp, nhưng ý tưởng đơn giản: thay vì nhét tất cả vào constructor, mày build từng bước, và cuối cùng gọi .build() để tạo object thật.
Builder thủ công trông thế nào
public class Appointment {
// Fields
private final UUID patientId; // required
private final UUID doctorId; // required
private final LocalDateTime scheduledAt; // required
private final String notes; // optional
private final String referralCode; // optional
private final String insuranceClaimId; // optional
private final Priority priorityLevel; // optional, default NORMAL
// Private constructor — chỉ Builder mới được gọi
private Appointment(Builder builder) {
this.patientId = builder.patientId;
this.doctorId = builder.doctorId;
this.scheduledAt = builder.scheduledAt;
this.notes = builder.notes;
this.referralCode = builder.referralCode;
this.insuranceClaimId = builder.insuranceClaimId;
this.priorityLevel = builder.priorityLevel;
}
// Static nested Builder class
public static class Builder {
// Required fields — set qua constructor của Builder
private final UUID patientId;
private final UUID doctorId;
private final LocalDateTime scheduledAt;
// Optional fields — default value
private String notes;
private String referralCode;
private String insuranceClaimId;
private Priority priorityLevel = Priority.NORMAL;
public Builder(UUID patientId, UUID doctorId, LocalDateTime scheduledAt) {
this.patientId = patientId;
this.doctorId = doctorId;
this.scheduledAt = scheduledAt;
}
public Builder notes(String notes) {
this.notes = notes;
return this;
}
public Builder referralCode(String referralCode) {
this.referralCode = referralCode;
return this;
}
public Builder insuranceClaimId(String id) {
this.insuranceClaimId = id;
return this;
}
public Builder priorityLevel(Priority priority) {
this.priorityLevel = priority;
return this;
}
public Appointment build() {
// Validate required fields hoặc business rule tại đây
Objects.requireNonNull(patientId, "patientId is required");
Objects.requireNonNull(doctorId, "doctorId is required");
Objects.requireNonNull(scheduledAt, "scheduledAt is required");
return new Appointment(this);
}
}
}
Gọi:
// ✅ Rõ ràng từng field
Appointment apt = new Appointment.Builder(patientId, doctorId, scheduledAt)
.insuranceClaimId("INS-789")
.priorityLevel(Priority.HIGH)
.build();
Mày đọc code này mà không cần mở class Appointment vẫn biết đang set gì. Thêm field mới vào Builder không break caller cũ.
Lombok @Builder: 99% trường hợp mày nên dùng cái này
Viết tay Builder cho mọi class là boilerplate không cần thiết. Lombok có @Builder:
@Builder
@Getter
public class Appointment {
// Required fields không có giá trị default
private final UUID patientId;
private final UUID doctorId;
private final LocalDateTime scheduledAt;
// Optional với default
@Builder.Default
private final Priority priorityLevel = Priority.NORMAL;
private final String notes;
private final String referralCode;
private final String insuranceClaimId;
}
Lombok tự generate toàn bộ Builder class. Cú pháp gọi y hệt:
Appointment apt = Appointment.builder()
.patientId(patientId)
.doctorId(doctorId)
.scheduledAt(scheduledAt)
.insuranceClaimId("INS-789")
.priorityLevel(Priority.HIGH)
.build();
@Builder.Default là quan trọng — nếu không có nó, field có default value (Priority.NORMAL) sẽ bị set về null khi dùng Builder, vì Lombok generated code không gọi field initializer.
Một biến thể quan trọng: Builder để tạo Request object
Trong HMS, Builder không chỉ dùng cho entity. Một ứng dụng phổ biến là DTO/Request object được tạo ở nhiều chỗ với nhiều combination khác nhau — đặc biệt trong test:
// Trong test — Builder giúp tạo fixture rõ ràng
@Test
void shouldSendHighPriorityNotification() {
Appointment apt = Appointment.builder()
.patientId(UUID.randomUUID())
.doctorId(UUID.randomUUID())
.scheduledAt(LocalDateTime.now().plusDays(1))
.priorityLevel(Priority.HIGH)
.build();
// ... assert notification được gửi ngay
}
So với việc tạo constructor 9 tham số rồi điền null cho những field không cần trong test — Builder làm test dễ đọc hơn nhiều.
Khi nào Builder quá tay
Builder thêm complexity. Với object đơn giản chỉ có 2–3 field, tất cả required, constructor thường là đủ:
// ✅ Constructor đủ dùng — đơn giản và rõ
public SmsRequest(String recipient, String message) {
this.recipient = recipient;
this.message = message;
}
Builder cho SmsRequest hai field là over-engineering. Nguyên tắc đơn giản: khi mày bắt đầu thấy mình điền null vào constructor, hoặc khi mày tạo nhiều constructor overload, đó là lúc cần Builder.
Cũng đừng dùng Builder khi object cần validation phức tạp giữa nhiều field với nhau. .build() method có thể chứa một số validation, nhưng nếu rule phức tạp, Factory Method hoặc static factory method thường đọc rõ hơn.
Takeaway
Builder giải bài “constructor quá nhiều tham số” và “optional field làm caller phải điền null liên tục”. Lombok @Builder là đủ cho 99% trường hợp trong Spring Boot — không cần viết tay. Nhớ thêm @Builder.Default cho field có giá trị mặc định, hoặc mày sẽ debug NullPointerException không rõ lý do.
Bài tiếp theo: Adapter — khi hai interface không nói chuyện được với nhau