Builder Pattern para objetos com muitos parâmetros em Java
1. Visão geral
Quando uma classe tem seis ou sete parâmetros, dos quais alguns são opcionais, o construtor comum rapidamente se torna difícil de ler e fácil de cometer erros. O Builder Pattern resolve esse problema. Você atribui nomes explícitos para cada parâmetro, tem valores padrão claros e valida tudo em um único lugar.
Este artigo passa por quatro partes. Primeiro, o problema do construtor com muitos parâmetros. Depois você implementará um Builder completo e o testará com JUnit 5. Por fim, como usar o Lombok @Builder e como combinar o Builder com record no Java 21.
2. Problema do construtor com muitos parâmetros
2.1. Construtor telescópico
Suponha que você tenha uma classe Order com os campos: customerId, uma lista items, shippingAddress, couponCode, giftWrap e priority. A abordagem comum é escrever múltiplos construtores sobrecarregados, cada construtor adicionando um parâmetro. Esse padrão é chamado de construtor telescópico:
Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);
Ao ler esta linha, é difícil saber o que true significa e o que 0 significa. Pior ainda, shippingAddress e couponCode são do mesmo tipo String. Se você inverter a posição desses dois parâmetros, o código ainda compila normalmente, e o erro só aparece em tempo de execução.
2.2. JavaBeans com setter
A segunda abordagem é usar um construtor vazio e chamar setters:
Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// esqueceu de chamar setItems(...) — o compilador não reclama
O código é mais legível, mas tem três desvantagens:
- O objeto existe em um estado intermediário entre as chamadas de setter.
- Você não pode declarar os campos como
final, então o objeto não é imutável. - Não há um lugar natural para verificar restrições entre os campos.
O Builder mantém as vantagens de legibilidade dos setters e corrige todas as três desvantagens.
3. Implementando o Builder manualmente
3.1. Estrutura básica
O Builder geralmente é uma classe static aninhada. Ele contém uma cópia dos campos da classe externa, tem métodos que retornam a si mesmo (return this) para chamar em cadeia, e um método build() para criar o 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 não pode ser nulo");
}
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 deve ter pelo menos um item");
}
if (priority < 0 || priority > 5) {
throw new IllegalStateException("priority deve estar entre 0 e 5");
}
return new Order(this);
}
}
}
Alguns pontos importantes no código acima:
- O construtor de
Orderéprivate. A única forma de criar umOrderé através do Builder, então cada objeto foi validado embuild(). - Os parâmetros obrigatórios ficam em
builder(customerId). Você não pode esquecercustomerId, pois sem ele o código não compila. Coloque aqui apenas os parâmetros verdadeiramente obrigatórios, no máximo um ou dois. Se for mais, você volta ao problema do construtor telescópico. - Os valores padrão ficam no Builder (
giftWrap = false,priority = 0), não espalhados no local da chamada. List.copyOf(builder.items)cria uma cópia imutável. Se o builder for usado depois debuild(), oOrderjá criado não será afetado.- Todas as restrições ficam em
build(). Este é o local apropriado para verificar condições que envolvem múltiplos campos ao mesmo tempo.
3.2. Usando o 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 vem com seu nome, então você não precisa lembrar a ordem dos parâmetros. couponCode e priority não foram chamados, então recebem os valores padrão:
Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]
Se você esquecer de chamar item(...), build() lança um erro imediatamente:
Exception in thread "main" java.lang.IllegalStateException: Order deve ter pelo menos um item
O erro aparece no momento da criação do objeto, não três camadas depois quando algum serviço encontra uma lista vazia.
4. Testando o Builder
Você deve testar três comportamentos: valores padrão, validação e imutabilidade do objeto criado. Adicione a dependência de teste se o projeto ainda não tiver. Com Spring Boot, spring-boot-starter-test já vem com JUnit 5 e 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("pelo menos um 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);
}
}
O último teste é o mais importante. Se você trocar List.copyOf(...) por this.items = builder.items, first.getItems() conterá SKU-2 e o teste falhará. Este é um erro muito comum ao escrever o Builder manualmente.
5. Usando o Lombok @Builder
Escrever o Builder à mão é bem verboso. Na prática, muitos times usam Lombok para gerar esse código. Adicione a dependência assim (o parent do Spring Boot já gerencia a versão):
<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 anotação tem uma responsabilidade:
@Buildergera o métodoCustomer.builder(), métodos para atribuir valores ebuild().@NonNullgera uma verificação de nulo no construtor. Seemailestiver faltando,build()lançaNullPointerException.@Builder.Defaultmantém o valor inicializado"STANDARD". Se você não usar essa anotação, Lombok ignora o valor inicializado, etierseránull.@Singulargera um métodotag(...)para adicionar um elemento por vez. Lombok também cria automaticamente uma lista imutável ao fazer build.toBuilder = truepermite criar uma cópia com edições a partir de um objeto existente. Isso é muito útil ao trabalhar com objetos imutáveis.
Nota: Lombok não tem um lugar fácil para validação complexa envolvendo múltiplos campos como o
build()manual. Se você precisar de restrições complexas, pode declarar manualmente um construtor com verificação e anotar o Builder nele, ou voltar à implementação manual.
Se estiver usando Jackson para desserializar JSON em uma classe com @Builder, você precisa adicionar @Jacksonized. Sem isso, Jackson não sabe como criar o objeto porque a classe não tem um construtor vazio.
6. Builder combinado com record
A partir do Java 16, record é a escolha natural para data classes imutáveis. Você ainda pode adicionar um Builder quando o record tem muitos campos opcionais. A validação fica no compact constructor, então ela roda mesmo se alguém chamar o construtor diretamente:
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 deve ser maior 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]
Com essa abordagem, o Builder cuida apenas da legibilidade, enquanto o record cuida da imutabilidade e validação. As duas responsabilidades ficam bem separadas. O @Builder do Lombok também pode ser colocado diretamente em um record se você quiser algo mais conciso.
7. Erros comuns e quando não usar
Erros comuns:
- Vazamento de coleções mutáveis. Atribuir diretamente a lista do builder ao objeto faz com que o objeto “imutável” ainda possa ser modificado. Sempre use
List.copyOf,Set.copyOfouMap.copyOf. - Esquecer de chamar
build(). Você recebe umBuilderem vez de umOrder. O compilador geralmente detecta esse erro porque os tipos não batem, mas em código genérico ou ao usarvaro erro pode passar despercebido. - Validação espalhada. Verificar em cada setter do builder não detecta restrições entre campos. Centralize em
build()ou no compact constructor. - Compartilhar o builder entre múltiplas threads. Builder é um objeto mutável e não é thread-safe. Cada vez que criar um objeto, crie um novo builder.
@Builderem uma entidade JPA. Hibernate precisa de um construtor sem parâmetros.@Buildersozinho removerá esse construtor, então você precisa adicionar@NoArgsConstructor(access = AccessLevel.PROTECTED)e@AllArgsConstructor. Em geral, evite usar Builder em entities.
Nota: Não use Builder para uma classe com apenas dois ou três parâmetros obrigatórios, como
Money(amount, currency). Um construtor simples ou um método factory estático comoMoney.of(amount, currency)é mais conciso. O Builder também não é apropriado quando todos os parâmetros são obrigatórios, pois ele transforma o erro “parâmetro faltando” do tempo de compilação para o tempo de execução.
Regra simples: considere usar Builder quando uma classe tem quatro ou mais parâmetros, a maioria dos quais é opcional, ou tem muitos parâmetros do mesmo tipo que são fáceis de confundir.
8. Conclusão
O Builder Pattern ajuda a criar objetos com muitos parâmetros de uma forma legível, com valores padrão claros e com um único ponto de validação. Ao implementar o Builder manualmente, mantenha o construtor private, coloque os parâmetros obrigatórios no método builder(...), copie as coleções e verifique as restrições em build().
O Lombok @Builder ajuda você a eliminar código repetitivo, mas lembre-se de @Builder.Default e @Singular. Com Java 21, combinar record com Builder oferece objetos imutáveis com validação imediatamente no compact constructor. Por fim, use Builder apenas quando o número de parâmetros realmente causar dificuldade. Para classes pequenas, um construtor ou factory method estático ainda é a melhor escolha.