---
title: "JavaのBuilderパターン実装｜Lombok @Builder・継承・record・Spring Bootでの書き方"
url: "https://www.issoh.co.jp/tech/details/7072/"
published: 2025-06-03
updated: 2026-11-05
categories: ["Java"]
publisher: "株式会社一創"
---

# JavaのBuilderパターン実装｜Lombok @Builder・継承・record・Spring Bootでの書き方

JavaでBuilderパターンを書く方法は、手書きの静的ネストクラス、Lombokの`@Builder`、必須項目を型で強制するStep Builderが中心です。継承するクラスには再帰ジェネリクスの手書きBuilderか`@SuperBuilder`を使い、項目が少なければrecordのコンストラクタで足ります。Spring Bootでは、DTOをBuilderで組む場合と、Bootが注入する`RestClient.Builder`などを使う場合とで注意点が分かれます。

実行例はTemurin JDK 25.0.5+8（LTS）、Lombok 1.18.48、Jackson 3.2.4で検証しました。Spring Boot 4.1.2が管理する版（Jackson 3.1.7）でも出力は同じで、Springの2例はコンパイルのみ確認しています。GoFの4役割の定義やFactoryとの違い、Go言語での書き方は[Builderパターンとは？GoFの4役割とGoのFunctional Optionsとの使い分け](/tech/details/6334/)で扱います。

## まとめ：JavaのBuilder実装の選び方

- 任意項目が多いクラスは、静的ネストクラスのBuilderを手で書くのが基本形です。必須項目はBuilderのコンストラクタで受け、項目間の検証は`build()`に集めます。
- Lombokの`@Builder`は未設定の項目を`null`や`0`のまま通します。必須項目には`@NonNull`を付ければ`build()`の時点で`NullPointerException`になります。コンパイル時に止めたいならStep Builderです。
- `@Builder`と`@NoArgsConstructor`だけを併用するとコンパイルエラーになります。JPAエンティティでは、DBが採番する`id`を外したコンストラクタに`@Builder`を付けます。
- 継承するクラスは、親のBuilderを`Builder<T extends Builder<T>>`にして`self()`で子の型を返します。Lombokなら親子の両方に`@SuperBuilder`です。
- 項目が数個で全部必須ならrecordで足ります。recordの一部を変えたコピーを作る`with`式（JEP 468）は2026年11月時点で未導入です。
- Spring Boot 4でLombokのBuilderをJSONの復元に使う設定は、`@Jacksonized`と`lombok.config`で自動生成できます。Bootが注入する`RestClient.Builder`はprototypeスコープです。

## 静的ネストクラスで書くBuilderの基本形

Effective Java 第3版のItem 2「Consider a builder when faced with many constructor parameters」が示す形です。引数の数違いでコンストラクタを並べる方法（テレスコーピング）は同じ型の引数を取り違えやすく、引数なしコンストラクタとsetterで組み立てる方法は組み立て途中の不完全なオブジェクトが外から見え、不変にもできません。Builderはこの2つの欠点を避けます。

```
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

final class Pizza {
    private final String size;
    private final String crust;
    private final List<String> toppings;

    private Pizza(Builder b) {
        this.size = b.size;
        this.crust = b.crust;
        this.toppings = List.copyOf(b.toppings);
    }

    static Builder builder(String size) {
        return new Builder(size);
    }

    static final class Builder {
        // 必須項目はBuilderのコンストラクタで受ける
        private final String size;
        // 任意項目は既定値を持たせる
        private String crust = "regular";
        private final List<String> toppings = new ArrayList<>();

        private Builder(String size) {
            this.size = Objects.requireNonNull(size, "size");
        }

        Builder crust(String crust) {
            this.crust = crust;
            return this;
        }

        Builder topping(String t) {
            this.toppings.add(t);
            return this;
        }

        Pizza build() {
            if (toppings.size() > 5) {
                throw new IllegalStateException("toppings must be <= 5: " + toppings.size());
            }
            return new Pizza(this);
        }
    }

    List<String> toppings() {
        return toppings;
    }

    @Override
    public String toString() {
        return "Pizza[size=" + size + ", crust=" + crust + ", toppings=" + toppings + "]";
    }
}

public class Main {
    public static void main(String[] args) {
        Pizza p = Pizza.builder("M").crust("thin").topping("basil").topping("cheese").build();
        System.out.println(p);
        try {
            p.toppings().add("ham");
        } catch (UnsupportedOperationException e) {
            System.out.println("immutable: " + e.getClass().getSimpleName());
        }
        try {
            Pizza.builder("L").topping("a").topping("b").topping("c")
                    .topping("d").topping("e").topping("f").build();
        } catch (IllegalStateException e) {
            System.out.println("build() rejected: " + e.getMessage());
        }
        try {
            Pizza.builder(null);
        } catch (NullPointerException e) {
            System.out.println("NPE: " + e.getMessage());
        }
    }
}
```

実行結果です。`build()`での検証と、`List.copyOf`による不変化が効いています。

```
Pizza[size=M, crust=thin, toppings=[basil, cheese]]
immutable: UnsupportedOperationException
build() rejected: toppings must be <= 5: 6
NPE: size
```

| 要素            | 書き方               | 理由                |
| ------------- | ----------------- | ----------------- |
| 必須項目          | Builderのコンストラクタ引数 | 渡し忘れがコンパイルエラーになる  |
| 任意項目          | フィールド初期化子で既定値     | 呼び出し側が省略できる       |
| 対象クラスのコンストラクタ | private           | Builder経由以外の生成を塞ぐ |
| コレクション        | `List.copyOf`でコピー | 要素の追加・削除・置換を禁止    |
| 項目間の整合性       | `build()`で例外      | 検証を1か所に集める        |

## Lombok @Builderの生成内容と落とし穴

クラスに`@Builder`を付けると、Lombokはパッケージプライベートの全引数コンストラクタ、`builder()`メソッド、フィールドごとの設定メソッドと`build()`を持つBuilderクラスを生成します。[Lombokの@Builderの公式ドキュメント](https://projectlombok.org/features/Builder)によると、この全引数コンストラクタが生成されるのは、自分でコンストラクタを書いておらず、`@NoArgsConstructor`などでLombokに作らせてもいない場合だけです。Lombok全体の導入手順は[Lombokとは？読み方・使い方とアノテーション一覧、Maven・Gradleの設定【1.18.48】](/tech/details/3300/)にまとめています。

### @Builder.Default・toBuilder・@Singularの実行結果

```
import java.util.List;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;

@Value
@Builder(toBuilder = true)
class Mail {
    String to;
    String subject;
    @Builder.Default
    int priority = 3;
    @Singular
    List<String> ccs;
}

public class Main {
    public static void main(String[] args) {
        Mail m = Mail.builder().to("a@example.com").subject("hi")
                .cc("b@example.com").cc("c@example.com").build();
        System.out.println(m);
        Mail m2 = m.toBuilder().subject("re: hi").clearCcs().build();
        System.out.println(m2);
        System.out.println("original: " + m.getSubject());
        try {
            m.getCcs().add("x@example.com");
        } catch (UnsupportedOperationException e) {
            System.out.println("ccs is immutable");
        }
        System.out.println(Mail.builder().subject("no recipient").build());
    }
}
```

```
Mail(to=a@example.com, subject=hi, priority=3, ccs=[b@example.com, c@example.com])
Mail(to=a@example.com, subject=re: hi, priority=3, ccs=[])
original: hi
ccs is immutable
Mail(to=null, subject=no recipient, priority=3, ccs=[])
```

3行目のとおり、`toBuilder()`は元のインスタンスの値をコピーした新しいBuilderを返し、元は変わりません。`@Singular`は1件ずつ追加する`cc()`と`clearCcs()`を生成し、出来上がるリストは変更不可です。一方、最終行のように`to`を設定しなくても`build()`は通ります。

### @NonNullによる必須項目の未設定チェック

必須項目の設定漏れを実行時に検出するなら、フィールドに`lombok.NonNull`を付けます。[@NonNullの公式ドキュメント](https://projectlombok.org/features/NonNull)のとおり、Lombokが生成するコンストラクタの先頭にnullチェックが入り、`build()`がそのコンストラクタを呼んだ時点で例外になります。

```
import lombok.Builder;
import lombok.NonNull;
import lombok.Value;

@Value
@Builder
class Mail {
    @NonNull String to;
    String subject;
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Mail.builder().to("a@example.com").build());
        try {
            Mail.builder().subject("no recipient").build();
        } catch (NullPointerException e) {
            System.out.println("NPE: " + e.getMessage());
        }
    }
}
```

```
Mail(to=a@example.com, subject=null)
NPE: to is marked non-null but is null
```

検出は実行時で、コンパイルは通ります。空文字や範囲の検証までは行わないので、それらは自分で書いたコンストラクタかrecordのコンパクトコンストラクタに置きます。設定漏れをコンパイルエラーにしたい場合は後述のStep Builderを使います。

### コンストラクタへの@Builder指定による設定項目の制限

`@Builder`はクラスだけでなく、コンストラクタやstaticメソッドにも付けられます。コンストラクタに付けると、その引数だけがBuilderの設定項目になります。DBが採番する`id`のように、呼び出し側に設定させたくないフィールドを外せます。

```
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
    private Long id;
    private String status;
    private String customer;

    @Builder(builderMethodName = "newOrder", setterPrefix = "with")
    private Order(String customer) {
        this.customer = customer;
        this.status = "DRAFT";
    }

    public static void main(String[] args) {
        Order o = Order.newOrder().withCustomer("c-001").build();
        System.out.println(o.getId() + " " + o.getStatus() + " " + o.getCustomer());
    }
}
```

```
null DRAFT c-001
```

別のクラスから`Order.newOrder().withId(1L)`と書くと、`cannot find symbol: method withId(long)`でコンパイルが止まります。主な属性は次のとおりです。`setterPrefix`はLombok 1.18.12（2020年2月）で追加された属性で、公式は使用を勧めていません。既存のライブラリが`with`や`set`の接頭辞を要求する場合に限って使います。

| 属性                  | 既定値         | 変わるもの                   |
| ------------------- | ----------- | ----------------------- |
| `builderMethodName` | `builder`   | Builderを返すstaticメソッド名   |
| `buildMethodName`   | `build`     | 完成品を返すメソッド名             |
| `builderClassName`  | `型名Builder` | 生成されるBuilderクラス名        |
| `toBuilder`         | `false`     | インスタンスから値を写したBuilderを作る |
| `access`            | `PUBLIC`    | Builderクラスと生成メソッドの公開範囲  |
| `setterPrefix`      | なし          | 設定メソッドの接頭辞              |

### @Builderと@NoArgsConstructorの併用エラーと解消策

JPAエンティティは引数なしコンストラクタが必須なので、クラスに`@NoArgsConstructor`と`@Builder`を並べがちです。この2つだけだと、Lombokが全引数コンストラクタを作らないため、生成されたBuilderの`build()`が呼ぶコンストラクタが存在しません。

```
import lombok.Builder;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Builder
public class Order {
    private Long id;
    private String status;
}
```

```
Order.java:5: error: constructor Order in class Order cannot be applied to given types;
@Builder
^
  required: no arguments
  found:    Long,String
  reason: actual and formal argument lists differ in length
1 error
```

上のようにコンストラクタへ`@Builder`を移すか、全引数コンストラクタをprivateで足すと解消します。後者ではJPAの引数なしコンストラクタはprotectedで足ります。`@Builder.Default`の値は、Lombokが生成した`@NoArgsConstructor`経由の`new`でも入ります。

```
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class Order {
    private Long id;
    @Builder.Default
    private String status = "DRAFT";

    public static void main(String[] args) {
        System.out.println("builder: " + Order.builder().id(1L).build().getStatus());
        System.out.println("new    : " + new Order().getStatus());
    }
}
```

```
builder: DRAFT
new    : DRAFT
```

ただし公式ドキュメントは、自分で書いたコンストラクタでは`@Builder.Default`の値が使われず手で設定する必要があると注意しています。エンティティとDTOの役割の分け方は[エンティティクラスとは？JPAとEF Coreの書き方・DTOとの違いを実行結果で解説](/tech/details/3948/)を参照してください。

### @Builder.Defaultの付け忘れと非finalフィールドの既定値

非finalフィールドでは、`@Builder.Default`がないとBuilder経由の生成で初期化子が使われません。コンパイル時に警告は出ますが、エラーにはならず`null`のまま動きます。初期化済みのfinalフィールドはBuilderの設定対象から外れ、初期化子の値を保持します。

```
import lombok.Builder;

@Builder
public class Draft {
    private String status = "DRAFT";

    public static void main(String[] args) {
        System.out.println("status=" + Draft.builder().build().status);
    }
}
```

```
Draft.java:5: warning: @Builder will ignore the initializing expression entirely. If you want the initializing expression to serve as default, add @Builder.Default. If it is not supposed to be settable during building, make the field final.
    private String status = "DRAFT";
                   ^
1 warning
status=null
```

CIでこの警告を見落とさないよう、`-Werror`を付けるか、ビルドログの`@Builder will ignore`を検出する設定にしておくと安全です。なお、JDK 25でLombokを使うとコンパイル時に`sun.misc.Unsafe::objectFieldOffset`の警告も出ます（この記事の各実行でも出力されました）が、コンパイルと生成物の動作には影響しませんでした。

## 継承するクラスのBuilder：再帰ジェネリクスと@SuperBuilder

親クラスのBuilderに設定メソッドを置くと、その戻り値は親のBuilder型になり、続けて子クラス固有の設定メソッドを呼べません。親のBuilderを`Builder`型だけで書いた場合、`new Calzone.Builder().addTopping("HAM").sauceInside()`は`cannot find symbol: method sauceInside()`でコンパイルが止まります。

Effective Javaの[Item 2の公式サンプルコード](https://github.com/jbloch/effective-java-3e-source-code/blob/master/src/effectivejava/chapter2/item2/hierarchicalbuilder/Pizza.java)は、親のBuilderを`Builder<T extends Builder<T>>`とし、子に`this`を返させる`self()`で解決しています。次は同じ形を縮めた例です。

```
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;

abstract class Pizza {
    enum Topping { HAM, MUSHROOM, ONION }

    final Set<Topping> toppings;

    abstract static class Builder<T extends Builder<T>> {
        private final EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);

        T addTopping(Topping t) {
            toppings.add(Objects.requireNonNull(t));
            return self();
        }

        abstract Pizza build();

        // サブクラスで this を返す
        protected abstract T self();
    }

    Pizza(Builder<?> b) {
        this.toppings = Set.copyOf(b.toppings);
    }
}

final class Calzone extends Pizza {
    private final boolean sauceInside;

    static final class Builder extends Pizza.Builder<Builder> {
        private boolean sauceInside = false;

        Builder sauceInside() {
            this.sauceInside = true;
            return this;
        }

        @Override
        Calzone build() {
            return new Calzone(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }

    private Calzone(Builder b) {
        super(b);
        this.sauceInside = b.sauceInside;
    }

    @Override
    public String toString() {
        return "Calzone[toppings=" + toppings + ", sauceInside=" + sauceInside + "]";
    }
}

public class Main {
    public static void main(String[] args) {
        Calzone c = new Calzone.Builder()
                .addTopping(Pizza.Topping.HAM)
                .sauceInside()
                .build();
        System.out.println(c);
    }
}
```

```
Calzone[toppings=[HAM], sauceInside=true]
```

`addTopping`が`T`（ここでは`Calzone.Builder`）を返すので、子の`sauceInside()`へつなげられます。`build()`も共変戻り値で`Calzone`を返すため、キャストは要りません。

Lombokで同じことをするのが`@SuperBuilder`です。クラスに付けた`@Builder`は親クラスのフィールドを取り込まないため、継承階層では`@SuperBuilder`を親子の両方に付けます（[@SuperBuilderの公式ドキュメント](https://projectlombok.org/features/SuperBuilder)は全スーパークラスへの付与を必須としています）。`@SuperBuilder`はLombok 1.18.2で実験機能として入り、1.18.48（2026年9月1日）で`lombok.experimental`から`lombok`パッケージへ昇格しました。

```
import lombok.Getter;
import lombok.SuperBuilder;

@Getter
@SuperBuilder
class BaseEvent {
    private final String id;
}

@Getter
@SuperBuilder
class PaymentEvent extends BaseEvent {
    private final long amount;
}

public class Main {
    public static void main(String[] args) {
        PaymentEvent e = PaymentEvent.builder().id("evt-1").amount(1200L).build();
        System.out.println(e.getId() + " " + e.getAmount());
    }
}
```

```
evt-1 1200
```

Lombokを入れていないライブラリや、Builderの型を公開APIとして固定したい場合は手書き、アプリケーション内のDTOやイベントなら`@SuperBuilder`で足ります。

## recordとBuilderの使い分け

項目がすべて必須で数も少ないなら、recordの正規コンストラクタで十分です。名前付き引数のないJavaでは、同じ型の項目が4つ、5つと並ぶと引数の取り違えが起きやすくなるので、その段階でBuilderを検討します。recordの基本は[Javaのレコードクラス（record）とは？使い方・コンストラクタ・継承できない理由とエラー例をJava 25で実測](/tech/details/3254/)で解説しています。

Lombokは1.18.20（2021年4月）でrecordに対応しており、recordにも`@Builder`を付けられます。生成されたBuilderは最後に正規コンストラクタを呼ぶので、コンパクトコンストラクタに書いた検証がBuilder経由でも効きます。

```
import lombok.Builder;

@Builder
record Range(int from, int to) {
    Range {
        if (to < from) {
            throw new IllegalArgumentException("to < from: " + from + " > " + to);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Range.builder().from(1).to(5).build());
        System.out.println(Range.builder().to(5).build());
        try {
            Range.builder().from(1).build();
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}
```

```
Range[from=1, to=5]
Range[from=0, to=5]
rejected: to < from: 1 > 0
```

2行目のように、設定しなかった`int`は`0`で通ります。`to`を省いた3つ目の呼び出しは、`to`が`0`になったことでコンパクトコンストラクタの検証に掛かっています。省略を検出したい項目は、`Integer`にして`null`を検証するのが確実です。

recordの一部だけを変えたコピーを作る`with`式は、[JEP 468（Derived Record Creation）](https://openjdk.org/jeps/468)として提案されています。ただしステータスはCandidateのまま対象JDKが決まっておらず、2026年9月15日にGAとなったJDK 27にも入っていません。現時点で値を一部変えたコピーが必要なら、`@Builder(toBuilder = true)`か、変更する項目だけを受け取るメソッドをrecordに手で書きます。

| 状況                 | 選ぶ書き方                      |
| ------------------ | -------------------------- |
| 項目が3つ程度で全部必須       | recordのコンストラクタ             |
| 任意項目が多く既定値がある      | 手書きBuilderか@Builder        |
| 必須項目のnullを実行時に弾きたい | @Builder＋@NonNull          |
| 必須項目の漏れを型で防ぎたい     | Step Builder               |
| 値を一部だけ変えたコピーが要る    | @Builder(toBuilder = true) |
| 継承階層がある            | 再帰ジェネリクスか@SuperBuilder     |

## 必須項目をコンパイル時に強制するStep Builder

Step Builderは、設定の順番ごとにインターフェースを分け、各メソッドが次の段階の型だけを返すようにした書き方です。必須項目をすべて設定し終えた段階の型にだけ`build()`を置くので、設定漏れがコンパイルエラーになります。ただし、保証するのは設定メソッドを呼ぶことまでです。この例ではnullや空文字も渡せるため、値の妥当性はコンストラクタなどで別途検証します。

```
final class Mail {
    private final String to;
    private final String subject;
    private final String body;

    private Mail(String to, String subject, String body) {
        this.to = to;
        this.subject = subject;
        this.body = body;
    }

    interface ToStep {
        SubjectStep to(String to);
    }

    interface SubjectStep {
        OptionalStep subject(String subject);
    }

    interface OptionalStep {
        OptionalStep body(String body);

        Mail build();
    }

    static ToStep builder() {
        return new Steps();
    }

    private static final class Steps implements ToStep, SubjectStep, OptionalStep {
        private String to;
        private String subject;
        private String body = "";

        public SubjectStep to(String to) {
            this.to = to;
            return this;
        }

        public OptionalStep subject(String subject) {
            this.subject = subject;
            return this;
        }

        public OptionalStep body(String body) {
            this.body = body;
            return this;
        }

        public Mail build() {
            return new Mail(to, subject, body);
        }
    }

    @Override
    public String toString() {
        return "Mail[to=" + to + ", subject=" + subject + ", body=" + body + "]";
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(Mail.builder().to("a@example.com").subject("hi").build());
        System.out.println(Mail.builder().to("a@example.com").subject("hi").body("hello").build());
    }
}
```

```
Mail[to=a@example.com, subject=hi, body=]
Mail[to=a@example.com, subject=hi, body=hello]
```

`subject`を飛ばして`build()`を呼ぶと、`SubjectStep`に`build()`が無いためコンパイルが通りません。

```
public class Ng {
    public static void main(String[] args) {
        Mail.builder().to("a@example.com").build();
    }
}
```

```
Ng.java:3: error: cannot find symbol
        Mail.builder().to("a@example.com").build();
                                          ^
  symbol:   method build()
  location: interface SubjectStep
1 error
```

代償は、必須項目1つにつきインターフェースが1つ増えることと、Lombokでは生成できないことです。公開ライブラリのAPIのように呼び出し側を制御できない場面では有効ですが、社内の数か所から呼ぶだけのクラスなら、`@NonNull`か`build()`での検証とテストで足ります。

## GoF型BuilderのJava実装：Directorによる出力形式の切り替え

ここまでの形はEffective Java型で、目的は引数の多いコンストラクタの置き換えです。GoFのデザインパターンとしてのBuilderは目的が違い、同じ組み立て手順（Director）から別の表現（ConcreteBuilder）を作り分けます。次の例では、1つの`ReportDirector`がMarkdownとCSVの2形式を出力します。CSV側は固定文字列用の簡略例です。任意の文字列を渡す場合は、カンマ・改行・二重引用符を含むフィールドの引用処理も必要です。

```
import java.util.List;

interface ReportBuilder {
    void title(String text);

    void item(String text);

    String result();
}

final class MarkdownReportBuilder implements ReportBuilder {
    private final StringBuilder sb = new StringBuilder();

    public void title(String text) {
        sb.append("# ").append(text).append('\n');
    }

    public void item(String text) {
        sb.append("- ").append(text).append('\n');
    }

    public String result() {
        return sb.toString();
    }
}

final class CsvReportBuilder implements ReportBuilder {
    private final StringBuilder sb = new StringBuilder();

    public void title(String text) {
        sb.append("title,").append(text).append('\n');
    }

    public void item(String text) {
        sb.append("item,").append(text).append('\n');
    }

    public String result() {
        return sb.toString();
    }
}

final class ReportDirector {
    private final List<String> items;

    ReportDirector(List<String> items) {
        this.items = List.copyOf(items);
    }

    // 組み立ての順番だけを知っている
    void construct(ReportBuilder b) {
        b.title("Monthly report");
        items.forEach(b::item);
    }
}

public class Main {
    public static void main(String[] args) {
        ReportDirector director = new ReportDirector(List.of("sales 120", "cost 80"));
        for (ReportBuilder b : List.of(new MarkdownReportBuilder(), new CsvReportBuilder())) {
            director.construct(b);
            System.out.print(b.result());
        }
    }
}
```

```
# Monthly report
- sales 120
- cost 80
title,Monthly report
item,sales 120
item,cost 80
```

引数の多いコンストラクタを置き換える目的なら、Effective Java型か`@Builder`で済みます。GoF型を選ぶのは、帳票やドキュメントを複数の形式で出し分けるなど、同じ手順で別の成果物を作る要件があるときだけです。生成するクラスの選択自体を隠したいならFactoryの領分で、Javaでの書き方は[ファクトリパターンとは？Factory Method・Abstract Factoryの違いとJava・Python・Spring Boot実装例](/tech/details/3658/)にあります。

## Spring BootでのBuilderパターンの使い方

Spring BootでBuilderを使う場面は、自分のDTOをBuilderで組む場合と、Spring Bootが用意するBuilderを使う場合に分かれます。

### Spring Boot 4のDTO：@Jacksonizedとlombok.configによるJSON復元設定

`@Value`と`@Builder`だけでは、掲載例の設定でJSONオブジェクトを復元できません。Jacksonは引数付きコンストラクタでも復元できるため、引数なしコンストラクタやsetterの有無だけで可否が決まるわけではありません。[@Jacksonized](https://projectlombok.org/features/experimental/Jacksonized)を足すと、Lombokが生成したBuilderをJacksonのデシリアライズに使う設定（`@JsonDeserialize(builder = ...)`と`@JsonPOJOBuilder(withPrefix = "")`）を自動で付けます。

Spring Boot 4はJackson 3を標準にしており、主要パッケージは`com.fasterxml.jackson`から`tools.jackson`へ移りました。ただし、jackson-annotationsの`com.fasterxml.jackson.annotation`は変更されていません。Lombokは1.18.44から両方の版に対応していますが、どちらの注釈を生成するかを`lombok.config`で指定しないと、既定でJackson 2用の注釈を生成します。Jackson 3だけの環境では、次のようにコンパイルエラーになりました。

```
Main.java:8: warning: Ambiguous: Jackson2 and Jackson3 exist; define which variant(s) you want in 'lombok.config'. See https://projectlombok.org/features/experimental/Jacksonized
@Jacksonized
^
Main.java:8: error: package com.fasterxml.jackson.databind.annotation does not exist
```

プロジェクトのルートに`lombok.config`を置き、Jackson 3を指定します。Jackson 2から移行中で両方必要なら、この行を2と3で2行書きます。

```
lombok.jacksonized.jacksonVersion += 3
```

```
import lombok.Builder;
import lombok.Value;
import lombok.extern.jackson.Jacksonized;
import tools.jackson.databind.json.JsonMapper;

@Value
@Builder
@Jacksonized
class OrderRequest {
    String orderId;
    @Builder.Default
    int quantity = 1;
}

@Value
@Builder
class PlainRequest {
    String orderId;
}

record OrderRecord(String orderId, Integer quantity) {
}

public class Main {
    public static void main(String[] args) {
        JsonMapper mapper = JsonMapper.builder().build();
        String json = "{\"orderId\":\"A-1\"}";
        System.out.println("@Jacksonized: " + mapper.readValue(json, OrderRequest.class));
        System.out.println("record      : " + mapper.readValue(json, OrderRecord.class));
        try {
            mapper.readValue(json, PlainRequest.class);
        } catch (Exception e) {
            System.out.println("@Builder only: " + e.getClass().getSimpleName());
        }
    }
}
```

```
@Jacksonized: OrderRequest(orderId=A-1, quantity=1)
record      : OrderRecord[orderId=A-1, quantity=null]
@Builder only: MismatchedInputException
```

`@Jacksonized`付きは`@Builder.Default`の`quantity=1`まで反映されました。`@Builder`だけのクラスは`MismatchedInputException`で失敗します。リクエストDTOがrecordで足りるなら、JacksonはBuilder無しでrecordを復元できるので、Lombokは要りません。

### Spring Bootが注入するBuilder Bean：RestClient.Builderなど

Spring Bootは`RestClient.Builder`、`WebClient.Builder`、Jackson 3の`JsonMapper.Builder`を自動構成し、どれも`@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)`で登録します（[4.1.2のRestClientAutoConfiguration](https://github.com/spring-projects/spring-boot/blob/v4.1.2/module/spring-boot-restclient/src/main/java/org/springframework/boot/restclient/autoconfigure/RestClientAutoConfiguration.java)ほか、各自動構成クラスのソースで確認）。注入のたびに新しいBuilderが渡されるので、1つのクラスで`baseUrl`を設定しても、別のクラスに注入されたBuilderには影響しません。

```
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
public class PaymentClient {
    private final RestClient restClient;

    public PaymentClient(RestClient.Builder builder) {
        this.restClient = builder
                .baseUrl("https://payments.example.com")
                .defaultHeader("Accept", "application/json")
                .build();
    }

    public String status(String id) {
        return restClient.get().uri("/payments/{id}", id).retrieve().body(String.class);
    }
}
```

全クライアント共通の設定は、Builderを直接いじらず`RestClientCustomizer`（WebClientなら`WebClientCustomizer`）のBeanで足します。自動構成されたBuilderは、生成時にこれらのCustomizerを順に適用します。

```
import org.springframework.boot.restclient.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration(proxyBeanMethods = false)
public class ClientConfig {
    @Bean
    RestClientCustomizer userAgentCustomizer() {
        return builder -> builder.defaultHeader("User-Agent", "issoh-sample/1.0");
    }
}
```

上の2ファイルはspring-web 7.0.10とspring-boot-restclient 4.1.2でコンパイルを確認しています。Spring Boot 4では、`RestClient.Builder`の自動構成に`spring-boot-starter-restclient`、`WebClient.Builder`に`spring-boot-starter-webclient`が要ります。RestClient自体の使い方は[Spring RestClientの使い方：Boot 4.1での導入・エラー処理・テストとRestTemplateからの移行](/tech/details/3311/)、Boot 4の変更点の全体は[Spring Boot 4とは？最新バージョン4.1の変更点・新機能とSpring Boot 3との違い【2026年10月】](/tech/details/10124/)にあります。

Jacksonの設定も同じ考え方です。Spring Framework 7で`Jackson2ObjectMapperBuilder`は削除予定の非推奨（`forRemoval = true`）になり、Jackson 3の`JsonMapper.builder()`を使うよう案内されています。Bootでは`JsonMapperBuilderCustomizer`のBeanで設定を足します。

## JDK標準APIにあるBuilderの実例

JDK自身もBuilderを多用しています。設定をメソッドで積み上げる点は共通していますが、入口はAPIごとに異なります。

```
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.time.Duration;
import java.util.Locale;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .build();
        HttpRequest req = HttpRequest.newBuilder(URI.create("https://example.com/api"))
                .timeout(Duration.ofSeconds(10))
                .header("Accept", "application/json")
                .GET()
                .build();
        System.out.println(req.method() + " " + req.uri() + " " + req.headers().map());
        System.out.println(client.connectTimeout());

        Locale loc = new Locale.Builder().setLanguage("ja").setRegion("JP").build();
        System.out.println(loc.toLanguageTag());

        Stream.Builder<String> sb = Stream.builder();
        sb.add("a").add("b");
        System.out.println(sb.build().toList());

        Thread t = Thread.ofVirtual().name("worker-", 0)
                .start(() -> System.out.println(Thread.currentThread().getName()
                        + " virtual=" + Thread.currentThread().isVirtual()));
        t.join();
    }
}
```

```
GET https://example.com/api {Accept=[application/json]}
Optional[PT5S]
ja-JP
[a, b]
worker-0 virtual=true
```

`StringBuilder`は名前にBuilderを含みますが、可変の文字列バッファで、ここで扱う生成パターンとは別物です。使い方は[JavaのStringBuilderとは：使い方とStringBufferとの違い、+連結との速度差を実測で解説](/tech/details/5282/)を参照してください。

## Java実装でBuilderを避けるべきケース

- **Builderをスレッド間で共有する**：Builderは可変オブジェクトで、上の手書き例も`ArrayList`を持っています。Builderはメソッド内で作って捨て、共有するのは`build()`後の不変オブジェクトだけにします。
- **JPAエンティティのクラス全体に@Builderを付ける**：`id`やバージョン列など、アプリケーションが設定すべきでない項目までBuilderに出てしまいます。付けるなら設定させる項目だけを引数に取るコンストラクタに付け、生成用の入力はDTOかrecordで受けます。
- **項目が3つ以下で全部必須**：コンストラクタかrecordで足ります。Builderクラスの分だけ読む手間が増えます。

DTO・エンティティ・Builderの役割分担を含め、Javaの業務システムを設計から組みたい場合は、一創の[フルスクラッチ開発](/service/system/fullscratch/)でご相談を受け付けています。

## よくある質問

### @BuilderはSpringのアノテーションですか？

いいえ。`@Builder`はLombokの`lombok.Builder`です。Spring Bootのプロジェクトでよく使われますが、Lombokの依存関係に加え、ビルド側にもアノテーションプロセッサの設定が必要です。[JDK 23のリリースノート](https://www.oracle.com/java/technologies/javase/23-relnote-issues.html)のとおり、JDK 23以降はクラスパス上のアノテーションプロセッサが既定で動かないため、MavenのannotationProcessorPathsなどでLombokを明示します。Spring自体が提供するのは`RestClient.Builder`などのBuilder Beanです。

### Builderパターンとsetterで組み立てる方法の違いは何ですか？

setterで組み立てると、全項目を設定し終える前のオブジェクトが外から見え、生成後も値を変えられます。Builderは`build()`を呼ぶまで対象のオブジェクトを作らないので、検証を通った完成品だけを渡せ、フィールドを`final`にし、可変オブジェクトへの参照も防御的コピーなどで管理すれば、不変に設計できます。

### Lombokの@Builderで必須項目を強制できますか？

実行時なら強制できます。フィールドに`@NonNull`を付けると、未設定のまま`build()`を呼んだ時点で`NullPointerException`（メッセージは「to is marked non-null but is null」の形）になります。コンパイル時に止める機能はLombokに無いので、その場合はStep Builderを手で書きます。

### recordに@Builderは付けられますか？

付けられます。Lombok 1.18.20（2021年4月）以降で対応しています。Builderは正規コンストラクタを呼ぶので、コンパクトコンストラクタの検証がそのまま効きます。ただし設定しなかった項目は`null`や`0`で渡ります。

### StringBuilderはBuilderパターンですか？

違います。`StringBuilder`は可変の文字列バッファで、`append`で中身を書き換えていくクラスです。名前が似ているだけで、オブジェクトの生成手順を分けるBuilderパターンとは目的が異なります。

## 関連記事

- [Builderパターンとは？GoFの4役割とGoのFunctional Optionsとの使い分け](/tech/details/6334/)
- [Lombokとは？読み方・使い方とアノテーション一覧、Maven・Gradleの設定【1.18.48】](/tech/details/3300/)
- [Javaのレコードクラス（record）とは？使い方・コンストラクタ・継承できない理由とエラー例をJava 25で実測](/tech/details/3254/)
- [ファクトリパターンとは？Factory Method・Abstract Factoryの違いとJava・Python・Spring Boot実装例](/tech/details/3658/)
- [Spring RestClientの使い方：Boot 4.1での導入・エラー処理・テストとRestTemplateからの移行](/tech/details/3311/)

---

出典: [JavaのBuilderパターン実装｜Lombok @Builder・継承・record・Spring Bootでの書き方](<https://www.issoh.co.jp/tech/details/7072/>)（株式会社一創）
