Category: Creational
O problema
Alguns objetos têm muitos campos, a maioria opcionais, e só alguns obrigatórios. Um construtor
que recebe todos eles fica ilegível no ponto de chamada (new Computer("Ryzen 9", 32, 1024, true, false, "extended-warranty") — qual booleano era qual?), e um que ganha uma sobrecarga nova pra
cada combinação de campos opcionais (o "telescoping constructor") multiplica combinatoriamente
conforme mais opções são adicionadas. Setters em vez de construtor resolvem a legibilidade, mas
deixam o objeto mutável e possivelmente incompleto se quem chama esquecer um campo obrigatório.
A solução
Mover a construção pra um objeto separado que acumula valores campo a campo por uma API fluente
e encadeável, e só produz o objeto real (imutável) na chamada final de build().
classDiagram
class Product {
<<immutable>>
}
class Builder {
+withOptionA(value) Builder
+withOptionB(value) Builder
+build() Product
}
Builder ..> Product : creates
Exemplo clássico
classic/Computer
é o builder fluente clássico dos livros: um cpu obrigatório, três campos opcionais com valores
padrão sensatos (ramGb, storageGb, hasGraphicsCard), e um construtor privado, de modo que a
única forma de obter um Computer é através de Computer.builder(cpu)....build().
ComputerTest
verifica que os padrões se aplicam quando nada mais é definido, que sobrescrever um campo não
afeta os outros, e que um campo obrigatório nulo falha rápido com um NPE em vez de produzir um
objeto incompleto.
Exemplo aplicado: montagem de proposta de financiamento de veículo
applied/AutoLoanProposal
é a mesma estrutura aplicada a uma proposta de financiamento de veículo, do tipo montada no
ponto de venda de um banco: dois campos obrigatórios (solicitante, preço do veículo) e quatro
adicionais opcionais e independentes (número de parcelas, seguro, um veículo usado como
garantia, uma taxa promocional) que não se aplicam a todo negócio. Um construtor comum aqui
forçaria cada ponto de chamada a passar false, false, null, false pros negócios que dispensam
todo adicional — o builder deixa cada ponto de chamada ler exatamente o que solicita, nada mais.
AutoLoanProposalTest
cobre o prazo padrão, todos os adicionais combinados, e as duas falhas de validação (preço não
positivo, número de parcelas não positivo).
Quando não usar
- Se o objeto tem dois ou três campos e nenhum valor padrão significativo, um builder é cerimônia sem benefício — um construtor ou um método de fábrica estático é mais claro.
- Se todo campo é de fato obrigatório, um builder só adia o problema de "esqueci algo" da
compilação (argumento de construtor faltando) pra execução (chamada de
build()faltando) — um construtor comum com métodos de fábrica no estilo de parâmetros nomeados é mais seguro. - Não recorra a um builder pra contornar uma classe que faz coisa demais. Se os "campos opcionais" são na verdade modos diferentes do mesmo objeto, tipos separados costumam modelar o domínio melhor do que um objeto com uma dúzia de chaves liga/desliga.
Cobertura de testes
100% de cobertura de instrução, 100% de cobertura de branch (JaCoCo). Reproduza você mesmo:
./gradlew :creational:builder:jacocoTestReport
Relatório em creational/builder/build/reports/jacoco/test/html/index.html.
Leitura complementar
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — o Capítulo 3 formaliza o Builder.
- Bloch, J. (2018). Effective Java (3ª ed.), Item 2: "Consider a builder when faced with many constructor parameters." Addison-Wesley. — exatamente o problema do telescoping constructor com que este módulo abre, e o argumento padrão do Java moderno pra recorrer a este padrão.
Testes unitários
src/test/java/com/designpatterns/creational/builder/classic/ComputerTest.java
package com.designpatterns.creational.builder.classic;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ComputerTest {
@Test
void appliesSensibleDefaultsWhenOnlyTheRequiredFieldIsSet() {
Computer computer = Computer.builder("Ryzen 7").build();
assertThat(computer.cpu()).isEqualTo("Ryzen 7");
assertThat(computer.ramGb()).isEqualTo(8);
assertThat(computer.storageGb()).isEqualTo(256);
assertThat(computer.hasGraphicsCard()).isFalse();
}
@Test
void overridesOnlyTheFieldsExplicitlySet() {
Computer computer = Computer.builder("Ryzen 9")
.ramGb(32)
.storageGb(1024)
.withGraphicsCard()
.build();
assertThat(computer.ramGb()).isEqualTo(32);
assertThat(computer.storageGb()).isEqualTo(1024);
assertThat(computer.hasGraphicsCard()).isTrue();
}
@Test
void rejectsANullCpu() {
assertThatThrownBy(() -> Computer.builder(null)).isInstanceOf(NullPointerException.class);
}
}
src/test/java/com/designpatterns/creational/builder/applied/AutoLoanProposalTest.java
package com.designpatterns.creational.builder.applied;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class AutoLoanProposalTest {
@Test
void appliesTheDefaultTermWhenNoAddOnsAreRequested() {
AutoLoanProposal proposal = AutoLoanProposal.builder("applicant-1", 80_000_00L).build();
assertThat(proposal.applicantId()).isEqualTo("applicant-1");
assertThat(proposal.vehiclePriceCents()).isEqualTo(80_000_00L);
assertThat(proposal.installments()).isEqualTo(48);
assertThat(proposal.insuranceIncluded()).isFalse();
assertThat(proposal.hasCollateral()).isFalse();
assertThat(proposal.promotionalRate()).isFalse();
}
@Test
void combinesOnlyTheAddOnsExplicitlyRequested() {
AutoLoanProposal proposal = AutoLoanProposal.builder("applicant-2", 120_000_00L)
.installments(60)
.withInsurance()
.withCollateral("ABC1D23")
.promotionalRate()
.build();
assertThat(proposal.installments()).isEqualTo(60);
assertThat(proposal.insuranceIncluded()).isTrue();
assertThat(proposal.hasCollateral()).isTrue();
assertThat(proposal.collateralVehiclePlate()).isEqualTo("ABC1D23");
assertThat(proposal.promotionalRate()).isTrue();
}
@Test
void rejectsANonPositiveVehiclePrice() {
assertThatThrownBy(() -> AutoLoanProposal.builder("applicant-3", 0L))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsANonPositiveInstallmentCount() {
AutoLoanProposal.Builder builder = AutoLoanProposal.builder("applicant-4", 50_000_00L);
assertThatThrownBy(() -> builder.installments(0)).isInstanceOf(IllegalArgumentException.class);
}
}