テックブログ

Java record入門:DTOをシンプルに書くための基本

Java record入門:DTOをシンプルに書くための基本

Javaのrecordは、複数の値をまとめて保持するためのシンプルなデータクラスを短く書ける仕組みです。フィールド、コンストラクタ、アクセサ、equalshashCodetoStringなどが自動的に用意されるため、DTOやAPIレスポンス用オブジェクトのように「値を運ぶ」目的のクラスを簡潔に表現できます。ただし、状態を変更し続けるオブジェクトや、複雑な業務ロジックを持つクラスに何でも使うものではありません。

1. Java recordとは何か

1-1. 値をまとめて保持するためのデータクラス

Javaのrecordは、複数の値をまとめて保持するためのデータクラスを簡潔に書く仕組みです。名前、メールアドレス、金額、件数など、関連する値を1つの型として扱いたい場面で使いやすいです。

通常のclassでデータ保持クラスを作る場合、フィールド、コンストラクタ、getter、equalshashCodetoStringなどを用意することがあります。recordでは、宣言した項目に対してこれらの基本的な部品が自動的に用意されます。そのため、値を運ぶためだけのクラスを短く、意図が分かる形で書けます。

public record UserResponse(
    String id,
    String name,
    String email
) {
}

このコードでは、ユーザーID、名前、メールアドレスを持つUserResponseをrecordとして定義しています。注意点は、recordは「データを保持する目的」に向いた仕組みであり、通常のclassの完全な上位互換ではないことです。

1-2. 通常のclassとの違い

recordと通常のclassの違いは、データ保持に必要な基本メソッドが自動生成され、状態変更を前提にしない設計になっている点です。通常のclassよりも、値のまとまりを表す意図が明確になります。

recordで宣言した要素は、recordコンポーネントと呼ばれます。Javaはそのコンポーネントに対応するフィールド、コンストラクタ、アクセサ、equalshashCodetoStringを生成します。アクセサは通常のJavaBeans形式のgetName()ではなく、name()のようなメソッド名になります。

たとえば、user.name()のように値を取り出します。落とし穴は、既存のフレームワークやライブラリがJavaBeansのgetterを前提にしている場合です。最近の主要フレームワークではrecord対応が進んでいますが、プロジェクトのJavaバージョンやライブラリ対応状況は確認しましょう。

1-3. DTOで使いやすい理由

recordは、DTOのように値を運ぶだけのクラスと相性がよいです。DTOは複雑な状態変更よりも、必要なデータをまとめて受け渡すことが主な役割だからです。

APIレスポンス用DTO、検索結果DTO、画面表示用DTOなどは、生成したあとに中身を書き換え続けるより、必要な値を詰めて渡すだけのことが多いです。recordを使うと、「この型は値のまとまりです」という意図がコードから読み取りやすくなります。また、equalstoStringが自動生成されるため、テストやログ確認でも扱いやすくなります。

たとえば、UserエンティティをそのままAPIに返すのではなく、必要な項目だけをUserResponse recordに詰め替える使い方があります。DTOをrecordにすると、余計なsetterを持たせず、レスポンスの形を明確にできます。

2. recordで自動生成されるもの

2-1. コンストラクタとアクセサ

recordでは、宣言したコンポーネントに対応するコンストラクタとアクセサが自動生成されます。そのため、値を受け取って保持するだけのコードを大きく減らせます。

たとえばrecord UserResponse(String id, String name)と書くと、idnameを受け取るコンストラクタが作られます。また、値を取り出すためにid()name()というアクセサも作られます。通常のgetterであるgetId()getName()ではない点に注意が必要です。

UserResponse response = new UserResponse("U001", "山田太郎");

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

このコードでは、recordのコンストラクタで値を渡し、アクセサで取り出しています。注意点は、recordの値は生成後にsetterで変更する設計ではないことです。値を変えたい場合は、新しいrecordインスタンスを作る考え方になります。

2-2. equals / hashCode / toString

recordでは、equalshashCodetoStringが自動生成されます。値の中身で比較したいDTOでは、この性質が便利です。

通常のclassでは、同じ値を持つ別インスタンスを同じものとして比較したい場合、equalshashCodeを自分で実装する必要があります。recordでは、すべてのコンポーネントを基準にした比較メソッドが生成されます。そのため、DTO同士の比較や、テストで期待値と実際の値を比べる場面で扱いやすくなります。

UserResponse a = new UserResponse("U001", "山田太郎");
UserResponse b = new UserResponse("U001", "山田太郎");

System.out.println(a.equals(b));  // true
System.out.println(a);            // UserResponse[id=U001, name=山田太郎]

このコードでは、別インスタンスでもコンポーネントの値が同じなのでequalsがtrueになります。注意点は、recordのequalsは宣言したコンポーネント全体を基準にすることです。一部の項目だけで同一性を判断したいクラスでは、recordが適さない場合があります。

2-3. フィールドが基本的に変更されない設計

recordは、生成後にコンポーネントの値を変更しない設計になっています。DTOのように、作った時点の値を運ぶ用途に向いています。

recordのコンポーネントに対応するフィールドは、基本的にprivate finalとして扱われます。そのため、setterで値を書き換えるような使い方はできません。これにより、途中で値が変わって追いにくくなる問題を減らしやすくなります。ただし、コンポーネントにListなどの可変オブジェクトを持たせた場合、その中身まで自動的に完全不変になるわけではありません。

public record OrderSummary(
    String orderId,
    int totalAmount
) {
}

このコードでは、注文IDと合計金額を持つ集計結果をrecordで表しています。注意点は、recordが「変更不可に近い形」を作りやすい一方で、内部に持つオブジェクトの扱いまですべて安全になるわけではないことです。可変リストなどを持つ場合は、外から変更されない設計を別途考えましょう。

3. 通常のclassとrecordを比較する

3-1. classでDTOを書く場合

通常のclassでDTOを書く場合、フィールド、コンストラクタ、getter、比較メソッドなどを自分で用意する必要があります。シンプルなDTOでもコード量が増えやすいです。

もちろん、通常のclassには柔軟性があります。setterを用意して後から値を変えたり、複雑な振る舞いを持たせたり、継承を使った設計にしたりできます。一方、値を運ぶだけのDTOでは、その柔軟性が不要なことも多く、定型コードが目立つ原因になります。

import java.util.Objects;

public class UserResponse {
    private final String id;
    private final String name;
    private final String email;

    public UserResponse(String id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    public String getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserResponse that)) return false;
        return Objects.equals(id, that.id)
            && Objects.equals(name, that.name)
            && Objects.equals(email, that.email);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name, email);
    }

    @Override
    public String toString() {
        return "UserResponse[id=" + id + ", name=" + name + ", email=" + email + "]";
    }
}

このコードでは、DTOとして必要な処理を通常のclassで書いています。注意点は、コード量が増えるほど、フィールド追加時にgetterやequalshashCodeの更新漏れが起きやすくなることです。IDEで生成できるとはいえ、定型コードが多い点は変わりません。

3-2. recordでDTOを書く場合

recordでDTOを書く場合、データ構造を1つの宣言で表現できます。値を運ぶだけの型なら、通常のclassよりも意図が分かりやすくなります。

recordでは、宣言したコンポーネントからコンストラクタ、アクセサ、equalshashCodetoStringが生成されます。そのため、「このDTOはどんな値を持つのか」に集中できます。コードレビューでも、定型メソッドよりデータ項目そのものを確認しやすくなります。

public record UserResponse(
    String id,
    String name,
    String email
) {
}

このコードは、先ほどのclass版と同じようにユーザー情報を保持するDTOです。注意点は、アクセサ名がgetId()ではなくid()になることです。フレームワークやテンプレートエンジンがどちらに対応しているかは確認しましょう。

3-3. コード量と読みやすさの違い

recordの大きな利点は、定型コードを減らし、DTOの構造を読み取りやすくできることです。特にレスポンス用DTOや集計結果のような小さな型で効果が出ます。

通常のclassでは、コンストラクタやgetterの行数が多くなり、肝心の「どんな値を持つか」が埋もれがちです。recordでは、コンポーネント一覧がそのまま型の構造になります。そのため、読み手は短時間でDTOの項目を把握できます。

ただし、コード量が少ないことだけでrecordを選ぶのは危険です。後から状態を変えたい、複雑なバリデーションや業務ロジックを持たせたい、継承前提で設計したい場合は、通常のclassを選んだほうが自然です。

4. recordが向いている場面

4-1. APIレスポンス用DTO

recordは、APIレスポンス用DTOに向いています。レスポンス用DTOは、必要な値をまとめてクライアントへ返すことが主な役割だからです。

Web APIでは、DBのエンティティをそのまま返すのではなく、画面やクライアントに必要な項目だけをDTOに変換することがよくあります。このDTOは、生成後に値を書き換えるより、レスポンスの形を明確に表すことが重要です。recordを使うと、レスポンス項目を短く定義できます。

public record ProductResponse(
    String id,
    String name,
    int price,
    boolean inStock
) {
}

このコードでは、商品APIのレスポンス用DTOをrecordで定義しています。注意点は、エンティティそのものを何でもrecordに置き換える必要はないことです。DB更新や状態変更を持つ業務オブジェクトは、通常のclassのほうが適している場合があります。

4-2. 一時的な集計結果

recordは、一時的な集計結果を表す小さなオブジェクトにも向いています。複数の値をまとめて返したいときに、専用の型を簡単に作れます。

たとえば、注文件数と合計金額、ユーザー数と有効ユーザー数、カテゴリ名と商品数など、複数の結果をまとめたい場面があります。配列やMapで雑に返すより、recordで名前付きの型にすると、呼び出し側が値の意味を理解しやすくなります。

public record SalesSummary(
    long orderCount,
    int totalAmount
) {
}

このコードでは、注文件数と合計金額をまとめる集計結果をrecordで表しています。注意点は、金額計算で小数や税率を扱う場合、intで十分か、BigDecimalを使うべきかを業務要件に合わせて判断することです。

4-3. 値を運ぶだけの小さなオブジェクト

recordは、値を運ぶだけの小さなオブジェクトに向いています。処理の中心ではなく、データの受け渡しを分かりやすくするための型として使いやすいです。

メソッドの戻り値で複数の値を返したい場合や、処理の途中で一時的に値をまとめたい場合、recordを使うと型の意味を明確にできます。単なるMap<String, Object>や配列より、フィールド名と型が明確になるため、保守しやすくなります。

たとえば、LoginResultとしてユーザーID、表示名、権限をまとめる、ValidationResultとして成功可否とメッセージをまとめる、といった使い方があります。ただし、値を運ぶ以上の責務を持ちはじめたら、通常のclassにする判断も必要です。

5. recordを避けた方がよい場面

5-1. 状態を変更し続けるオブジェクト

recordは、状態を変更し続けるオブジェクトには向いていません。生成後に値を固定して扱う設計と相性がよい仕組みだからです。

たとえば、注文ステータスが「作成中」「支払い済み」「発送済み」と変化する業務オブジェクトや、ゲームのキャラクターの現在HPのように頻繁に状態が変わるオブジェクトでは、setterや状態変更メソッドを持つ通常のclassのほうが自然です。recordで無理に表現すると、変更のたびに新しいインスタンスを作る必要があり、設計意図が分かりにくくなることがあります。

DTOのように「作った値を運ぶ」ならrecordが向いています。一方、業務処理の中で状態を育てたり変更したりする中心的なオブジェクトでは、通常のclassを検討しましょう。

5-2. 複雑な業務ロジックを持つクラス

複雑な業務ロジックを持つクラスには、recordより通常のclassのほうが向いていることが多いです。recordはデータ保持を簡潔に表すための仕組みであり、すべての設計を置き換えるものではありません。

もちろん、recordにもメソッドを追加できます。しかし、状態変更、複雑なバリデーション、外部サービスとの連携、ドメインルールの集約などを多く持たせると、recordの「値のまとまり」という性質から外れていきます。読み手もrecordを見ると、まずシンプルなデータクラスだと期待します。

たとえば、注文金額の再計算、在庫引当、キャンセル可否判定などを多く持つOrderをrecordにすると、責務が重くなりすぎる可能性があります。DTOはrecord、業務ロジックを持つ中心クラスはclass、という分け方から始めると判断しやすいです。

5-3. 継承を前提にした設計

recordは、継承を前提にした設計には向いていません。通常のclassのように自由に継承階層を作るための仕組みではないからです。

recordは暗黙的にjava.lang.Recordを継承し、他のclassを継承できません。また、record自体は基本的に継承して拡張する設計ではありません。データの形を固定して表すことに向いているため、親クラスから共通フィールドや振る舞いを受け継ぐ設計とは相性がよくありません。

たとえば、複数のレスポンスDTOに共通の親クラスを持たせたい設計では、recordが合わない場合があります。その場合は、通常のclass、interface、共通部品への分離など、別の設計を検討しましょう。

6. まとめ

6-1. recordを使う判断基準

recordを使う判断基準は、そのクラスが「値を運ぶための小さなデータクラス」かどうかです。DTOや集計結果のように、生成後に値を変更しない用途では特に使いやすいです。

recordを使うと、コンストラクタ、アクセサ、equalshashCodetoStringを自動生成でき、コード量を減らせます。通常のclassよりも「この型は値のまとまりです」という意図が伝わりやすくなります。一方で、状態変更、複雑な業務ロジック、継承を前提にした設計には向きません。

最初は、APIレスポンス用DTO、検索結果DTO、一時的な集計結果からrecordを使ってみると理解しやすいです。通常のclassとrecordは対立するものではなく、役割に応じて使い分けるものです。

6-2. DTO作成時のチェックリスト

DTOをrecordで作るか迷ったときは、役割、変更有無、フレームワーク対応、比較基準を確認すると判断しやすくなります。

  • 値を運ぶだけの小さなクラスか
  • 生成後に値を変更し続ける必要がないか
  • setterがなくても困らない設計か
  • アクセサがname()形式でも利用側が対応できるか
  • APIレスポンス用DTOや集計結果として使えるか
  • 複雑な業務ロジックを持たせようとしていないか
  • 継承を前提にしていないか
  • 可変オブジェクトを持たせる場合に外部から変更されないか
  • プロジェクトのJavaバージョンやライブラリがrecordに対応しているか

recordは、DTOをシンプルに書くための強力な選択肢です。ただし、通常のclassを置き換える万能機能ではありません。値を運ぶだけの型に使い、状態変更や複雑な振る舞いが必要な場面ではclassを選ぶことで、読みやすく保守しやすいJavaコードになります。

7. 参考リンク