← Todos los patrones

Builder

Creational · ver código fuente en GitHub

Leer en: English · Português · Español

Category: Creational

El problema

Algunos objetos tienen muchos campos, la mayoría opcionales, y solo algunos obligatorios. Un constructor que recibe todos ellos resulta ilegible en el punto de llamada (new Computer("Ryzen 9", 32, 1024, true, false, "extended-warranty") — ¿cuál booleano era cuál?), y uno que gana una sobrecarga nueva por cada combinación de campos opcionales (el "telescoping constructor") se multiplica combinatoriamente a medida que se agregan más opciones. Usar setters en vez de constructor resuelve la legibilidad, pero deja el objeto mutable y posiblemente incompleto si quien lo llama olvida un campo obligatorio.

La solución

Mover la construcción a un objeto separado que acumula valores campo por campo mediante una API fluida y encadenable, y solo produce el objeto real (inmutable) en la llamada final a build().

classDiagram
    class Product {
        <<immutable>>
    }
    class Builder {
        +withOptionA(value) Builder
        +withOptionB(value) Builder
        +build() Product
    }
    Builder ..> Product : creates

Ejemplo clásico

classic/Computer es el builder fluido clásico de los libros: un cpu obligatorio, tres campos opcionales con valores por defecto razonables (ramGb, storageGb, hasGraphicsCard), y un constructor privado, de modo que la única forma de obtener un Computer es mediante Computer.builder(cpu)....build(). ComputerTest verifica que los valores por defecto se aplican cuando no se define nada más, que sobrescribir un campo no afecta a los demás, y que un campo obligatorio nulo falla rápido con un NPE en vez de producir un objeto incompleto.

Ejemplo aplicado: ensamblaje de propuesta de financiamiento vehicular

applied/AutoLoanProposal es la misma estructura aplicada a una propuesta de financiamiento vehicular, del tipo que se ensambla en el punto de venta de un banco: dos campos obligatorios (solicitante, precio del vehículo) y cuatro adicionales opcionales e independientes (número de cuotas, seguro, un vehículo usado como garantía, una tasa promocional) que no aplican a todo negocio. Un constructor común aquí obligaría a cada punto de llamada a pasar false, false, null, false para los negocios que omiten todo adicional — el builder deja que cada punto de llamada exprese exactamente lo que solicita, nada más. AutoLoanProposalTest cubre el plazo por defecto, todos los adicionales combinados, y las dos fallas de validación (precio no positivo, número de cuotas no positivo).

Cuándo no usarlo

Cobertura de pruebas

100% de cobertura de instrucciones, 100% de cobertura de ramas (JaCoCo). Reprodúzcalo usted mismo:

./gradlew :creational:builder:jacocoTestReport

Informe en creational/builder/build/reports/jacoco/test/html/index.html.

Lecturas adicionales

Pruebas unitarias

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);
    }
}

Ver informe completo de cobertura JaCoCo →