🧩 Розділ 2 · Питання #8

Коли використовувати 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):

  1. Escape Analysis (Аналіз втечі): JIT-компілятор (HotSpot C2) аналізує область видимості екземпляра Builder. Якщо посилання на Builder не «втікає» за межі методу (не передається в сторонні методи та не зберігається в поля інших об’єктів), компілятор застосовує Scalar Replacement (скалярне заміщення): поля білдера розкладаються безпосередньо в регістри процесора або на стек викликів, і реального виділення пам’яті в купі не відбувається.
  2. Обмеження 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 каверзних запитання з відповідями

  1. Чому Builder безпечніший за сетери з точки зору багатопотоковості?
    • Відповідь: Під час використання сетерів об’єкт публікується в пам’ять незаповненим, і паралельний потік може прочитати посилання до того, як будуть викликані всі сетери, отримавши пошкоджений або неконсистентний стан (Data Race). Builder інкапсулює складання всередині допоміжного класу і повертає цільовий об’єкт лише в методі build(), де всі поля цільового класу оголошені final, що гарантує безпечну публікацію згідно з JMM (JSR-133).
  2. Як працює Step Builder (Staged Builder) і навіщо він потрібен?
    • Відповідь: Step Builder розділяє процес конструювання об’єкта на послідовність викликів строго типізованих інтерфейсів (Step1 -> Step2 -> ... -> OptionalSteps -> build()). Це перетворює валідацію обов’язкових полів із перевірки під час виконання (runtime exception) на сувору гарантію часу компіляції: викликати build() неможливо, доки не будуть заповнені всі обов’язкові параметри.
  3. У яких ситуаціях використання Builder є антипатерном?
    • Відповідь: По-перше, для класів із 1–3 параметрами (надлишкове ускладнення архітектури). По-друге, в екстремальних hot-path ділянках із мільйонним RPS, де створення проміжних об’єктів-будівельників перевантажує Eden/GC, якщо JIT не впорався зі скалярним заміщенням (Escape Analysis). По-третє, якщо поля створеного класу є змінними (відсутній модифікатор final), що зводить нанівець гарантії цілісності.
  4. Як вирішити проблему наслідування класів під час використання Lombok @Builder?
    • Відповідь: Звичайний @Builder не працює з ієрархіями наслідування, оскільки повертає білдер поточного класу і не бачить полів базового класу. Для цього використовується анотація @SuperBuilder над батьківським і дочірніми класами, яка генерує узагальнені (generic) білдери з коректною коваріантністю типів повернення.

Типові помилки та Red Flags

  • 🚩 Використання Builder для класів із 1–2 параметрами — невиправдане роздування коду без практичної користі.
  • 🚩 Відсутність модифікатора final для полів цільового класу — створений білдером об’єкт залишається мутабельним і вразливим до станів гонки.
  • 🚩 Відсутність валідації в методі build() — перетворення Builder на звичайний синтаксичний аналог сетерів без контролю бізнес-правил.
  • 🚩 Ігнорування навантаження на GC у мікросервісах із екстремальним навантаженням (high-throughput) без профілювання алокацій пам’яті.

Пов’язані теми