Builder Pattern for Objects with Many Parameters in Java
1. Overview
When a class has six or seven parameters, some of which are optional, the regular constructor quickly becomes hard to read and prone to mistakes. The Builder Pattern solves this problem. You give each parameter a clear name, have default values, and validate in a single place.
This article covers four parts. First, the problems with many-parameter constructors. Next, you’ll write a complete Builder yourself and test it with JUnit 5. Finally, we’ll look at using Lombok’s @Builder and how to combine Builder with record in Java 21.
2. Problems with Constructors That Have Many Parameters
2.1. Telescoping constructor
Suppose you have a class Order with fields: customerId, a list of items, shippingAddress, couponCode, giftWrap, and priority. The common approach is to write multiple overloaded constructors, each one adding a parameter. This pattern is called telescoping constructor:
Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);
Reading this line, it’s hard to tell what true and 0 represent. Worse, shippingAddress and couponCode are both String types. If you swap these two parameters, the code still compiles fine, and the error only shows up at runtime.
2.2. JavaBeans with Setters
The second approach is to use an empty constructor and call setters:
Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// forgot setItems(...) — compiler doesn't complain
The code is more readable, but it has three drawbacks:
- The object exists in a “half-baked” state between setter calls.
- You can’t declare fields as
final, so the object is not immutable. - There’s no natural place to check constraints across multiple fields.
The Builder pattern keeps the readability advantage of setters and fixes all three drawbacks.
3. Implementing Builder Yourself
3.1. Basic Structure
A Builder is typically a static nested class. It holds copies of the outer class’s fields, has methods that return this to enable method chaining, and a build() method to create the final object:
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 must not be null");
}
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 must have at least one item");
}
if (priority < 0 || priority > 5) {
throw new IllegalStateException("priority must be in the range 0..5");
}
return new Order(this);
}
}
}
Some important points in the code above:
- Order’s constructor is
private. The only way to create anOrderis through the Builder, so every object has been checked bybuild(). - Required parameters go in
builder(customerId). You can’t forgetcustomerIdbecause the code won’t compile without it. Only put truly required parameters here—at most one or two. If you have more, you’re back to telescoping. - Default values live in the Builder (
giftWrap = false,priority = 0), not scattered in the calling code. List.copyOf(builder.items)creates an immutable copy. If the builder is reused afterbuild(), the already-createdOrderremains unaffected.- All constraints live in
build(). This is the right place to check conditions involving multiple fields.
3.2. Using the Builder
public class OrderDemo {
public static void main(String[] args) {
Order order = Order.builder("C001")
.item("SKU-1")
.item("SKU-2")
.shippingAddress("12 Ly Thuong Kiet, Hanoi")
.giftWrap(true)
.build();
System.out.println(order);
}
}
Each value comes with its name, so you don’t need to remember parameter order. couponCode and priority aren’t set, so they get their default values:
Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Ly Thuong Kiet, Hanoi, coupon=null, giftWrap=true, priority=0]
If you forget to call item(...), build() throws an error immediately:
Exception in thread "main" java.lang.IllegalStateException: Order must have at least one item
The error appears right when the object is created, not three layers deep when some service encounters an empty list.
4. Testing the Builder
You should test three behaviors: default values, validation, and immutability of the built object. Add test dependencies if your project doesn’t have them yet. With Spring Boot, spring-boot-starter-test already includes JUnit 5 and 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("at least one 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);
}
}
The last test is the most important one. If you replace List.copyOf(...) with this.items = builder.items, first.getItems() will contain SKU-2 and the test will fail. This is a very common mistake when writing a Builder by hand.
5. Using Lombok’s @Builder
Writing a Builder by hand is quite verbose. In practice, many teams use Lombok to generate this code. Add the dependency as follows (Spring Boot parent already manages the version):
<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("Nguyen Thi 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=Nguyen Thi Lan, tier=STANDARD, tags=[newsletter, early-adopter])
Customer(email=lan@example.com, fullName=Nguyen Thi Lan, tier=VIP, tags=[newsletter, early-adopter])
Each annotation handles one responsibility:
@Buildergenerates theCustomer.builder()method, the value-setting methods, andbuild().@NonNullgenerates a null check in the constructor. Ifemailis missing,build()throws aNullPointerException.@Builder.Defaultpreserves the"STANDARD"initialization. If you omit this annotation, Lombok ignores the initial value, andtierbecomesnull.@Singulargenerates thetag(...)method to add individual elements. Lombok also automatically makes the list immutable when building.toBuilder = truelets you create a modified copy from an existing object. This is very handy when working with immutable objects.
Note: Lombok doesn’t have an easy place for multi-field validation like a hand-written
build()method. If you need complex constraints, you can declare your own constructor with checks and put@Builderon it, or go back to hand-writing.
If you use Jackson to deserialize JSON into a class with @Builder, you need to add @Jacksonized. Without it, Jackson doesn’t know how to create the object because the class has no empty constructor.
6. Builder Combined with Records
Since Java 16, record is the natural choice for an immutable data class. You can still add a Builder when a record has many optional fields. Put validation in the compact constructor so it runs even if someone calls the constructor directly:
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 must be greater than 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]
This way, the Builder handles readability, while the record handles immutability and validation. The two concerns are separated. Lombok’s @Builder can also be placed directly on a record if you want something more compact.
7. Common Mistakes and When Not to Use Builder
Common mistakes:
- Mutable collection leaks. Assigning the builder’s list directly to the object means the supposedly “immutable” object can still be modified. Always use
List.copyOf,Set.copyOf, orMap.copyOf. - Forgetting to call
build(). You get back aBuilderinstead of anOrder. The compiler usually catches this because the type doesn’t match, but with generic code orvarthe mistake can slip through. - Validation scattered around. Checking in each builder setter won’t catch constraints across fields. Consolidate checks in
build()or a compact constructor. - Sharing a builder across threads. Builder is a mutable, non-thread-safe object. Create a new builder each time you build an object.
@Builderon JPA entities. Hibernate needs a no-argument constructor.@Builderalone removes it, so you have to add@NoArgsConstructor(access = AccessLevel.PROTECTED)and@AllArgsConstructor. Generally, avoid using Builder for entities.
Note: Don’t use Builder for a class with just two or three required parameters, like
Money(amount, currency). A constructor or static factory method likeMoney.of(amount, currency)is more concise. Builder is also a poor fit when all parameters are required, because it shifts the “missing parameter” error from compile time to runtime.
Brief rule: consider Builder when a class has four or more parameters, most of them optional, or when there are many parameters of the same type that are easy to mix up.
8. Conclusion
The Builder Pattern helps you create objects with many parameters in a readable way, with clear defaults and a single validation point. When writing a Builder yourself, keep the constructor private, put required parameters in the builder(...) method, copy collections, and check constraints in build().
Lombok’s @Builder lets you skip boilerplate, but remember @Builder.Default and @Singular. With Java 21, combining record and Builder gives you an immutable object with validation right in the compact constructor. Finally, only use Builder when the number of parameters really becomes a problem. For small classes, a constructor or static factory is often the better choice.