Коли використовувати Builder
Коли у класу багато полів, розробники часто створюють довгий ланцюжок перевантажених конструкторів. Читати та підтримувати такий код вкрай важко, а переплутати параметри однаков...
🟢 Junior Level
Builder (Будівельник) — це породжувальний патерн проєктування, який дозволяє поетапно створювати складні об’єкти з великою кількістю параметрів, відокремлюючи процес конструювання від внутрішнього представлення об’єкта.
Проблеми, які вирішує Builder
1. «Телескопічний» конструктор (Telescoping Constructor Anti-pattern)
Коли у класу багато полів, розробники часто створюють довгий ланцюжок перевантажених конструкторів. Читати та підтримувати такий код вкрай важко, а переплутати параметри однакового типу (наприклад, кілька String поспіль) дуже легко:
// ❌ Антипатерн: який саме String за що відповідає?
User user = new User("Іван", "Іванов", "ivan@mail.ua", "+380991234567", "Київ", 28, true);
2. Мутабельний антипатерн JavaBeans (сетери)
Створення порожнього об’єкта з наступним викликом сетерів порушує інваріант цілісності:
// ❌ Небезпечно: об'єкт існує в напівзаповненому, невалідному стані!
User user = new User();
user.setFirstName("Іван");
// Якщо просто зараз інший потік прочитає user, він побачить null у lastName та email!
user.setLastName("Іванов");
Рішення: Класичний Builder
public class User {
// Поля final забезпечують незмінність (імутабельність та потокобезпечність)
private final String firstName;
private final String lastName;
private final String email;
private final int age;
private User(Builder builder) {
this.firstName = builder.firstName;
this.lastName = builder.lastName;
this.email = builder.email;
this.age = builder.age;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String firstName;
private String lastName;
private String email;
private int age;
public Builder firstName(String firstName) {
this.firstName = firstName;
return this; // Fluent API
}
public Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public User build() {
// Валідація інваріантів перед безпосереднім створенням цільового об'єкта
if (firstName == null || lastName == null) {
throw new IllegalStateException("Ім'я та прізвище є обов'язковими для заповнення!");
}
return new User(this);
}
}
}
// Використання:
User user = User.builder()
.firstName("Іван")
.lastName("Іванов")
.email("ivan@mail.ua")
.age(28)
.build();
Коли використовувати:
- Конструктор приймає 4 і більше параметрів (особливо однакових типів).
- В об’єкта багато опціональних (необов’язкових) параметрів зі зрозумілими значеннями за замовчуванням.
- Потрібно створити імутабельний (незмінний) об’єкт зі строгою перевіркою взаємопов’язаних інваріантів.
Коли НЕ використовувати:
- У класу всього 1–3 обов’язкових параметри (звичайний конструктор або статичний фабричний метод набагато простіший).
- Прості структури даних (Value Objects, DTO з парою полів).
- Екстремально високонавантажені ділянки коду (hot-path), де створення проміжних об’єктів-білдерів викликає надмірний тиск на збирач сміття (GC pressure).
🟡 Middle Level
Безпечна публікація через final поля (JMM Safe Publication)
Об’єкт, сконструйований через Builder із final-полями, має гарантію безпечної публікації (Safe Publication) відповідно до специфікації Java Memory Model (JSR-133):
- JVM гарантує, що будь-який потік, який отримав посилання на об’єкт
Userпісля завершення конструктораUser(Builder), побачить коректно ініціалізовані значення всіхfinal-полів без потреби в додатковій синхронізації (volatileчиsynchronized). - На відміну від підходу з сетерами (JavaBeans), об’єкт передається клієнтському коду виключно в повністю зібраному та гарантовано валідному вигляді.
Валідація та перевірка крос-польових інваріантів (Cross-Field Invariants)
Builder дозволяє проводити атомарну валідацію взаємопов’язаних полів у методі build():
import java.time.LocalDate;
public class BookingRequest {
private final LocalDate startDate;
private final LocalDate endDate;
private BookingRequest(Builder b) {
this.startDate = b.startDate;
this.endDate = b.endDate;
}
public static class Builder {
private LocalDate startDate;
private LocalDate endDate;
public Builder startDate(LocalDate start) { this.startDate = start; return this; }
public Builder endDate(LocalDate end) { this.endDate = end; return this; }
public BookingRequest build() {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("Дати бронювання не можуть бути null");
}
// Атомарна перевірка взаємопов'язаних полів
if (startDate.isAfter(endDate)) {
throw new IllegalStateException("Дата початку не може бути пізнішою за дату завершення!");
}
return new BookingRequest(this);
}
}
}
Використання бібліотеки Lombok: @Builder та @SuperBuilder
Щоб уникнути написання десятків рядків шаблонного коду (boilerplate), у промисловій розробці часто застосовують Project Lombok:
import lombok.Builder;
import lombok.Getter;
import java.math.BigDecimal;
@Getter
@Builder(toBuilder = true) // toBuilder дозволяє елегантно створювати змінені копії
public class AccountDto {
private final String accountId;
private final String ownerName;
private final BigDecimal balance;
}
// Зміна одного поля незмінного об'єкта (Wither-семантика):
AccountDto updated = original.toBuilder()
.balance(new BigDecimal("1500.00"))
.build();
Наслідування з білдером: @SuperBuilder
При наслідуванні звичайний @Builder не бачить полів батьківського класу. Для вирішення цієї проблеми Lombok надає анотацію @SuperBuilder:
import lombok.experimental.SuperBuilder;
import java.time.Instant;
@SuperBuilder
public abstract class BaseEntity {
private final Long id;
private final Instant createdAt;
}
@SuperBuilder
public class Order extends BaseEntity {
private final String orderNumber;
}
// Доступні поля обох класів зі збереженням коректного типу Builder:
Order order = Order.builder()
.id(42L)
.createdAt(Instant.now())
.orderNumber("ORD-999")
.build();
🔴 Senior Level
Step Builder (Staged Builder) — типобезпечний компіляторний контроль
Класичний Builder не гарантує на етапі компіляції, що розробник передав усі обов’язкові поля (помилка виявляється лише в runtime під час виклику build()).
Патерн Step Builder змушує розробника заповнювати обов’язкові поля строго за ланцюжком за допомогою системи внутрішніх інтерфейсів:
public class EmailMessage {
private final String from;
private final String to;
private final String subject;
private final String body;
private EmailMessage(String from, String to, String subject, String body) {
this.from = from;
this.to = to;
this.subject = subject;
this.body = body;
}
// Кроки конструювання у вигляді інтерфейсів
public interface FromStep { ToStep from(String from); }
public interface ToStep { SubjectStep to(String to); }
public interface SubjectStep { OptionalSteps subject(String subject); }
public interface OptionalSteps {
OptionalSteps body(String body);
EmailMessage build();
}
public static FromStep builder() {
return new StepsExecutor();
}
private static class StepsExecutor implements FromStep, ToStep, SubjectStep, OptionalSteps {
private String from;
private String to;
private String subject;
private String body = "";
public ToStep from(String from) { this.from = from; return this; }
public SubjectStep to(String to) { this.to = to; return this; }
public OptionalSteps subject(String subject) { this.subject = subject; return this; }
public OptionalSteps body(String body) { this.body = body; return this; }
public EmailMessage build() { return new EmailMessage(from, to, subject, body); }
}
}
// Використання: IDE підказує виключно дозволений наступний крок!
EmailMessage msg = EmailMessage.builder()
.from("support@example.ua") // Крок 1: FromStep
.to("client@example.ua") // Крок 2: ToStep
.subject("Ваше замовлення") // Крок 3: SubjectStep
.body("Текст повідомлення") // Опціонально
.build();
// ❌ Не скомпілюється: спроба викликати build() без обов'язкового одержувача
// EmailMessage.builder().from("a@b.ua").build();
Аналіз продуктивності: Навантаження на GC та Escape Analysis
Кожен виклик new Builder() алокує проміжний об’єкт у купі (Young Generation / Eden):
- Escape Analysis (Аналіз втечі):
JIT-компілятор (HotSpot C2) аналізує область видимості екземпляра
Builder. Якщо посилання наBuilderне «втікає» за межі методу (не передається в сторонні методи та не зберігається в поля інших об’єктів), компілятор застосовує Scalar Replacement (скалярне заміщення): поля білдера розкладаються безпосередньо в регістри процесора або на стек викликів, і реального виділення пам’яті в купі не відбувається. - Обмеження Escape Analysis:
Якщо ланцюжок викликів білдера занадто складний, містить цикли або нетривіальні виклики методів, JIT не може довести відсутність втечі посилання. У застосунках із мільйонами запитів на секунду (High-Frequency Trading, обробка фінансової телеметрії) створення мільйонів короткоживучих об’єктів
Builderстворює відчутний тиск на збирач сміття (GC Pressure). У таких hot-path сценаріях надають перевагу прямим конструкторам або пулам об’єктів.
Java Records (Java 16+) проти Builder
З появою record багато сценаріїв створення DTO суттєво спростилися:
recordза замовчуванням є незмінним, компактним і генерує канонічний конструктор.- Проте за наявності великої кількості компонентів (6+) або необхідності значень за замовчуванням
recordтакож суттєво виграє від застосування патерна Builder.
public record UserRecord(String id, String username, String email, String role, boolean active) {
// Компактний конструктор для валідації інваріантів
public UserRecord {
if (id == null || username == null) {
throw new IllegalArgumentException("ID та username є обов'язковими");
}
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String id;
private String username;
private String email;
private String role = "USER"; // Значення за замовчуванням
private boolean active = true;
public Builder id(String id) { this.id = id; return this; }
public Builder username(String u) { this.username = u; return this; }
public Builder email(String e) { this.email = e; return this; }
public Builder role(String r) { this.role = r; return this; }
public Builder active(boolean a) { this.active = a; return this; }
public UserRecord build() {
return new UserRecord(id, username, email, role, active);
}
}
}
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«Патерн Builder використовується для покрокового конструювання складних об’єктів, коли клас містить велику кількість параметрів (від 4 і більше), серед яких багато опціональних. Builder вирішує дві ключові проблеми: усуває антипатерн телескопічного конструктора (нечитабельні ланцюжки перевантажень) та запобігає створенню об’єктів у невалідному проміжному стані, властивому сетерам (JavaBeans). Він гарантує створення повністю валідного, імутабельного об’єкта з потокобезпечною публікацією полів (
final). У ролі просунутого варіанта застосовується Step Builder, який забезпечує контроль передачі обов’язкових полів на етапі компіляції».
3–4 каверзних запитання з відповідями
- Чому Builder безпечніший за сетери з точки зору багатопотоковості?
- Відповідь: Під час використання сетерів об’єкт публікується в пам’ять незаповненим, і паралельний потік може прочитати посилання до того, як будуть викликані всі сетери, отримавши пошкоджений або неконсистентний стан (Data Race). Builder інкапсулює складання всередині допоміжного класу і повертає цільовий об’єкт лише в методі
build(), де всі поля цільового класу оголошеніfinal, що гарантує безпечну публікацію згідно з JMM (JSR-133).
- Відповідь: Під час використання сетерів об’єкт публікується в пам’ять незаповненим, і паралельний потік може прочитати посилання до того, як будуть викликані всі сетери, отримавши пошкоджений або неконсистентний стан (Data Race). Builder інкапсулює складання всередині допоміжного класу і повертає цільовий об’єкт лише в методі
- Як працює Step Builder (Staged Builder) і навіщо він потрібен?
- Відповідь: Step Builder розділяє процес конструювання об’єкта на послідовність викликів строго типізованих інтерфейсів (
Step1 -> Step2 -> ... -> OptionalSteps -> build()). Це перетворює валідацію обов’язкових полів із перевірки під час виконання (runtime exception) на сувору гарантію часу компіляції: викликатиbuild()неможливо, доки не будуть заповнені всі обов’язкові параметри.
- Відповідь: Step Builder розділяє процес конструювання об’єкта на послідовність викликів строго типізованих інтерфейсів (
- У яких ситуаціях використання Builder є антипатерном?
- Відповідь: По-перше, для класів із 1–3 параметрами (надлишкове ускладнення архітектури). По-друге, в екстремальних hot-path ділянках із мільйонним RPS, де створення проміжних об’єктів-будівельників перевантажує Eden/GC, якщо JIT не впорався зі скалярним заміщенням (Escape Analysis). По-третє, якщо поля створеного класу є змінними (відсутній модифікатор
final), що зводить нанівець гарантії цілісності.
- Відповідь: По-перше, для класів із 1–3 параметрами (надлишкове ускладнення архітектури). По-друге, в екстремальних hot-path ділянках із мільйонним RPS, де створення проміжних об’єктів-будівельників перевантажує Eden/GC, якщо JIT не впорався зі скалярним заміщенням (Escape Analysis). По-третє, якщо поля створеного класу є змінними (відсутній модифікатор
- Як вирішити проблему наслідування класів під час використання Lombok
@Builder?- Відповідь: Звичайний
@Builderне працює з ієрархіями наслідування, оскільки повертає білдер поточного класу і не бачить полів базового класу. Для цього використовується анотація@SuperBuilderнад батьківським і дочірніми класами, яка генерує узагальнені (generic) білдери з коректною коваріантністю типів повернення.
- Відповідь: Звичайний
Типові помилки та Red Flags
- 🚩 Використання Builder для класів із 1–2 параметрами — невиправдане роздування коду без практичної користі.
- 🚩 Відсутність модифікатора
finalдля полів цільового класу — створений білдером об’єкт залишається мутабельним і вразливим до станів гонки. - 🚩 Відсутність валідації в методі
build()— перетворення Builder на звичайний синтаксичний аналог сетерів без контролю бізнес-правил. - 🚩 Ігнорування навантаження на GC у мікросервісах із екстремальним навантаженням (high-throughput) без профілювання алокацій пам’яті.
Пов’язані теми
- У чому різниця між Factory Method та Abstract Factory — порівняння породжувальних патернів
- Що таке Prototype pattern — створення об’єктів шляхом клонування
- Що таке Singleton — забезпечення єдиного екземпляра
- Що таке імутабельність — концепція незмінних об’єктів у Java
- Records та Дженерики — синтаксис сучасних незмінних структур даних