Article

🧱 Java recordとList.copyOf:変更されないAPI DTOの作り方

java, record, api, backend

Javaのrecord(レコード)は、データを保持するクラスを短く記述できる構文です。REST APIのDTO(Data Transfer Object:レイヤー間でデータを運ぶオブジェクト)に採用すると、フィールドやequalsなどを手書きする手間を減らせます。

ただし、recordにListを持たせただけでは、あとから値が変わるのを防げません。注文APIで、バリデーション済みの明細が後から書き換わると、ログや業務処理の整合性が崩れる恐れがあります。

この記事では、Java標準APIだけでできる「防御的コピー」、List.copyOfとCollections.unmodifiableListの使い分け、nullや入れ子の可変オブジェクトの落とし穴まで解説します。

2026年10月24日時点のOracle公式Java APIを参照。サンプルはOpenJDK 21でjavac --release 17を使ってコンパイルし、実行結果を確認しました。SpringやJacksonと組み合わせたデシリアライズ挙動は、使用バージョンに依存するためここでは検証済みとしません。

1. recordのfinalだけでは守れない

次のrecordには、明示的なsetterがありません。

import java.util.List;

record OrderRequest(List<String> itemIds) {}

しかし、保持しているListの参照先が可変なら、外から変更できます。

var items = new java.util.ArrayList<>(List.of("item-1"));
var request = new OrderRequest(items);

items.add("item-2");
System.out.println(request.itemIds()); // [item-1, item-2]

finalが守るのはフィールドに格納された参照の差し替えであり、参照先のListの中身まで不変にすることではありません。OracleのRecord APIも「shallowly immutable(浅い不変性)」と説明しています。

2. 防御的コピーをコンストラクタで作る

防御的コピーとは、呼び出し元の可変オブジェクトをそのまま持たず、独立した値を保存することです。recordではコンパクト・コンストラクタ(引数リストを省略したコンストラクタ本体)内で行えます。

import java.util.List;
import java.util.Objects;

public record CreateOrderRequest(String buyerId, List<String> itemIds) {
    public CreateOrderRequest {
        if (buyerId == null || buyerId.isBlank()) {
            throw new IllegalArgumentException("buyerId is required");
        }

        Objects.requireNonNull(itemIds, "itemIds");
        if (itemIds.isEmpty()) {
            throw new IllegalArgumentException("itemIds must not be empty");
        }

        itemIds = List.copyOf(itemIds);
    }
}

最後のitemIds = List.copyOf(itemIds)は、コンストラクタ引数を差し替えています。コンパクト・コンストラクタでは、処理終了後に各引数が対応するフィールドへ自動的に代入されるため、this.itemIdsへ手動で代入する必要はありません。

List.copyOfが返すリストは**変更不可(unmodifiable)**で、元のコレクションに要素を追加・削除しても結果には反映されません。受け取った側がaddなどを試すと、UnsupportedOperationExceptionになります。

List.copyOfは、入力がすでに変更不可リストならオブジェクトを再利用する場合があります。「必ず別インスタンスになる」ことを前提に==でテストしないでください。

3. コピーとビューの違いを実行して確かめる

Collections.unmodifiableListも直接のaddを禁止しますが、こちらは元のリストに対する読み取り専用ビューです。元データの変更はビューに現れます。

次のコードはJava 17以降で動作する完全な実行例です。RecordDefensiveCopyDemo.javaとして保存してください。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

public class RecordDefensiveCopyDemo {
    record CreateOrderRequest(String buyerId, List<String> itemIds) {
        public CreateOrderRequest {
            if (buyerId == null || buyerId.isBlank()) {
                throw new IllegalArgumentException("buyerId is required");
            }
            Objects.requireNonNull(itemIds, "itemIds");
            if (itemIds.isEmpty()) {
                throw new IllegalArgumentException("itemIds must not be empty");
            }
            itemIds = List.copyOf(itemIds);
        }
    }

    public static void main(String[] args) {
        var original = new ArrayList<>(List.of("item-1"));
        var request = new CreateOrderRequest("buyer-1", original);

        original.add("item-2");
        System.out.println(request.itemIds()); // [item-1]

        try {
            request.itemIds().add("item-3");
        } catch (UnsupportedOperationException expected) {
            System.out.println("変更不可");
        }

        // Arrays.asListはnull要素を受け付けるので、copyOf側の検証を試せる
        try {
            new CreateOrderRequest("buyer-1", Arrays.asList("item-1", null));
        } catch (NullPointerException expected) {
            System.out.println("null要素を拒否");
        }

        var source = new ArrayList<>(List.of("A"));
        var view = Collections.unmodifiableList(source);
        var snapshot = List.copyOf(source);
        source.add("B");

        System.out.println("view=" + view + ", snapshot=" + snapshot);

        var mutableElement = new StringBuilder("before");
        var shallowCopy = List.copyOf(List.of(mutableElement));
        mutableElement.append("-after");
        System.out.println(shallowCopy.get(0));
    }
}

実行コマンドは次のとおりです。

javac --release 17 RecordDefensiveCopyDemo.java
java RecordDefensiveCopyDemo

javacはJavaソースをコンパイルするコマンドです。--release 17はJava 17のAPI・言語仕様でコンパイル可能か確認する指定、javaは生成されたクラスを実行するコマンドです。

出力例:

[item-1]
変更不可
null要素を拒否
view=[A, B], snapshot=[A]
before-after

viewだけが後から追加したBを反映します。APIリクエスト時点の内容を固定したいなら、Collections.unmodifiableListを元の可変Listにそのまま掛けるのではなく、List.copyOfが適しています。

4. ハマりどころ:nullと「浅いコピー」

nullは自動的に空リストにならない

List.copyOf(null)、またはリスト内のnull要素はNullPointerExceptionです。受け付けない入力を早い段階で拒否するなら便利ですが、仕様として「省略したら空配列」を許容したいなら変換方針を別途決めてください。

// 仕様として「nullは空」を許容するケースに限る
itemIds = itemIds == null ? List.of() : List.copyOf(itemIds);

先ほどの注文例では空明細も拒否する仕様なので、この変換を機械的に入れるべきではありません。バリデーションのルールを先に決めることが重要です。

List内の可変オブジェクトまでは固定されない

List.copyOfは「要素の参照」をコピーします。要素がStringBuilderなど可変の型なら、その要素自体を変更できます。実行例の最後がbefore-afterになるのはそのためです。

実務ではList<String>、List<Integer>、不変な値だけを保持するrecordなど、要素の不変性も確認します。List<Map<String, Object>>のような入れ子構造はList.copyOfだけでは不十分で、必要に応じて内側もコピー・変換します。

5. 実務での使い分け

手段 元のリストの後続変更 戻り値へのadd 主な用途
List.copyOf(source) 反映しない 不可 DTOの入力確定、スナップショット
Collections.unmodifiableList(source) 反映する 不可 元のコレクションを読み取り専用ビューとして公開
new ArrayList<>(source) 反映しない 可能 作業用の独立した可変リスト
List.of("A", "B") 元データなし 不可 固定値から変更不可リストを作る

注意点は、「変更不可」と「要素を含め完全に不変」は別ということです。またList.copyOfは入力の順序を保持しますが、完全な深いコピーを保証しません。

レビュー時に見るポイント

  • recordのフィールドにList、Map、配列などの可変コンテナがないか。
  • 生成後に呼び出し側が元のリストを変更しても、DTOの内容が変わらないか。
  • コンテナ内にさらに可変の要素や入れ子のコレクションがないか。
  • nullと空コレクションを業務仕様でどう扱うかが決まっているか。
  • コンパクト・コンストラクタの入力検証が、API境界で期待するルールに合っているか。

まとめ

recordはDTOを簡潔に書けますが、それだけで深い不変性は手に入りません。外部から渡されたリストの**要素構成を固定したいなら、コンストラクタでList.copyOf**を使い、nullと要素の可変性も確認するのが基本です。

特にAPI境界では、入力検証後の値が別の箇所で書き換わらないことが重要です。まず小さなrecordで防御的コピーを適用し、元リストを変更するテストを追加すると設計意図を守りやすくなります。

参考資料(2026年10月24日確認)