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);
}
}前のサンプルから1行だけの record TaskView(...) を外し、上の修正版を TaskView.java に保存します。RecordListExample の本体はそのまま再実行してください。生成後に original.add("至急") を実行しても、view.labels() の表示は [要確認] のままです。その直後に view.labels().add("至急") を追加すると UnsupportedOperationException になります。元のリストを変更しても表示が変わらないことと、アクセサから取ったリストも変更できないことを、別々に確認できます。
List.copyOf で固定できるのは、リストに並ぶ要素の参照です。要素が可変の MutableItem なら、呼び出し元が同じ要素の名前を書き換えたとき、DTOからも新しい名前が見えます。生成時点の値を返すDTOでは、要素を String や不変のDTOに変換してからリストに入れます。
List.copyOf は、リスト自体が null の場合も、リストに null 要素がある場合も NullPointerException を投げます。「ラベル情報がない」と「ラベルが0件」を同じ意味にするAPIなら、リスト自体の null は labels = labels == null ? List.of() : List.copyOf(labels); で空リストに統一できます。空欄の位置を null 要素で残す契約なら、リスト自体は必須にし、java.util.Collections と java.util.ArrayList をインポートして labels = Collections.unmodifiableList(new ArrayList<>(labels)); でコピーします。後者は元のリストだけを Collections.unmodifiableList で包むと元の変更が見えるため、先に new ArrayList<>(labels) でコピーするのが要点です。
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にして短くなること自体を目的にせず、「この型は固定された値を表す」ことを利用者に示せるかで選んでください。業務処理の途中で状態を育てるオブジェクトなら、通常クラスの方が意図を表しやすい場合があります。
