Javaで複数のパラメータを持つオブジェクトのBuilderパターン

1. 概要

クラスが6個、7個のパラメータを持つとき、そのいくつかはオプションです。通常のコンストラクタはすぐに読みにくくなり、パラメータを誤って渡しやすくなります。Builderパターンはこの問題を解決します。各パラメータに明確な名前を付けることで、デフォルト値が設定され、1つの場所でのみ検証が行われます。

この記事は4つの部分から成ります。最初は複数のパラメータを持つコンストラクタの問題です。次に、完全なBuilderを自分で書き、JUnit 5でテストします。最後に、Lombokの@Builderの使い方と、Java 21のrecordとBuilderを組み合わせる方法です。

2. 複数のパラメータを持つコンストラクタの問題

2.1. Telescoping constructor

customerId、アイテムのリスト、shippingAddress、couponCode、giftWrap、priorityというフィールドを持つOrderクラスがあるとします。一般的なアプローチは複数のオーバーロードされたコンストラクタを書くことで、各コンストラクタが1つのパラメータを追加します。このスタイルはtelescoping constructorと呼ばれます:

Order order = new Order("C001", List.of("SKU-1"), null, null, true, 0);

この行を読むと、trueが何で、0が何なのかは理解しづらいです。さらに悪いことに、shippingAddressとcouponCodeは両方ともString型です。この2つのパラメータの位置を交換すれば、コードはコンパイルされますが、エラーは実行時にしか現れません。

2.2. JavaBeansとsetter

2番目のアプローチは、空のコンストラクタを使い、setterを呼ぶことです:

Order order = new Order();
order.setCustomerId("C001");
order.setGiftWrap(true);
// setItems(...) の呼び忘れ — コンパイラは何も報告しない

このコードは読みやすいですが、3つの欠点があります:

  • オブジェクトは各setterの呼び出し間の「半分の状態」に存在します。
  • フィールドをfinalとして宣言できないため、オブジェクトはimmutableではありません。
  • フィールド間の制約をチェックする自然な場所がありません。

Builderはsetterの読みやすさの利点を保ちながら、上記の3つの欠点を解決します。

3. Builderの自作

3.1. 基本的な構造

Builderは通常、staticなネストクラスです。これはクラスの外のフィールドのコピーを持ち、メソッドチェーンのために自身を返す(return this)メソッドと、最終的なオブジェクトを作成するためのbuild()メソッドを持ちます:

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 は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は最低1つのアイテムを持つ必要があります");
            }
            if (priority < 0 || priority > 5) {
                throw new IllegalStateException("priorityは0〜5の範囲内である必要があります");
            }
            return new Order(this);
        }
    }
}

上記のコードで注目すべき点がいくつかあります:

  • Orderのコンストラクタはprivateです。 Orderを作成する唯一の方法はBuilderを通すことです。そのため、すべてのオブジェクトがbuild()でチェックされています。
  • 必須パラメータはbuilder(customerId)にあります。 customerIdを忘れることはできません。なぜなら、それがなければコードはコンパイルされないからです。本当に必須なパラメータだけをここに入れてください。最大1〜2個です。それ以上ある場合は、telescoping コンストラクタの問題に戻ってしまいます。
  • デフォルト値はBuilderの中に置かれています(giftWrap = false、priority = 0)。呼び出し側に分散させられていません。
  • **List.copyOf(builder.items)**は不変のコピーを作成します。build()の後もbuilderを使い続けた場合、すでに作成されたOrderは影響を受けません。
  • すべての制約はbuild()に含まれています。 ここが複数のフィールドに関連する条件をチェックする論理的な場所です。

3.2. Builderの使用

public class OrderDemo {
    public static void main(String[] args) {
        Order order = Order.builder("C001")
                .item("SKU-1")
                .item("SKU-2")
                .shippingAddress("12 Lý Thường Kiệt, Hà Nội")
                .giftWrap(true)
                .build();

        System.out.println(order);
    }
}

各値にはその名前が付いているため、パラメータの順序を覚える必要がありません。couponCodeとpriorityは呼ばれていないため、デフォルト値を取得します:

Order[customerId=C001, items=[SKU-1, SKU-2], address=12 Lý Thường Kiệt, Hà Nội, coupon=null, giftWrap=true, priority=0]

item(...)の呼び出しを忘れた場合、build()は直ちにエラーをスローします:

Exception in thread "main" java.lang.IllegalStateException: Orderは最低1つのアイテムを持つ必要があります

エラーはオブジェクト作成時に直ちに現れ、その後サービスが空のリストに出くわすずっと後ではなく。

4. Builderのテスト

3つの動作をテストすべきです:デフォルト値、検証、そして構築されたオブジェクトの不変性です。プロジェクトにまだテスト依存関係がない場合は追加してください。Spring Bootでは、spring-boot-starter-testには既にJUnit 5と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("最低1つのアイテム");
    }

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

最後のテストが最も重要です。List.copyOf(...)をthis.items = builder.itemsに変更した場合、first.getItems()はSKU-2も含まれ、テストは失敗します。これはBuilderを自分で書く際に非常に一般的なバグです。

5. Lombok @Builderの使用

Builderを手で書くのは非常に冗長です。実際には、多くのチームはLombokを使用してこのコードを生成します。依存関係を以下のように追加します(Spring Boot parentはすでにバージョンを管理しています):

<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("Nguyễn Thị 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=Nguyễn Thị Lan, tier=STANDARD, tags=[newsletter, early-adopter])
Customer(email=lan@example.com, fullName=Nguyễn Thị Lan, tier=VIP, tags=[newsletter, early-adopter])

各annotationは1つの機能を担当しています:

  • @BuilderはCustomer.builder()メソッド、値を設定するメソッド、build()メソッドを生成します。
  • @NonNullはコンストラクタにnullチェック処理を生成します。emailがない場合、build()はNullPointerExceptionをスローします。
  • @Builder.Defaultは初期値"STANDARD"を保持します。このannotationなしに、Lombokは初期値を無視します。そしてtierはnullになります。
  • @Singularは各要素を追加するためのtag(...)メソッドを生成します。Lombokはbuild時にリストを自動的に不変にします。
  • toBuilder = trueは既存のオブジェクトから変更を加えたコピーを作成することを可能にします。このアプローチはimmutableオブジェクトとの作業に非常に便利です。

注意: Lombokには自作のbuild()のような複数フィールドの検証を行うための明確な場所がありません。複雑な制約が必要な場合は、検証ロジックを持つコンストラクタを自分で宣言し、そのコンストラクタに@Builderを配置するか、手書きの方法に戻ることができます。

Jacksonを使用してJSONをクラスにデシリアライズする場合で、そのクラスが@Builderを持つ場合、@Jacksonizedを追加する必要があります。それがないと、Jacksonはそのクラスに空のコンストラクタがないため、オブジェクトの作成方法を知りません。

6. Builderとrecordの組み合わせ

Java 16から、recordはimmutableなデータクラスの自然な選択肢です。recordが多くのオプションフィールドを持つ場合でもBuilderを追加できます。検証はcompact constructorに置かれるため、誰かがコンストラクタを直接呼び出した場合でも実行されます:

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 は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]

このアプローチでは、Builderは読みやすさの部分を担当し、recordは不変性と検証を担当します。2つの責任は明確に分離されています。必要に応じて、Lombok @Builderをrecordの上に直接配置して、よりコンパクトにすることもできます。

7. よくあるエラーと使用しないべき場合

よくあるエラー:

  • 可変なcollectionのリーク。 builderのリストをオブジェクトに直接割り当てると、「immutable」なオブジェクトでも変更可能になります。常にList.copyOf、Set.copyOf、Map.copyOfを使用してください。
  • build()の呼び忘れ。 OrderではなくBuilderを受け取ります。コンパイラは通常このエラーを捕捉します。型が一致しないためです。しかし、ジェネリックコードやvarの使用では、エラーが通る可能性があります。
  • 検証が分散している。 builderの各setterでのチェックは、フィールド間の制約を捕捉できません。build()またはcompact constructorにまとめてください。
  • 複数のスレッド間でbuilderを共有する。 BuilderはmutableオブジェクトでありThread-safeではありません。オブジェクトを作成するたびに、新しいbuilderを作成してください。
  • JPAエンティティに@Builderを使用する。 Hibernateは引数なしのコンストラクタが必要です。@Builderだけではそのコンストラクタが削除され、@NoArgsConstructor(access = AccessLevel.PROTECTED)と@AllArgsConstructorを追加する必要があります。一般的には、エンティティでBuilderの使用を制限する方が良いです。

注意: Money(amount, currency)のように、2つ、3つのパラメータだけが必須のクラスにはBuilderを使用しないでください。コンストラクタまたはMoney.of(amount, currency)のような静的ファクトリメソッドの方がずっとコンパクトです。すべてのパラメータが必須の場合、Builderも適切ではありません。「パラメータが不足しているエラー」をコンパイル時から実行時に変更するだけだからです。

簡潔な経験則:クラスが4つ以上のパラメータを持つ場合、大部分はオプション、または多くの同じ型のパラメータがあり誤って渡されやすい場合、Builderを検討してください。

8. まとめ

Builderパターンは、多くのパラメータを持つオブジェクトを読みやすい方法で、明確なデフォルト値と1つの検証ポイントで作成するのに役立ちます。Builderを自分で書く場合は、コンストラクタをprivateに保ち、必須パラメータをbuilder(...)メソッドに入れ、collectionをコピーし、build()で制約をチェックしてください。

Lombok @Builderはボイラープレートコードを削減するのに役立ちますが、@Builder.Defaultと@Singularを忘れないでください。Java 21では、recordとBuilderを組み合わせることで、compact constructorで直接検証を行う不変なオブジェクトが得られます。最後に、パラメータの数が実際に困難を引き起こす場合にのみBuilderを使用してください。小さなクラスの場合、コンストラクタまたは静的ファクトリメソッドはまだ良い選択肢です。

design-patternjavabuilderlombokclean-code