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
- If the object has two or three fields and no meaningful defaults, a builder is ceremony for no benefit — a constructor or a static factory method is clearer.
- If every field is actually required, a builder just defers the "did I forget something"
problem from compile time (missing constructor argument) to runtime (missing
build()call) — a plain constructor with named-parameter-style factory methods is safer. - Don't reach for a builder to work around a class that's doing too much. If the "optional fields" are really different modes of the same object, separate types usually model the domain better than one object with a dozen toggles.
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
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley. — Chapter 3 formalizes Builder.
- Bloch, J. (2018). Effective Java (3rd ed.), Item 2: "Consider a builder when faced with many constructor parameters." Addison-Wesley. — the exact telescoping-constructor problem this module opens with, and the standard modern-Java argument for reaching for this pattern.
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);
}
}