Builder Pattern para objetos con múltiples parámetros en Java
1. Descripción general
Cuando una clase tiene seis, siete parámetros, de los cuales algunos son opcionales, el constructor tradicional rápidamente se vuelve difícil de leer y es fácil pasar valores incorrectos. El patrón Builder resuelve este problema. Nombras claramente cada parámetro, estableces valores por defecto y validas en un único lugar.
El artículo consta de cuatro partes. Primero, el problema de los constructores con múltiples parámetros. Luego, escribirás tu propio Builder completo y lo probarás con JUnit 5. Finalmente, cómo usar Lombok @Builder y cómo combinar Builder con record en Java 21.
2. El problema de los constructores con múltiples parámetros
2.1. Constructor telescópico
Supongamos que tienes una clase Order con campos: customerId, una lista items, shippingAddress, couponCode, giftWrap y priority. El enfoque común es escribir múltiples constructores sobrecargados, cada uno añadiendo un parámetro. Este patrón se llama telescoping constructor:
Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);
Leyendo esta línea, es difícil saber qué significa true y qué significa 0. Peor aún, shippingAddress y couponCode son del mismo tipo String. Si intercambias la posición de estos dos parámetros, el código seguirá compilando sin problemas, y el error solo aparecerá en tiempo de ejecución.
2.2. JavaBeans con setter
La segunda forma es usar un constructor vacío y llamar a setters:
Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// olvidas setItems(...) — el compilador no dice nada
El código es más legible, pero tiene tres desventajas:
- El objeto existe en un estado “intermedio” entre las llamadas a setters.
- No puedes declarar los campos como
final, por lo que el objeto no es inmutable. - No hay un lugar natural para validar restricciones entre campos.
Builder mantiene la legibilidad del enfoque setter y soluciona las tres desventajas anteriores.
3. Implementar Builder manualmente
3.1. Estructura básica
Builder es típicamente una clase estática anidada. Contiene copias de los campos de la clase externa, tiene métodos que retornan a sí mismo (return this) para encadenamiento de llamadas, y un método build() para crear el objeto final:
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class Order {
private final String customerId;
private final List<String> items;
private final String shippingAddress;
private final String couponCode;
private final boolean giftWrap;
private final int priority;
private Order(Builder builder) {
this.customerId = builder.customerId;
this.items = List.copyOf(builder.items);
this.shippingAddress = builder.shippingAddress;
this.couponCode = builder.couponCode;
this.giftWrap = builder.giftWrap;
this.priority = builder.priority;
}
public static Builder builder(String customerId) {
return new Builder(customerId);
}
public String getCustomerId() { return customerId; }
public List<String> getItems() { return items; }
public String getShippingAddress() { return shippingAddress; }
public String getCouponCode() { return couponCode; }
public boolean isGiftWrap() { return giftWrap; }
public int getPriority() { return priority; }
@Override
public String toString() {
return "Order[customerId=%s, items=%s, address=%s, coupon=%s, giftWrap=%s, priority=%d]"
.formatted(customerId, items, shippingAddress, couponCode, giftWrap, priority);
}
public static final class Builder {
private final String customerId;
private final List<String> items = new ArrayList<>();
private String shippingAddress;
private String couponCode;
private boolean giftWrap = false;
private int priority = 0;
private Builder(String customerId) {
this.customerId = Objects.requireNonNull(customerId, "customerId no puede ser null");
}
public Builder item(String sku) {
this.items.add(Objects.requireNonNull(sku));
return this;
}
public Builder shippingAddress(String shippingAddress) {
this.shippingAddress = shippingAddress;
return this;
}
public Builder couponCode(String couponCode) {
this.couponCode = couponCode;
return this;
}
public Builder giftWrap(boolean giftWrap) {
this.giftWrap = giftWrap;
return this;
}
public Builder priority(int priority) {
this.priority = priority;
return this;
}
public Order build() {
if (items.isEmpty()) {
throw new IllegalStateException("Order debe tener al menos un item");
}
if (priority < 0 || priority > 5) {
throw new IllegalStateException("priority debe estar en el rango 0..5");
}
return new Order(this);
}
}
}
Algunos puntos a destacar en el código anterior:
- El constructor de
Orderesprivate. La única forma de crear unOrderes a través de Builder, por lo que cada objeto ha sido validado enbuild(). - Los parámetros obligatorios están en
builder(customerId). No puedes olvidarcustomerId, porque sin él el código no compilará. Incluye solo los parámetros realmente obligatorios, máximo uno o dos. Si hay más, vuelves al problema del telescoping constructor. - Los valores por defecto están en Builder (
giftWrap = false,priority = 0), no dispersos en la llamada. List.copyOf(builder.items)crea una copia inmutable. Si el builder se usa nuevamente después debuild(), elOrderya creado no se verá afectado.- Toda validación está en
build(). Este es el lugar natural para verificar condiciones que involucran múltiples campos a la vez.
3.2. Usar Builder
public class OrderDemo {
public static void main(String[] args) {
Order order = Order.builder("C001")
.item("SKU-1")
.item("SKU-2")
.shippingAddress("12 Lý Thường Kiệt, Hà Nội")
.giftWrap(true)
.build();
System.out.println(order);
}
}
Cada valor viene con su nombre, así que no necesitas recordar el orden de los parámetros. couponCode y priority no se llaman, por lo que reciben valores por defecto:
Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]
Si olvidas llamar a item(...), build() lanza un error inmediatamente:
Exception in thread "main" java.lang.IllegalStateException: Order debe tener al menos un item
El error aparece inmediatamente al crear el objeto, no tres capas más tarde cuando algún servicio encuentra una lista vacía.
4. Probar Builder
Debes probar tres comportamientos: valores por defecto, validación e inmutabilidad del objeto una vez construido. Añade la dependencia de test si el proyecto aún no la tiene. Con Spring Boot, spring-boot-starter-test ya incluye JUnit 5 y AssertJ:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class OrderBuilderTest {
@Test
void givenOnlyRequiredFields_whenBuild_thenDefaultsApplied() {
Order order = Order.builder("C001").item("SKU-1").build();
assertThat(order.isGiftWrap()).isFalse();
assertThat(order.getPriority()).isZero();
assertThat(order.getCouponCode()).isNull();
}
@Test
void givenNoItems_whenBuild_thenThrowsException() {
assertThatThrownBy(() -> Order.builder("C001").build())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("al menos un item");
}
@Test
void givenInvalidPriority_whenBuild_thenThrowsException() {
assertThatThrownBy(() -> Order.builder("C001").item("SKU-1").priority(9).build())
.isInstanceOf(IllegalStateException.class);
}
@Test
void givenBuilderReused_whenAddMoreItems_thenBuiltOrderUnchanged() {
Order.Builder builder = Order.builder("C001").item("SKU-1");
Order first = builder.build();
builder.item("SKU-2");
assertThat(first.getItems()).containsExactly("SKU-1");
assertThatThrownBy(() -> first.getItems().add("SKU-3"))
.isInstanceOf(UnsupportedOperationException.class);
}
}
La última prueba es la más importante. Si reemplazas List.copyOf(...) con this.items = builder.items, first.getItems() contendrá tanto SKU-2 y la prueba fallará. Este es un error muy común al escribir Builder manualmente.
5. Usar Lombok @Builder
Escribir Builder manualmente es bastante verboso. En la práctica, muchos equipos usan Lombok para generar este código. Añade la dependencia de la siguiente manera (el padre de Spring Boot ya gestiona la versión):
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
import lombok.Builder;
import lombok.Getter;
import lombok.NonNull;
import lombok.Singular;
import lombok.ToString;
import java.util.List;
@Getter
@ToString
@Builder(toBuilder = true)
public class Customer {
@NonNull
private final String email;
private final String fullName;
@Builder.Default
private final String tier = "STANDARD";
@Singular
private final List<String> tags;
}
Customer customer = Customer.builder()
.email("lan@example.com")
.fullName("Nguyễn Thị Lan")
.tag("newsletter")
.tag("early-adopter")
.build();
Customer vip = customer.toBuilder().tier("VIP").build();
System.out.println(customer);
System.out.println(vip);
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=STANDARD, tags=[newsletter, early-adopter])
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=VIP, tags=[newsletter, early-adopter])
Cada anotación se encarga de una tarea:
@Buildergenera el métodoCustomer.builder(), métodos para establecer valores ybuild().@NonNullgenera una verificación de null en el constructor. Si faltaemail,build()lanza unaNullPointerException.@Builder.Defaultmantiene el valor inicial"STANDARD". Si falta esta anotación, Lombok ignora el valor inicial, ytierseránull.@Singulargenera el métodotag(...)para añadir elementos individuales. Lombok también crea automáticamente una lista inmutable en build.toBuilder = truepermite crear una copia modificada a partir de un objeto existente. Esto es muy conveniente cuando se trabaja con objetos inmutables.
Nota: Lombok no tiene un lugar fácil para validación de múltiples campos como el
build()escrito manualmente. Si necesitas restricciones complejas, puedes declarar manualmente un constructor con validación y colocar@Builderen ese constructor, o volver a la implementación manual.
Si usas Jackson para deserializar JSON en una clase con @Builder, necesitas añadir @Jacksonized. Sin él, Jackson no sabe cómo crear el objeto porque la clase no tiene un constructor sin parámetros.
6. Builder combinado con record
A partir de Java 16, record es la opción natural para una clase de datos inmutable. Aún puedes añadir Builder cuando un record tiene muchos campos opcionales. La validación se coloca en el compact constructor, por lo que se ejecuta incluso si alguien llama al constructor directamente:
import java.math.BigDecimal;
import java.util.Objects;
public record PaymentRequest(String orderId, BigDecimal amount, String currency, String note) {
public PaymentRequest {
Objects.requireNonNull(orderId, "orderId");
if (amount == null || amount.signum() <= 0) {
throw new IllegalArgumentException("amount debe ser mayor que 0");
}
currency = currency == null ? "VND" : currency;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private String orderId;
private BigDecimal amount;
private String currency;
private String note;
public Builder orderId(String orderId) { this.orderId = orderId; return this; }
public Builder amount(BigDecimal amount) { this.amount = amount; return this; }
public Builder currency(String currency) { this.currency = currency; return this; }
public Builder note(String note) { this.note = note; return this; }
public PaymentRequest build() {
return new PaymentRequest(orderId, amount, currency, note);
}
}
}
PaymentRequest request = PaymentRequest.builder()
.orderId("ORD-42")
.amount(new BigDecimal("150000"))
.build();
System.out.println(request);
PaymentRequest[orderId=ORD-42, amount=150000, currency=VND, note=null]
De esta manera, Builder solo se encarga de la legibilidad, mientras que record se encarga de la inmutabilidad y validación. Las dos responsabilidades están separadas. Lombok @Builder también se puede colocar directamente en record si quieres una solución más concisa.
7. Errores comunes y cuándo no usar Builder
Errores comunes:
- Fuga de colecciones mutables. Asignar directamente la lista del builder al objeto hace que el objeto “inmutable” aún sea modificable. Siempre usa
List.copyOf,Set.copyOfoMap.copyOf. - Olvidar llamar a
build(). Recibes unBuilderen lugar de unOrder. El compilador generalmente detecta este error porque los tipos no coinciden, pero en código genérico o al usarvar, el error puede pasar desapercibido. - Validación dispersa. Verificar en cada setter de builder no detecta restricciones entre campos. Agrupa la validación en
build()o en compact constructor. - Compartir builder entre múltiples hilos. Builder es un objeto mutable y no es thread-safe. Cada vez que crees un objeto, crea un nuevo builder.
@Builderen entidades JPA. Hibernate necesita un constructor sin parámetros.@Buildersolo eliminará ese constructor, por lo que debes añadir@NoArgsConstructor(access = AccessLevel.PROTECTED)y@AllArgsConstructor. En general, debes evitar usar Builder para entidades.
Nota: No uses Builder para clases que solo tienen dos o tres parámetros obligatorios, por ejemplo
Money(amount, currency). Un constructor o un método de fábrica estático comoMoney.of(amount, currency)es más conciso. Builder tampoco es apropiado cuando todos los parámetros son obligatorios, porque traslada el error “parámetro faltante” de tiempo de compilación a tiempo de ejecución.
Regla simple: considera Builder cuando una clase tiene cuatro o más parámetros, la mayoría opcionales, o tiene muchos parámetros del mismo tipo que es fácil confundir.
8. Conclusión
El patrón Builder te ayuda a crear objetos con múltiples parámetros de forma legible, con valores por defecto claros y un único punto de validación. Cuando escribas Builder manualmente, mantén el constructor private, coloca los parámetros obligatorios en el método builder(...), copia las colecciones y valida las restricciones en build().
Lombok @Builder te ayuda a eliminar el código repetitivo, pero recuerda @Builder.Default y @Singular. Con Java 21, combinar record y Builder te da objetos inmutables con validación directamente en el compact constructor. Finalmente, solo usa Builder cuando el número de parámetros realmente causa dificultades. Para clases pequeñas, un constructor o una factory method estática sigue siendo la mejor opción.