APIのレスポンスDTOが、フィールド、コンストラクタ、getter、equals だけの長いクラスになっている。これなら record で短く書けます。一方、record にしただけでデータが完全に変更不能になると思うと、思わぬ共有状態を残します。
私の判断はシンプルです。値を受け渡すために形が固定された型にはrecord、生成後に状態を変える必要がある型には通常クラスを選びます。JPAのEntityなど、フレームワークが特定の構造を要求する型も、その要件を優先します。以下は Java 21 を前提にします。
何を短くできて、何が変わらないか
public record TaskResponse(long id, String title, boolean completed) {}この宣言から、各値を保持するフィールド、コンストラクタ、id() などのアクセサ、equals、hashCode、toString が得られます。フィールド参照を別の値へ差し替えるsetterはありません。APIで「この時点のタスク」を返すには分かりやすい形です。
しかしrecordは浅い不変性です。値にListや可変オブジェクトを持てば、その中身には別の参照から変更が届き得ます。
Listを受け取るrecordには防御的コピーを考える

import java.util.ArrayList;
import java.util.List;
record TaskView(List<String> labels) {}
class RecordListExample {
public static void main(String[] args) {
List<String> original = new ArrayList<>(List.of("要確認"));
TaskView view = new TaskView(original);
original.add("至急");
System.out.println(view.labels()); // [要確認, 至急]
}
}コンストラクタに渡したListと、recordが保持するListは同じオブジェクトです。外側から変更を遮断したいなら、コンパクトコンストラクタでコピーします。
import java.util.List;
public record TaskView(List<String> labels) {
public TaskView {
labels = List.copyOf(labels);
}
}これで元のListへの追加や、view.labels().add(...) による変更を防げます。ただし List.copyOf も要素オブジェクトを深くコピーしません。List<MutableItem> のような型なら、要素のプロパティ変更は見える可能性があります。String のような不変要素を使うか、要素を別の不変型へ変換します。また List.copyOf は null 要素を受け付けません。null を許す仕様なら、コピー方法と扱いを別途決めます。
DTO、フォーム、Entityで選び方を変える
| 型の役割 | recordの適性 | 判断理由 |
|---|---|---|
| APIの読み取りレスポンス | 高い | 一時点の値を渡し、変更しない |
| 更新値を一括で受け取るDTO | 条件付き | 利用するJSON変換・バリデーションとの互換性を確認する |
| 入力途中で各項目を書き換えるフォーム | 低い | 変更操作やデータ結合の前提が合わない場合がある |
| JPA Entity | 低い | recordはfinalで通常のEntityに必要な構造と合わない |
「DTO」という名前だけでrecordと決めず、その型を誰が作り、後から変更するかを追います。Spring MVCのJSON入力へ使う場合は、利用中のJackson・Spring Bootの組み合わせで、コンストラクタによる変換と制約の実行を実際にテストしてください。画面フォームは同じDTOでもバインディング方式が異なります。
既存クラスを置き換えるときの確認点
公開APIのgetter名が getTitle() から title() へ変わります。コンストラクタ引数も固定され、フィールドを追加すれば生成箇所へ影響します。クラスの継承もできません。置き換え前に、JSONの形、バリデーション、テスト、呼び出し元、シリアライズしたデータとの互換性を確認します。
recordにして短くなること自体を目的にせず、「この型は固定された値を表す」ことを利用者に示せるかで選んでください。業務処理の途中で状態を育てるオブジェクトなら、通常クラスの方が意図を表しやすい場合があります。
