🔒 Розділ 13 · Питання #26

Як реалізувати Builder pattern для незмінного класу

Патерн Будівельник (Builder) — це породжувальний патерн проєктування, який відокремлює складний процес поетапного конструювання об'єкта від його результуючого представлення.


🟢 Junior Level

30-секундна відповідь

Патерн Будівельник (Builder) — це породжувальний патерн проєктування, який відокремлює складний процес поетапного конструювання об’єкта від його результуючого представлення.

Для незмінних класів Builder вирішує дві головні проблеми:

  1. Усуває антипатерн «телескопічних конструкторів» (Telescoping Constructor): коли в класі доводиться оголошувати 10 конструкторів із різною кількістю аргументів, у яких легко переплутати параметри одного типу (наприклад, три рядки поспіль: name, surname, email).
  2. Забезпечує сувору незмінність: сам об’єкт Builder є змінним (мутабельним) (накопичує поля через ланцюжок викликів fluent API), але підсумковий метод build() повертає повністю незмінний об’єкт із final-полями та приватним конструктором.

Наочний приклад

// ❌ АНТИПАТЕРН: У якому порядку йдуть boolean-прапорці? Легко помилитися!
User user = new User("John", "Doe", 30, true, false, true, "john@test.com", null);

// ✅ З ПАТЕРНОМ BUILDER: Код самодокументований та читабельний
User user = new User.Builder("John", "Doe") // Обов'язкові поля
    .age(30)                                // Опціональні поля
    .email("john@test.com")
    .emailVerified(true)
    .build();                               // Повернення незмінного User

🟡 Middle Level

Класична реалізація за Джошуа Блохом (Effective Java, Item 2)

import java.util.Objects;

public final class ServerConfig {
    // 1. Усі поля цільового класу строго private final:
    private final String host;
    private final int port;
    private final int timeoutMs;
    private final boolean sslEnabled;

    // 2. Конструктор закритий (private): створення ТІЛЬКИ через Builder
    private ServerConfig(Builder builder) {
        this.host = builder.host;
        this.port = builder.port;
        this.timeoutMs = builder.timeoutMs;
        this.sslEnabled = builder.sslEnabled;
    }

    // 3. Лише гетери, сетери відсутні:
    public String getHost() { return host; }
    public int getPort() { return port; }
    public int getTimeoutMs() { return timeoutMs; }
    public boolean isSslEnabled() { return sslEnabled; }

    // 4. Статичний внутрішній клас-будівельник:
    public static class Builder {
        // Обов'язкові параметри:
        private final String host;
        private final int port;

        // Опціональні параметри з дефолтними значеннями:
        private int timeoutMs = 5000;
        private boolean sslEnabled = false;

        public Builder(String host, int port) {
            this.host = Objects.requireNonNull(host, "Host cannot be null");
            if (port <= 0 || port > 65535) throw new IllegalArgumentException("Invalid port");
            this.port = port;
        }

        public Builder timeoutMs(int timeoutMs) {
            this.timeoutMs = timeoutMs;
            return this; // Повернення this для ланцюжка викликів (Fluent Chaining)
        }

        public Builder sslEnabled(boolean sslEnabled) {
            this.sslEnabled = sslEnabled;
            return this;
        }

        public ServerConfig build() {
            // Місце для перевірки комплексних інваріантів
            return new ServerConfig(this);
        }
    }
}

Метод toBuilder() для еволюції незмінних об’єктів

Щоб легко створити модифіковану копію наявного незмінного об’єкта, додають метод toBuilder():

public Builder toBuilder() {
    return new Builder(this.host, this.port)
        .timeoutMs(this.timeoutMs)
        .sslEnabled(this.sslEnabled);
}

// Використання:
ServerConfig prod = config.toBuilder().port(443).sslEnabled(true).build();

Lombok: @Builder та анотація @Singular

У комерційній розробці шаблонний код Builder часто замінюють анотаціями бібліотеки Lombok:

@Value // final class, private final fields, getters, equals/hashCode/toString
@Builder(toBuilder = true)
public class Order {
    String orderId;
    
    // @Singular генерує методи addItem(item) та захищає колекцію через List.copyOf()
    @Singular 
    List<String> items;
}

🔴 Senior Level

Захист від уразливості повторного використання Builder (Builder Reuse Exploit)

Якщо Builder накопичує змінну колекцію (List<String> items), метод build() зобов’язаний захистити незмінний об’єкт від подальшої мутації будівельника:

// ❌ ВРАЗЛИВА РЕАЛІЗАЦІЯ:
public Order build() {
    return new Order(this.orderId, this.items); // Передано пряме посилання!
}
// Зовнішній код може зробити:
Order order = builder.build();
builder.addItem("Hacked"); // Якщо копії не було, order змінився!
// ✅ АБСОЛЮТНО БЕЗПЕЧНА РЕАЛІЗАЦІЯ:
private Order(Builder builder) {
    this.orderId = builder.orderId;
    // Захисна копія обов'язкова всередині конструктора незмінного об'єкта!
    this.items = List.copyOf(builder.items);
}

Покроковий будівельник (Step Builder Pattern)

Коли об’єкт має кілька обов’язкових полів, які повинні задаватися у суворій послідовності, використовують Step Builder на базі вкладених інтерфейсів:

public class UserRegistration {
    public interface NameStep { EmailStep withName(String name); }
    public interface EmailStep { PasswordStep withEmail(String email); }
    public interface PasswordStep { BuildStep withPassword(String password); }
    public interface BuildStep {
        BuildStep withAge(int age); // Опціональний
        UserRegistration build();
    }

    public static NameStep builder() {
        return new Steps();
    }

    private static class Steps implements NameStep, EmailStep, PasswordStep, BuildStep {
        private String name, email, password;
        private int age;

        public EmailStep withName(String name) { this.name = name; return this; }
        public PasswordStep withEmail(String email) { this.email = email; return this; }
        public BuildStep withPassword(String pwd) { this.password = pwd; return this; }
        public BuildStep withAge(int age) { this.age = age; return this; }
        public UserRegistration build() { return new UserRegistration(this); }
    }
}

Перевага: компілятор на етапі збирання не дозволить викликати .build(), доки не будуть викликані всі обов’язкові методи в строгому порядку!


Builder для Java Records

Хоча record має лаконічний канонічний конструктор, для записів із 8+ компонентами та дефолтними значеннями створення статичного білдера залишається чудовим архітектурним рішенням:

public record UserDTO(String id, String name, String email, int roleLevel, boolean active) {
    public static class Builder {
        private String id;
        private String name;
        private String email = "unknown@mail.com";
        private int roleLevel = 1;
        private boolean active = true;

        public Builder(String id, String name) {
            this.id = id;
            this.name = name;
        }
        // ... fluent setters
        public UserDTO build() {
            return new UserDTO(id, name, email, roleLevel, active);
        }
    }
}

4 Tricky Questions

1. Яка вразливість виникає, якщо метод build() передає внутрішній ArrayList будівельника безпосередньо у приватний конструктор незмінного класу?

Відповідь: Виникає вразливість витоку змінного стану через екземпляр будівельника. Якщо викликаючий код зберіг посилання на об’єкт builder, викликав Order order = builder.build(), а потім продовжив використовувати будівельник (builder.addItem("New Item")), то внутрішній список переданого order зміниться, зруйнувавши інваріант незмінності та потокобезпечності. Щоб клас залишався істинно незмінним, або метод build() повинен передавати List.copyOf(this.items), або приватний конструктор незмінного класу зобов’язаний зробити захисну копію списку.


2. Як патерн Step Builder (Staged Builder) гарантує встановлення всіх обов’язкових полів на етапі компіляції?

Відповідь: Step Builder реалізує ланцюжок окремих інтерфейсів (Step1, Step2, FinalStep), де кожен метод повертає інтерфейс наступного кроку, а метод build() доступний лише в термінальному інтерфейсі FinalStep. IDE та компілятор javac фізично не дозволяють викликати метод наступного кроку або фіналізуючий build(), доки не буде заповнений попередній обов’язковий крок. Це запобігає створенню не повністю ініціалізованого об’єкта без рантайм-винятків.


3. Де має виконуватися валідація комплексних бізнес-інваріантів (Cross-Field Validation): у сетерах білдера, у методі build() чи в конструкторі цільового класу?

Відповідь:

  • Перевірка простих типів (Objects.requireNonNull, межі чисел) може виконуватися одразу у fluent-методах білдера для раннього падіння (Fail-Fast).
  • Комплексна валідація зв’язків між кількома полями (наприклад, if (startDate.after(endDate))) повинна виконуватися всередині приватного конструктора цільового класу після зняття захисних копій. Якщо розмістити перевірку лише в методі build(), залишається ризик, що інший фабричний метод або десеріалізатор обійде її. Конструктор цільового класу — останній рубіж оборони інваріантів.

4. Чи є створення Builder для Java record архітектурним антипатерном?

Відповідь: Ні, це не антипатерн, якщо запис має понад 5–6 компонентів або вимагає дефолтних значень для опціональних полів. Канонічний конструктор рекорда вимагає передачі всіх параметрів у строгому порядку сигнатури. Якщо параметрів багато і серед них є кілька однакових типів (String, boolean), ризик переплутати аргументи при виклику new UserRecord(...) вкрай високий. Зовнішній Builder вирішує цю проблему, забезпечуючи читабельний fluent API та підстановку значень за замовчуванням, делегуючи фінальне створення канонічному конструктору рекорда.


🎯 Шпаргалка для інтерв’ю

Головне про Builder для незмінних класів

  • Призначення: безпечне та читабельне покрокове створення складних об’єктів (4+ полів).
  • Архітектура: змінний static class Builder збирає дані $\to$ приватний конструктор цільового класу заморожує стан у private final поля.
  • Захист даних: обов’язкове захисне копіювання колекцій (List.copyOf()) у конструкторі для захисту від повторного використання білдера.
  • Еволюція: метод toBuilder() дозволяє легко отримувати модифіковані копії.
  • Lombok: @Value + @Builder генерують зв’язку автоматично; @Singular захищає колекції.

Порівняння підходів до створення об’єктів

| Підхід | Читабельність | Захист від помилок в аргументах | Підходить для Immutability? | | :— | :— | :— | :— | | Телескопічні конструктори | 🔴 Жахлива | ❌ Високий ризик переплутати параметри | Так | | JavaBeans (сетери) | 🟡 Добра | ❌ Об’єкт тимчасово невалідний | ❌ Ні (клас мутабельний) | | Патерн Builder | 🟢 Ідеальна | 🟢 Помилки виключені, імена явні | 🟢 Ідеально | | Record (канонічний ctor) | 🟡 Середня при 6+ полях | ❌ Ризик при однакових типах | 🟢 Ідеально |

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «Builder робить незмінний клас змінним» — Builder сам змінний, але цільовий об’єкт залишається суворо незмінним.
  • ❌ «У методі build() можна просто передати внутрішній ArrayList будівельника в конструктор» — відкриває уразливість модифікації об’єкта через будівельник, що залишився.
  • ❌ «Builder потрібен узагалі кожному класу, навіть якщо в ньому лише 2 поля» — надлишкове ускладнення коду; для 1–3 полів достатньо конструкторів або статичних фабрик.

Пов’язані питання