recordの外枠は固定されても内側のListが変わることを示す図。浅い不変性に注意

設計・テスト・レビュー

Javaのrecordと普通のクラスはどう使い分ける?DTOに使う前の注意点

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には防御的コピーを考える

recordを選ぶ前の確認。用途:値の受け渡しか。更新:作成後に変更するか。参照型:Listの中身は変わるか。境界:入力検証はどこか
recordを選ぶ前の確認。DTOの中身まで確認して選ぶ
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にして短くなること自体を目的にせず、「この型は固定された値を表す」ことを利用者に示せるかで選んでください。業務処理の途中で状態を育てるオブジェクトなら、通常クラスの方が意図を表しやすい場合があります。

参考資料

-設計・テスト・レビュー
-,