Как реализовать Builder pattern для иммутабельного класса
Паттерн Строитель (Builder) — это порождающий паттерн проектирования, который разделяет сложный процесс поэтапного конструирования объекта от его результирующего представления.
🟢 Junior Level
30-секундный ответ
Паттерн Строитель (Builder) — это порождающий паттерн проектирования, который разделяет сложный процесс поэтапного конструирования объекта от его результирующего представления.
Для иммутабельных классов Builder решает две главные проблемы:
- Устраняет антипаттерн «телескопических конструкторов» (Telescoping Constructor): когда в классе приходится объявлять 10 конструкторов с разным числом аргументов, в которых легко перепутать параметры одного типа (например, три строки подряд:
name, surname, email). - Соблюдает строгую иммутабельность: сам объект 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 полей достаточно конструкторов или статических фабрик.