← All patterns

Builder

Creational · view source on GitHub

Read this in: English · Português · Español

Category: Creational

The problem

Some objects have many fields, most of them optional, and only a few required. A constructor that takes all of them is unreadable at the call site (new Computer("Ryzen 9", 32, 1024, true, false, "extended-warranty") — which boolean was which?), and one that grows a new overload for every combination of optional fields (the "telescoping constructor") multiplies combinatorially as more options are added. Setters instead of a constructor fix readability but leave the object mutable and possibly half-built if a caller forgets a required field.

The solution

Move construction into a separate object that accumulates values field by field through a fluent, chainable API, and only produces the real (immutable) object on the final build() call.

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

Classic example

classic/Computer is the textbook fluent builder: a required cpu, three optional fields with sensible defaults (ramGb, storageGb, hasGraphicsCard), and a private constructor so the only way to get a Computer at all is through Computer.builder(cpu)....build(). ComputerTest checks the defaults apply when nothing else is set, that overriding one field doesn't disturb the others, and that a null required field fails fast with an NPE rather than producing a half-built object.

Applied example: vehicle-financing proposal assembly

applied/AutoLoanProposal is the same shape applied to a vehicle-financing proposal, the kind assembled at a bank's point of sale: two required fields (applicant, vehicle price) and four independent optional add-ons (installment count, insurance, a trade-in as collateral, a promotional rate) that don't all apply to every deal. A plain constructor here would force every call site to pass false, false, null, false for the deals that skip every add-on — the builder lets each call site read as exactly what it requests, nothing more. AutoLoanProposalTest covers the default term, every add-on combined, and the two validation failures (non-positive price, non-positive installment count).

When not to use it

Test coverage

100% instruction coverage, 100% branch coverage (JaCoCo). Reproduce it yourself:

./gradlew :creational:builder:jacocoTestReport

Report at creational/builder/build/reports/jacoco/test/html/index.html.

Further reading

Unit tests

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

View full JaCoCo coverage report →