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との使い分けで扱います。
まとめ: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の公式ドキュメントによると、この全引数コンストラクタが生成されるのは、自分でコンストラクタを書いておらず、@NoArgsConstructorなどでLombokに作らせてもいない場合だけです。Lombok全体の導入手順はLombokとは?読み方・使い方とアノテーション一覧、Maven・Gradleの設定【1.18.48】にまとめています。
@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("[email protected]").subject("hi")
.cc("[email protected]").cc("[email protected]").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("[email protected]");
} catch (UnsupportedOperationException e) {
System.out.println("ccs is immutable");
}
System.out.println(Mail.builder().subject("no recipient").build());
}
}
Mail([email protected], subject=hi, priority=3, ccs=[[email protected], [email protected]])
Mail([email protected], 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の公式ドキュメントのとおり、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("[email protected]").build());
try {
Mail.builder().subject("no recipient").build();
} catch (NullPointerException e) {
System.out.println("NPE: " + e.getMessage());
}
}
}
Mail([email protected], 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との違いを実行結果で解説を参照してください。
@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の公式サンプルコードは、親の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の公式ドキュメントは全スーパークラスへの付与を必須としています)。@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で実測で解説しています。
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)として提案されています。ただしステータスは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("[email protected]").subject("hi").build());
System.out.println(Mail.builder().to("[email protected]").subject("hi").body("hello").build());
}
}
Mail[[email protected], subject=hi, body=]
Mail[[email protected], subject=hi, body=hello]
subjectを飛ばしてbuild()を呼ぶと、SubjectStepにbuild()が無いためコンパイルが通りません。
public class Ng {
public static void main(String[] args) {
Mail.builder().to("[email protected]").build();
}
}
Ng.java:3: error: cannot find symbol
Mail.builder().to("[email protected]").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実装例にあります。
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を足すと、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ほか、各自動構成クラスのソースで確認)。注入のたびに新しい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からの移行、Boot 4の変更点の全体はSpring Boot 4とは?最新バージョン4.1の変更点・新機能とSpring Boot 3との違い【2026年10月】にあります。
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との違い、+連結との速度差を実測で解説を参照してください。
Java実装でBuilderを避けるべきケース
- Builderをスレッド間で共有する:Builderは可変オブジェクトで、上の手書き例も
ArrayListを持っています。Builderはメソッド内で作って捨て、共有するのはbuild()後の不変オブジェクトだけにします。 - JPAエンティティのクラス全体に@Builderを付ける:
idやバージョン列など、アプリケーションが設定すべきでない項目までBuilderに出てしまいます。付けるなら設定させる項目だけを引数に取るコンストラクタに付け、生成用の入力はDTOかrecordで受けます。 - 項目が3つ以下で全部必須:コンストラクタかrecordで足ります。Builderクラスの分だけ読む手間が増えます。
DTO・エンティティ・Builderの役割分担を含め、Javaの業務システムを設計から組みたい場合は、一創のフルスクラッチ開発でご相談を受け付けています。
よくある質問
@BuilderはSpringのアノテーションですか?
いいえ。@BuilderはLombokのlombok.Builderです。Spring Bootのプロジェクトでよく使われますが、Lombokの依存関係に加え、ビルド側にもアノテーションプロセッサの設定が必要です。JDK 23のリリースノートのとおり、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パターンとは目的が異なります。