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 an Order is through the Builder, so every object has been checked by build().
  • Required parameters go in builder(customerId). You can’t forget customerId because 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 after build(), the already-created Order remains 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:

  • @Builder generates the Customer.builder() method, the value-setting methods, and build().
  • @NonNull generates a null check in the constructor. If email is missing, build() throws a NullPointerException.
  • @Builder.Default preserves the "STANDARD" initialization. If you omit this annotation, Lombok ignores the initial value, and tier becomes null.
  • @Singular generates the tag(...) method to add individual elements. Lombok also automatically makes the list immutable when building.
  • toBuilder = true lets 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 @Builder on 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, or Map.copyOf.
  • Forgetting to call build(). You get back a Builder instead of an Order. The compiler usually catches this because the type doesn’t match, but with generic code or var the 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.
  • @Builder on JPA entities. Hibernate needs a no-argument constructor. @Builder alone 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 like Money.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.

design-patternjavabuilderlombokclean-code