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 um Order é através do Builder, então cada objeto foi validado em build().
  • Os parâmetros obrigatórios ficam em builder(customerId). Você não pode esquecer customerId, 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 de build(), o Order já 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:

  • @Builder gera o método Customer.builder(), métodos para atribuir valores e build().
  • @NonNull gera uma verificação de nulo no construtor. Se email estiver faltando, build() lança NullPointerException.
  • @Builder.Default mantém o valor inicializado "STANDARD". Se você não usar essa anotação, Lombok ignora o valor inicializado, e tier será null.
  • @Singular gera um método tag(...) para adicionar um elemento por vez. Lombok também cria automaticamente uma lista imutável ao fazer build.
  • toBuilder = true permite 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.copyOf ou Map.copyOf.
  • Esquecer de chamar build(). Você recebe um Builder em vez de um Order. O compilador geralmente detecta esse erro porque os tipos não batem, mas em código genérico ou ao usar var o 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.
  • @Builder em uma entidade JPA. Hibernate precisa de um construtor sem parâmetros. @Builder sozinho 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 como Money.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.

design-patternjavabuilderlombokclean-code