Java record入門|DTOで使う基本・不変性の注意点と設計判断

Java recordをDTOとして整理するみかん猫のイラスト

DTOを通常のクラスで作ると、フィールドに加えてコンストラクタやgetter、値を比較するためのメソッドなどを書くことになります。その定型コードを減らせるのがJavaのrecordです。ただし、採用するときはコード量だけでなく、生成後に値を差し替える必要があるか、保持するデータまで変更されないかを考える必要があります。

この記事ではJava 21を前提に、DTOを例としてrecordの宣言、アクセサー、生成時の検証、不変性の注意点を整理します。最後に通常クラスとの違いを比較し、どのようなDTOにrecordが合うのかを説明します。

目次

Javaのrecordとは|DTOのデータ構造を簡潔に表す

DTOの役割とrecordが表すものを区別する

DTO(Data Transfer Object)は、処理の境界でデータを受け渡すためのオブジェクトです。APIのレスポンスやサービス間の受け渡しなどで、必要な値を一つにまとめる役割を持ちます。DTOという専用のJava構文があるわけではなく、通常クラスでもrecordでも表現できます。

recordは、どの値を構成要素として持つかを宣言し、その値に基づくデータ集約を表す特別なクラスです。宣言した構成要素はrecord componentと呼ばれます。コンストラクタやアクセサーに加え、componentに基づくequals()、hashCode()、toString()が暗黙に用意されます。

つまり、DTOはオブジェクトの役割、recordはJava言語の仕組みです。recordを使うだけで、APIの入出力形式や入力検証、永続化まで決まるわけではありません。

Java 16で正式化され、Java 21ではpreview設定が不要

record classesはJava 14とJava 15でpreview機能として導入され、Java 16で正式な言語機能になりました。本記事の前提であるJava 21では、recordを使うためのpreview設定は不要です。

導入時期は、公式のJava言語のリリース別変更履歴で確認できます。ここではrecordを使ったデータ構造に集中し、別の言語機能を組み合わせたパターンマッチングなどは扱いません。

DTOの例でrecordの宣言と使い方を理解する

headerにcomponentを宣言し、コンストラクタで生成する

利用者のIDと表示名を受け渡すDTOを考えます。recordでは、名前に続く丸括弧の中、つまりheaderにcomponentの型と名前を並べます。

public record UserDto(long id, String name) {
}

この宣言には、idとnameに対応するprivate finalフィールド、それぞれのpublicアクセサー、全componentを受け取るcanonical constructorが暗黙に用意されます。生成時は、headerに宣言した順番で値を渡します。

public class RecordExample {
    public static void main(String[] args) {
        UserDto user = new UserDto(1001L, "みかん");

        long id = user.id();
        String name = user.name();

        System.out.println(id);
        System.out.println(name);
    }
}

上の二つの宣言は、それぞれUserDto.javaとRecordExample.javaに置く形です。この段階のUserDtoには独自の検証を追加していないため、名前にnullを渡すこともできます。record自体に、必須入力や値の範囲を自動判定する機能はありません。

アクセサーはgetName()ではなくname()

暗黙に用意されるアクセサーの名前はcomponent名と同じです。String nameならname()で参照し、getName()ではありません。また、componentの値を書き換えるsetterは自動生成されません。

通常クラスのDTOをrecordへ変更する場合、呼び出し側のgetName()をそのまま使えるとは限りません。ライブラリが値を読み取る方法も含めて、アクセサーの違いを確認します。

アクセサーは明示的に宣言することもできます。その場合はcomponentと同じ名前・型を持つpublicの引数なしメソッドにするなど、recordの規則に従います。通常は暗黙のアクセサーで十分です。値の加工表示が必要なら、元の値を返すアクセサーと、表示用のメソッドを分けると役割が明確になります。

equals・hashCode・toStringはcomponentに基づく

通常クラスでequals()を実装しない場合、Objectから継承する比較は同じインスタンスかどうかを判定します。一方、recordの暗黙のequalsは、同じrecord型であり、対応するすべてのcomponentが等しいかを判定します。

public class RecordEqualityExample {
    public static void main(String[] args) {
        UserDto first = new UserDto(1001L, "みかん");
        UserDto second = new UserDto(1001L, "みかん");
        UserDto renamed = new UserDto(1001L, "みかんさん");

        boolean sameValues = first.equals(second);  // true
        boolean sameInstance = first == second;    // false
        boolean sameIdOnly = first.equals(renamed); // false
    }
}

このUserDtoはIDだけで同一性を判定する型ではありません。名前が違えばequalsはfalseになります。参照型のcomponentは、その型のequalsに従って比較されます。recordが参照先の構造を無条件に深く比較するわけではありません。

hashCodeもcomponentに基づいて計算され、equalsで等しいrecord同士は同じハッシュ値になります。toStringにはrecordの名前と全componentの名前・値が含まれます。これらのメソッドは明示的に実装することもできますが、その場合は独自の実装内容に従います。

暗黙のtoStringは全componentを含みます。パスワードやトークンなどを保持するDTOを、そのままログへ出力しないでください。ログには必要な項目だけを明示的に渡す設計にします。

compact constructorで生成時の条件を守る

canonical constructorとcompact constructorの関係

canonical constructorは、recordの全componentに対応する引数を受け取り、インスタンスを初期化するコンストラクタです。UserDtoなら、long id, String nameを受け取ります。

canonical constructorは、何も宣言しなければ暗黙に用意されます。独自の処理が必要な場合は、引数とフィールドへの代入を明示する通常形式か、引数リストを省略するcompact constructorの形式で宣言できます。同じrecordに両方の形式を宣言することはできません。

compact constructorは、canonical constructorを簡潔に宣言するための構文です。別の引数なしコンストラクタを追加しているわけではありません。headerのcomponentに対応する引数を暗黙に受け取ります。

引数の検証・正規化とcomponent fieldへの暗黙代入

先ほどのUserDtoに、「IDは正の値」「名前はnullや空白だけの値にしない」という条件を加えます。次のコードは基本宣言を置き換える例です。名前の前後の空白はstrip()で取り除いてから保持します。

import java.util.Objects;

public record UserDto(long id, String name) {
    public UserDto {
        if (id <= 0) {
            throw new IllegalArgumentException("id must be positive");
        }

        Objects.requireNonNull(name, "name");
        name = name.strip();

        if (name.isEmpty()) {
            throw new IllegalArgumentException("name must not be blank");
        }
    }
}

compact constructor内のnameはコンストラクタ引数です。name = name.strip()によって引数を正規化すると、本文が正常に終わった後、その値が対応するcomponent fieldへ暗黙に代入されます。this.name = nameを書く必要はなく、compact constructor内でcomponent fieldへ直接代入することはできません。

この例では、nullの名前はNullPointerException、正でないIDや正規化後に空になる名前はIllegalArgumentExceptionで拒否します。条件を満たさないときはコンストラクタが正常終了しないため、その値を持つUserDtoは生成されません。

正規化は、データの契約に合わせて選びます。表示名の前後の空白を除く仕様ならこの処理が使えますが、入力表記をそのまま保存する必要がある値へ一律に適用するものではありません。何を受け入れ、何を変換し、何を拒否するかを型ごとに決めます。

生成時の条件とWeb入力のバリデーションを分ける

コンストラクタの検証は、その型のインスタンスを生成するときに守る条件です。一方、Web入力のバリデーションには、入力項目ごとのエラー表示や、リクエストに対する応答を整える役割もあります。コンストラクタで例外を投げるだけでは、HTTP応答や項目別のエラーメッセージまで決まりません。

不完全な入力を受け取って利用者へエラーを返すためのDTOと、検証済みの値だけを保持する内部データ型を分ける設計もできます。すべてのリクエストDTOに、内部データ型と同じ生成条件を課す必要はありません。

Spring Bootで制約アノテーションや@Valid、BindingResultを使ってWeb入力を検証する基本は、次の記事で説明しています。recordへの置き換え可否は、使用する連携機能の要件と合わせて判断してください。

recordの不変性は参照先の変更まで防がない

finalな参照と参照先オブジェクトの不変性は異なる

recordのcomponent fieldはfinalです。ただし、参照型ではフィールドが別のオブジェクトを指すように変更できないことと、参照先の中身を変更できないことは別です。これが、recordの不変性を「浅い不変性」と呼ぶ理由です。

たとえば、Listをそのまま保持するrecordでは、呼び出し側が元のListを変更すると、recordから参照できる内容も変わります。次はその関係を示す例です。

import java.util.ArrayList;
import java.util.List;

public class ShallowImmutableExample {
    record UserTagsDto(long id, List<String> tags) {}

    public static void main(String[] args) {
        List<String> source = new ArrayList<>();
        source.add("Java");

        UserTagsDto dto = new UserTagsDto(1001L, source);
        source.add("DTO");

        // dto.tags()から見える内容にも「DTO」が含まれる
        System.out.println(dto.tags());
    }
}

この宣言ではアクセサーも保持したListをそのまま返すため、dto.tags().add(...)による変更も可能です。recordにsetterがないことだけでは、データ全体が変更されないとは判断できません。

可変コレクションは防御的コピーと要素の可変性を考える

コレクションの追加・削除・置き換えを防ぐには、生成時にList.copyOf()で変更できないListを保持する方法があります。

import java.util.List;

public record UserTagsDto(long id, List<String> tags) {
    public UserTagsDto {
        if (id <= 0) {
            throw new IllegalArgumentException("id must be positive");
        }
        tags = List.copyOf(tags);
    }
}

この形では、渡された元のListを後から変更しても、保持したListの内容には反映されません。また、アクセサーから得たListへ追加・削除・要素の置き換えを行うこともできません。List.copyOfはnullのListやnull要素を受け入れないため、その条件もDTOの契約に含まれます。

ただし、List.copyOfは要素そのものを複製しません。この例の要素は不変なStringですが、要素が可変オブジェクトなら、そのオブジェクトの内部状態は変わり得ます。必要に応じて、不変な要素型へ変換する、要素もコピーする、外部へ可変な参照を返さないといった設計を組み合わせます。

可変なcomponentの内容が変わると、recordのequalsやhashCodeの結果も変わる場合があります。HashMapのキーやHashSetの要素として扱うなら、格納中に等価性の判定へ使われる状態が変わらないことまで確認してください。

DTOにrecordを採用するか判断する

生成後に値を差し替えないDTOかを確認する

recordが合うのは、必要な値を生成時にそろえ、その後は一まとまりのデータとして受け渡すDTOです。取得結果をまとめたレスポンスや、ある時点の情報を渡すデータ構造などが候補になります。

値を変更する場合は、既存インスタンスを書き換えるのではなく、変更後の値を持つ別のインスタンスを生成します。たとえばUserDtoの表示名を変更するなら、new UserDto(user.id(), newName)のように表現します。

反対に、空のオブジェクトを作ってsetterで段階的に値を埋める処理や、同じインスタンスの状態を繰り返し更新する処理では、通常クラスのほうが要件に合う場合があります。同じIDと名前を保持する場合でも、設計上の選択肢は次のように異なります。

比較項目record通常クラス
保持するデータheaderのcomponentとして宣言するフィールドとして宣言する
フィールドの再代入component fieldはfinalfinalにするかを設計で選べる
値の参照component名のアクセサーが暗黙に用意されるgetterなどを実装する
生成後の変更componentを書き換えるsetterは自動生成されないsetterの有無を設計で選べる
等価性標準では全componentに基づくequalsとhashCodeを必要に応じて実装する
文字列表現標準では全componentを含むtoStringを必要に応じて実装する
参照先の不変性可変な参照先には別途対策が必要可変な参照先には別途対策が必要

通常クラスでも、finalフィールドやコンストラクタ、防御的コピーを使って変更を抑えた設計にできます。recordの利点は、単なる記述量の削減に加えて、componentを中心とするデータ構造と標準的な等価性を宣言で表せる点にあります。

継承・追加フィールドの制約とメソッド・インターフェースの活用

recordは暗黙にfinalであり、継承してサブクラスを作ることはできません。また、別のクラスをextendsすることもできません。headerで宣言したcomponentに対応するフィールド以外のinstance fieldや、instance initializerも宣言できません。

一方、instance methodやstatic method、static fieldは宣言でき、インターフェースも実装できます。componentから導出できる値は、追加のinstance fieldへ保存せずメソッドで求められます。

public record ItemDto(String name, int quantity) {
    public boolean hasStock() {
        return quantity > 0;
    }
}

recordだからメソッドを書けないわけではありません。ただし、component以外の個別状態を後から持たせたい場合や、継承を前提とする設計では制約が合わなくなります。必要な状態と振る舞いを整理してから採用します。

日時などのcomponentの型はデータの意味から選ぶ

recordは値をまとめる形を決めますが、componentに何の型を使うべきかまでは決めません。たとえば日時なら、イベントが発生した瞬間を表すのか、誕生日のような日付だけを表すのかで、保持する情報が異なります。

「DTOをrecordにしたから日時も安全になった」とは判断せず、データの意味に合う型と受け渡し方法を選びます。APIとDBの間で日時を扱う型選択やタイムゾーン変換は、次の記事で整理しています。

フレームワーク連携や永続化の要件を個別に確認する

言語としてrecordを宣言できることと、利用するフレームワークやライブラリがそのDTOを扱えることは別です。採用する製品・バージョン・設定に合わせ、オブジェクトの生成方法と値の読み取り方法を確認します。

連携箇所確認する内容
JSON変換componentとJSON項目の対応、コンストラクタによる生成、欠落値やnullの扱い
Web入力引数への変換方法、生成時の例外の扱い、エラー応答との対応
Bean Validation採用環境での検証対象・実行タイミング・アノテーションの扱い
永続化・マッピングDTOへの結果マッピングと、エンティティとして必要な生成・更新・継承などの要件

特に、検索結果を受け取るDTOに使えるかという判断と、永続化エンティティとして使えるかという判断は分けます。recordを理由に既存のJPAエンティティやリクエストDTOを一括で置き換えず、対象の用途で必要な条件を満たすか確認してください。

Java言語としての規則は、公式のJava SE 21のRecord Classesガイドと、Java言語仕様の8.10 Record Classesで確認できます。フレームワークとの連携は、これらの言語仕様に加えて採用環境の公式資料を参照します。

まとめ

Javaのrecordは、componentを中心とするデータ構造を簡潔に宣言する仕組みです。component名のアクセサー、canonical constructor、値に基づくequals・hashCode、全componentを含むtoStringが暗黙に用意されます。生成時に守る条件や正規化は、compact constructorにまとめられます。

DTOへ採用するときは、生成後にcomponentを差し替えない設計かを確認します。参照先のオブジェクトまで不変になるわけではないため、コレクションの防御的コピーと要素の可変性も検討してください。componentの型、全componentに基づく等価性、継承などの制約、利用するフレームワークの要件が合っていれば、recordはDTOの構造と意図を明確に表す選択肢になります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次