Engineering
Glossvation Engineering
アルゴリズム

Builder パターン — 引数 4 つを超えたら迷わず導入する

コンストラクタ引数が多いクラスの可読性を救う Builder。手書き・Lombok・record の併用パターンと、Fluent Builder でよくあるハマり所。

Glossvation ·

Builder パターン概念図

どういう問題を解くパターンか

コンストラクタ引数が多すぎて、呼び出し側で順番を間違える 問題を解きます。

// どの引数が何か分からない
new User("太郎", "山田", 32, "tokyo", "090-1234-5678", true, true, false);

これを Builder で書き直すと:

User user = User.builder()
    .firstName("太郎")
    .lastName("山田")
    .age(32)
    .city("tokyo")
    .phone("090-1234-5678")
    .notificationEnabled(true)
    .marketingOptIn(true)
    .suspended(false)
    .build();

引数の意味がコードに現れる のが Builder の本質的な価値です。 派生して、オプショナルな項目を省略できる・不変オブジェクトを安全に作れる、 といった利点も得られます。

いつ使う / 使わない

使うべき使わないほうがよい
コンストラクタ引数が 4 つ以上引数 1〜3 個 (素のコンストラクタで十分)
オプショナル項目が多い全項目必須で順序も自明
不変オブジェクトを作りたい内部で頻繁に setter 呼ばれる前提
入力の バリデーションを build() で一括 したい1 つずつ検証したい

Joshua Bloch の 「Effective Java」 Item 2 の基準としては 「オプショナル引数が 2 つ以上あれば検討する価値あり」。

実装 (手書き)

典型的な Fluent Builder の形。

public final class User {
    private final String firstName;
    private final String lastName;
    private final int age;
    private final String city;
    private final String phone;
    private final boolean notificationEnabled;

    private User(Builder b) {
        this.firstName = b.firstName;
        this.lastName = b.lastName;
        this.age = b.age;
        this.city = b.city;
        this.phone = b.phone;
        this.notificationEnabled = b.notificationEnabled;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static final class Builder {
        private String firstName;
        private String lastName;
        private int age;
        private String city;
        private String phone;
        private boolean notificationEnabled = false; // デフォルト値

        public Builder firstName(String v) { this.firstName = v; return this; }
        public Builder lastName(String v)  { this.lastName = v;  return this; }
        public Builder age(int v)          { this.age = v;       return this; }
        public Builder city(String v)      { this.city = v;      return this; }
        public Builder phone(String v)     { this.phone = v;     return this; }
        public Builder notificationEnabled(boolean v) {
            this.notificationEnabled = v; return this;
        }

        public User build() {
            // 必須項目の検証はここで一括
            Objects.requireNonNull(firstName, "firstName");
            Objects.requireNonNull(lastName, "lastName");
            if (age < 0) throw new IllegalArgumentException("age must be >= 0");
            return new User(this);
        }
    }
}

ポイント:

  • User のコンストラクタは private。生成経路を Builder に一本化
  • デフォルト値は Builder のフィールドで宣言
  • バリデーションは build() に集約 (1 ヶ所に書く)
  • 全フィールド final で不変化

Director パターン (GoF 本来の形)

GoF 本来の Builder は 「何を作るか」 の順序を Director に外出しします。 API ライブラリより 複数ステップのオブジェクト構築 (DOM/SQL ビルダー等) で見られる形。

public interface SqlBuilder {
    void select(String... columns);
    void from(String table);
    void where(String condition);
    String build();
}

public final class SqlDirector {
    public static String userByCity(SqlBuilder b, String city) {
        b.select("id", "name");
        b.from("users");
        b.where("city = '" + city + "'");
        return b.build();
    }
}

ただし業務コードで Director を切り出すシーンは稀。 多くの場合は Fluent Builder のみ で十分です。

Lombok を使う場合

全フィールドが単純な Fluent Builder なら、Lombok の @Builder でボイラープレートがほぼ消えます。

@Value
@Builder
public class User {
    String firstName;
    String lastName;
    int age;
    String city;
    @Builder.Default boolean notificationEnabled = false;
}

Lombok を避けたほうがいいケース:

  • build() で複雑なバリデーションや副作用が必要 → 手書き or @Builder(toBuilder = true) + カスタマイズ
  • フィールド名を公開 API 名と変えたい → 手書き
  • 新人が多いチームでアノテーションマジックを嫌う → 手書き

record と組み合わせる

Java 16+ の record は Builder と排他ではなく、組み合わせる のが実務的です。

public record User(
    String firstName,
    String lastName,
    int age,
    String city,
    boolean notificationEnabled
) {
    public static Builder builder() { return new Builder(); }

    public static final class Builder {
        private String firstName;
        private String lastName;
        private int age;
        private String city;
        private boolean notificationEnabled = false;

        public Builder firstName(String v) { this.firstName = v; return this; }
        // ... 以下同様
        public User build() {
            return new User(firstName, lastName, age, city, notificationEnabled);
        }
    }
}

record が equals/hashCode/toString を自動生成してくれるぶん、 Builder だけ書けばよくなります。

落とし穴

1. ビルダーのフィールドを引き継ぎしたい (toBuilder)

既存オブジェクトから一部だけ変えて新しいオブジェクトを作りたいケース:

User modified = original.toBuilder().city("osaka").build();

Lombok は @Builder(toBuilder = true)、手書きなら Builder(User src) を実装。 不変オブジェクトの更新パターン として非常に便利なので、よく使うなら最初から入れておきましょう。

2. build() を呼び忘れる

Fluent メソッドチェーンの最後に .build() を忘れると、Builder 型のまま使ってしまい ClassCastException やコンパイルエラー。 Builder クラスを final にしておき、呼び出し側コードレビューで気付けるようにします。

3. Builder を使い回して複数オブジェクトを作る

一つの Builder インスタンスで build() を複数回呼ぶと、状態が共有されて事故ります。 build() 内でフィールドをクリアするか、Builder を 「1 回使い切り」 とドキュメント化を。

4. 可変 Collection をそのまま入れる

public Builder tags(List<String> tags) {
    this.tags = tags; // 外部から変更されうる
    return this;
}

→ this.tags = List.copyOf(tags); にして防衛。


Builder は 「書くコストが高いが、読むコストが劇的に下がる」 パターン。 公開 API ・ドメインの中心にあるオブジェクト・テストで多用するオブジェクト には積極的に導入する価値があります。

関連記事