はじめに

Javaでデータを保持するクラスを作成する際、フィールド定義、コンストラクタ、getter、equals()hashCode()toString()といった多くのボイラープレートコードを書く必要がありました。

Recordは、Java 16で正式導入された機能で、このようなボイラープレートを劇的に削減し、イミュータブル(不変)なデータクラスを簡潔に定義できます。

この記事では、Recordの基本構文からコンパクトコンストラクタ、カスタマイズ方法、従来のPOJOやLombokとの比較、そしてDTOやValue Objectとしての実践的な活用パターンまで解説します。

Recordとは何か

Recordは、データの集約を目的とした特別なクラスです。データキャリア(data carrier)として設計されており、状態を保持することに特化しています。

Recordの導入経緯

Recordは以下のような経緯を経て正式導入されました。

バージョン ステータス JEP
Java 14 プレビュー機能(第1弾) JEP 359
Java 15 プレビュー機能(第2弾) JEP 384
Java 16 正式機能 JEP 395

Recordの特徴

Recordには以下の特徴があります。

特徴 説明
イミュータブル フィールドはprivate finalで宣言され、変更不可
簡潔な構文 1行でデータクラスを定義可能
自動生成 コンストラクタ、アクセサ、equals()hashCode()toString()が自動生成
暗黙的にfinal 継承不可(サブクラスを作成できない)

Recordの概念図

flowchart TB
    subgraph Record["Record宣言"]
        R["record User(String name, int age)"]
    end
    
    subgraph Generated["自動生成されるメンバー"]
        F["private final フィールド\n- name\n- age"]
        C["コンストラクタ\nUser(String name, int age)"]
        A["アクセサメソッド\n- name()\n- age()"]
        M["Objectメソッド\n- equals()\n- hashCode()\n- toString()"]
    end
    
    Record --> F
    Record --> C
    Record --> A
    Record --> M

Recordの基本構文

Recordの宣言は非常にシンプルです。classキーワードの代わりにrecordキーワードを使用します。

最小限のRecord定義

1
2
// Recordの定義
record User(String name, int age) {}

たったこれだけで、以下と同等のクラスが定義されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 従来のクラス定義(Recordと同等の機能)
public final class User {
    private final String name;
    private final int age;
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String name() {
        return name;
    }
    
    public int age() {
        return age;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User user)) return false;
        return age == user.age && Objects.equals(name, user.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
    
    @Override
    public String toString() {
        return "User[name=" + name + ", age=" + age + "]";
    }
}

Recordの使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class RecordExample {
    public static void main(String[] args) {
        // インスタンス生成
        User user = new User("田中太郎", 30);
        
        // アクセサメソッドによる値の取得
        System.out.println("名前: " + user.name());  // 名前: 田中太郎
        System.out.println("年齢: " + user.age());   // 年齢: 30
        
        // toString()の出力
        System.out.println(user);  // User[name=田中太郎, age=30]
        
        // equals()による比較
        User user2 = new User("田中太郎", 30);
        System.out.println(user.equals(user2));  // true
        
        // hashCode()の一致
        System.out.println(user.hashCode() == user2.hashCode());  // true
    }
}

実行環境: Java 16以降

自動生成されるメソッド

Recordでは、コンポーネント(ヘッダーで宣言したパラメータ)に基づいて、複数のメンバーが自動生成されます。

自動生成されるメンバー一覧

メンバー 説明
private finalフィールド 各コンポーネントに対応するフィールド
正規コンストラクタ 全コンポーネントを引数に取るコンストラクタ
アクセサメソッド 各コンポーネントと同名のメソッド(name()age()など)
equals(Object) 全コンポーネントを比較する実装
hashCode() 全コンポーネントに基づくハッシュ値
toString() ClassName[component1=value1, component2=value2]形式

equalsとhashCodeの動作

Recordのequals()hashCode()は、すべてのコンポーネントの値に基づいて動作します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
record Point(int x, int y) {}

public class EqualsHashCodeDemo {
    public static void main(String[] args) {
        Point p1 = new Point(10, 20);
        Point p2 = new Point(10, 20);
        Point p3 = new Point(10, 30);
        
        // 同じ値を持つRecordは等しい
        System.out.println(p1.equals(p2));  // true
        System.out.println(p1.equals(p3));  // false
        
        // HashSetでの利用
        Set<Point> points = new HashSet<>();
        points.add(p1);
        points.add(p2);  // p1と等しいため追加されない
        System.out.println(points.size());  // 1
    }
}

toStringの出力形式

toString()は、クラス名とすべてのコンポーネントの名前・値を含む文字列を返します。

1
2
3
4
5
record Product(String id, String name, int price) {}

Product product = new Product("P001", "ノートPC", 120000);
System.out.println(product);
// 出力: Product[id=P001, name=ノートPC, price=120000]

アクセサメソッドの命名規則

Recordのアクセサメソッドは、従来のgetXxx()形式ではなく、コンポーネント名そのものがメソッド名となります。

従来のgetter Recordのアクセサ
getName() name()
getAge() age()
isActive() active()

コンパクトコンストラクタ

Recordでは、バリデーションや正規化のためにコンパクトコンストラクタを定義できます。

コンパクトコンストラクタとは

コンパクトコンストラクタは、引数リストを省略した特別な形式のコンストラクタです。フィールドへの代入は暗黙的に行われるため、バリデーションロジックのみを記述します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
record User(String name, int age) {
    // コンパクトコンストラクタ
    public User {
        // バリデーション
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("名前は必須です");
        }
        if (age < 0) {
            throw new IllegalArgumentException("年齢は0以上である必要があります");
        }
        // this.name = name; は自動で行われる
        // this.age = age; は自動で行われる
    }
}

正規コンストラクタとの比較

コンパクトコンストラクタと正規コンストラクタの違いを確認しましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// コンパクトコンストラクタ(推奨)
record Email(String value) {
    public Email {
        if (!value.contains("@")) {
            throw new IllegalArgumentException("無効なメールアドレス形式です");
        }
    }
}

// 正規コンストラクタ(明示的な代入が必要)
record Email(String value) {
    public Email(String value) {
        if (!value.contains("@")) {
            throw new IllegalArgumentException("無効なメールアドレス形式です");
        }
        this.value = value;  // 明示的な代入が必要
    }
}

値の正規化

コンパクトコンストラクタでは、値の正規化も行えます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
record Name(String value) {
    public Name {
        // nullチェックと正規化
        value = Objects.requireNonNull(value, "名前はnullにできません").trim();
        
        if (value.isEmpty()) {
            throw new IllegalArgumentException("名前は空にできません");
        }
    }
}

public class NormalizationDemo {
    public static void main(String[] args) {
        Name name = new Name("  田中太郎  ");
        System.out.println("[" + name.value() + "]");  // [田中太郎]
    }
}

複数フィールドのバリデーション

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
record DateRange(LocalDate start, LocalDate end) {
    public DateRange {
        Objects.requireNonNull(start, "開始日は必須です");
        Objects.requireNonNull(end, "終了日は必須です");
        
        if (start.isAfter(end)) {
            throw new IllegalArgumentException("開始日は終了日以前である必要があります");
        }
    }
}

Recordのカスタマイズ

Recordは、必要に応じて追加のメソッドやコンストラクタを定義してカスタマイズできます。

追加メソッドの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
record Rectangle(double width, double height) {
    // 追加のインスタンスメソッド
    public double area() {
        return width * height;
    }
    
    public double perimeter() {
        return 2 * (width + height);
    }
    
    public boolean isSquare() {
        return width == height;
    }
}

public class RectangleDemo {
    public static void main(String[] args) {
        Rectangle rect = new Rectangle(10.0, 5.0);
        System.out.println("面積: " + rect.area());      // 面積: 50.0
        System.out.println("周長: " + rect.perimeter()); // 周長: 30.0
        System.out.println("正方形: " + rect.isSquare()); // 正方形: false
    }
}

静的メソッドとファクトリメソッド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
record Money(BigDecimal amount, String currency) {
    // 静的フィールド
    private static final Set<String> VALID_CURRENCIES = 
        Set.of("JPY", "USD", "EUR");
    
    // コンパクトコンストラクタ
    public Money {
        Objects.requireNonNull(amount, "金額は必須です");
        Objects.requireNonNull(currency, "通貨は必須です");
        
        if (!VALID_CURRENCIES.contains(currency)) {
            throw new IllegalArgumentException("無効な通貨コード: " + currency);
        }
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("金額は0以上である必要があります");
        }
    }
    
    // ファクトリメソッド
    public static Money yen(long amount) {
        return new Money(BigDecimal.valueOf(amount), "JPY");
    }
    
    public static Money usd(double amount) {
        return new Money(BigDecimal.valueOf(amount), "USD");
    }
    
    // インスタンスメソッド
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("通貨が異なります");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

アクセサメソッドのオーバーライド

アクセサメソッドをオーバーライドして、追加の処理を行うこともできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
record SecureUser(String name, String password) {
    // パスワードをマスクして返す
    @Override
    public String password() {
        return "********";
    }
    
    // 実際のパスワードを取得するメソッド(セキュリティに注意)
    public boolean verifyPassword(String input) {
        return password.equals(input);
    }
    
    // toStringもオーバーライドしてパスワードを隠す
    @Override
    public String toString() {
        return "SecureUser[name=" + name + ", password=********]";
    }
}

追加コンストラクタ

正規コンストラクタを呼び出す形で、追加のコンストラクタを定義できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
record Book(String title, String author, int publicationYear) {
    // 出版年を現在年にするコンストラクタ
    public Book(String title, String author) {
        this(title, author, java.time.Year.now().getValue());
    }
    
    // 著者を「著者不明」にするコンストラクタ
    public Book(String title) {
        this(title, "著者不明");
    }
}

インターフェースの実装

Recordはインターフェースを実装できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
interface Printable {
    String format();
}

record Invoice(String id, BigDecimal amount, LocalDate dueDate) 
    implements Printable, Comparable<Invoice> {
    
    @Override
    public String format() {
        return String.format("請求書 %s: ¥%s(期限: %s)", 
            id, amount.toPlainString(), dueDate);
    }
    
    @Override
    public int compareTo(Invoice other) {
        return this.dueDate.compareTo(other.dueDate);
    }
}

Recordの制約

Recordにはいくつかの制約があります。これらを理解することで、適切な場面でRecordを活用できます。

Recordの制約一覧

制約 説明
継承不可 Recordは暗黙的にfinalであり、他のクラスを継承できない
java.lang.Recordを継承 すべてのRecordはjava.lang.Recordを暗黙的に継承
インスタンスフィールド不可 コンポーネント以外のインスタンスフィールドを宣言できない
フィールドはfinal コンポーネントは変更不可(イミュータブル)
抽象化不可 abstract修飾子は使用不可
nativeメソッド不可 nativeメソッドを宣言できない

インスタンスフィールドの制限

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// コンパイルエラー: インスタンスフィールドは宣言できない
record User(String name) {
    private int loginCount;  // エラー
    
    {
        loginCount = 0;  // インスタンス初期化子も不可
    }
}

// 静的フィールドは許可される
record Config(String key, String value) {
    private static final String PREFIX = "config_";  // OK
    
    static {
        // 静的初期化子もOK
    }
}

ミュータブルなフィールドへの参照

Recordのフィールドはfinalですが、参照先のオブジェクトが可変の場合は注意が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
record Team(String name, List<String> members) {}

public class MutabilityDemo {
    public static void main(String[] args) {
        List<String> memberList = new ArrayList<>();
        memberList.add("田中");
        memberList.add("佐藤");
        
        Team team = new Team("開発チーム", memberList);
        System.out.println(team);  // Team[name=開発チーム, members=[田中, 佐藤]]
        
        // 外部からリストを変更できてしまう
        memberList.add("鈴木");
        System.out.println(team);  // Team[name=開発チーム, members=[田中, 佐藤, 鈴木]]
    }
}

この問題を防ぐには、防御的コピーを行います。

1
2
3
4
5
6
record Team(String name, List<String> members) {
    public Team {
        // 防御的コピーで不変性を保証
        members = List.copyOf(members);
    }
}

従来のPOJOとLombokとの比較

Recordと従来のPOJO、Lombokを使った方法を比較してみましょう。

従来のPOJO

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public final class UserPojo {
    private final String name;
    private final int age;
    
    public UserPojo(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    public String getName() {
        return name;
    }
    
    public int getAge() {
        return age;
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserPojo userPojo = (UserPojo) o;
        return age == userPojo.age && 
               Objects.equals(name, userPojo.name);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
    
    @Override
    public String toString() {
        return "UserPojo{name='" + name + "', age=" + age + "}";
    }
}

Lombokを使ったアプローチ

1
2
3
4
5
6
7
import lombok.Value;

@Value
public class UserLombok {
    String name;
    int age;
}

Recordによる定義

1
record UserRecord(String name, int age) {}

比較表

観点 POJO Lombok Record
コード量 多い(約40行) 少ない(約5行) 最小(1行)
外部依存 なし lombok必要 なし(Java 16+)
イミュータブル 手動で実装 @Valueで自動 自動
継承 可能 可能 不可
getter命名 getXxx() getXxx() xxx()
シリアライズ カスタマイズ可能 カスタマイズ可能 制限あり
IDE対応 完全 プラグイン必要 完全(Java 16+)
パターンマッチング 非対応 非対応 対応

選択の指針

flowchart TD
    A[データクラスが必要] --> B{Java 16以上?}
    B -->|No| C{Lombokを使用可能?}
    B -->|Yes| D{継承が必要?}
    
    C -->|Yes| E[Lombok @Value]
    C -->|No| F[従来のPOJO]
    
    D -->|Yes| G{Lombokを使用可能?}
    D -->|No| H[Record推奨]
    
    G -->|Yes| E
    G -->|No| F
ケース 推奨
Java 16+でイミュータブルなデータクラス Record
継承が必要な場合 POJO or Lombok
Java 15以前 Lombok or POJO
ライブラリ依存を避けたい Record or POJO

DTOやValue Objectとしての活用

Recordは、DTO(Data Transfer Object)やValue Object、APIレスポンスの型として最適です。

DTOとしての活用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// リクエストDTO
record CreateUserRequest(
    String name,
    String email,
    int age
) {
    public CreateUserRequest {
        Objects.requireNonNull(name, "名前は必須です");
        Objects.requireNonNull(email, "メールアドレスは必須です");
        
        if (!email.contains("@")) {
            throw new IllegalArgumentException("無効なメールアドレス形式です");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("年齢は0〜150の範囲で指定してください");
        }
    }
}

// レスポンスDTO
record UserResponse(
    long id,
    String name,
    String email,
    LocalDateTime createdAt
) {
    // エンティティからDTOへの変換
    public static UserResponse from(User entity) {
        return new UserResponse(
            entity.getId(),
            entity.getName(),
            entity.getEmail(),
            entity.getCreatedAt()
        );
    }
}

Value Objectとしての活用

Value Objectは、同一性ではなく値で等価性を判断するオブジェクトです。Recordのequals()/hashCode()実装はこの用途に最適です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// メールアドレスValue Object
record EmailAddress(String value) {
    private static final Pattern EMAIL_PATTERN = 
        Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$");
    
    public EmailAddress {
        Objects.requireNonNull(value, "メールアドレスは必須です");
        if (!EMAIL_PATTERN.matcher(value).matches()) {
            throw new IllegalArgumentException("無効なメールアドレス形式: " + value);
        }
    }
    
    public String domain() {
        return value.substring(value.indexOf('@') + 1);
    }
}

// 金額Value Object
record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "金額は必須です");
        Objects.requireNonNull(currency, "通貨は必須です");
        
        if (amount.scale() > currency.getDefaultFractionDigits()) {
            throw new IllegalArgumentException("小数点以下の桁数が多すぎます");
        }
    }
    
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("通貨が異なるため加算できません");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
    
    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }
}

ジェネリックRecordの活用

Recordはジェネリクスをサポートしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// APIレスポンスのラッパー
record ApiResponse<T>(
    boolean success,
    T data,
    String message,
    LocalDateTime timestamp
) {
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, data, null, LocalDateTime.now());
    }
    
    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(false, null, message, LocalDateTime.now());
    }
}

// ページネーション結果
record PageResult<T>(
    List<T> items,
    int page,
    int size,
    long totalElements,
    int totalPages
) {
    public PageResult {
        items = List.copyOf(items);  // 防御的コピー
    }
    
    public boolean hasNext() {
        return page < totalPages - 1;
    }
    
    public boolean hasPrevious() {
        return page > 0;
    }
}

Sealed Interfaceとの組み合わせ

Java 17のSealed Interfaceと組み合わせることで、型安全な代数的データ型を表現できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 処理結果を表すSealed Interface
sealed interface Result<T> permits Success, Failure {
    // 共通のメソッド
    default boolean isSuccess() {
        return this instanceof Success;
    }
}

record Success<T>(T value) implements Result<T> {}
record Failure<T>(String errorCode, String message) implements Result<T> {}

// 使用例
public class ResultExample {
    public static Result<User> findUser(long id) {
        if (id <= 0) {
            return new Failure<>("INVALID_ID", "無効なユーザーIDです");
        }
        // ユーザー検索ロジック
        User user = new User(id, "田中太郎");
        return new Success<>(user);
    }
    
    public static void main(String[] args) {
        Result<User> result = findUser(1L);
        
        // パターンマッチング(Java 21+)
        switch (result) {
            case Success<User>(var user) -> 
                System.out.println("ユーザー: " + user.name());
            case Failure<User>(var code, var msg) -> 
                System.out.println("エラー[" + code + "]: " + msg);
        }
    }
}

ローカルRecordの活用

メソッド内でRecordを定義することで、中間データを型安全に扱えます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class SalesReport {
    public List<String> generateReport(List<Sale> sales) {
        // ローカルRecord定義
        record MerchantSales(String merchantName, BigDecimal totalSales) {}
        
        return sales.stream()
            .collect(Collectors.groupingBy(
                Sale::merchantName,
                Collectors.reducing(
                    BigDecimal.ZERO,
                    Sale::amount,
                    BigDecimal::add
                )
            ))
            .entrySet().stream()
            .map(e -> new MerchantSales(e.getKey(), e.getValue()))
            .sorted((a, b) -> b.totalSales().compareTo(a.totalSales()))
            .map(ms -> String.format("%s: ¥%s", 
                ms.merchantName(), 
                ms.totalSales().toPlainString()))
            .collect(Collectors.toList());
    }
}

まとめ

JavaのRecordは、イミュータブルなデータクラスを簡潔に定義するための強力な機能です。

Recordの利点

利点 説明
ボイラープレート削減 コンストラクタ、アクセサ、equals()hashCode()toString()が自動生成
イミュータブル保証 フィールドはprivate finalで変更不可
可読性向上 宣言的な構文でデータ構造が明確
型安全 ジェネリクス、Sealed Interface、パターンマッチングと連携

Recordが適しているケース

  • DTO(Data Transfer Object)
  • Value Object
  • APIリクエスト/レスポンス
  • 設定値の集約
  • 中間計算結果の保持

Recordが適さないケース

  • ミュータブルな状態が必要な場合
  • 継承が必要な場合
  • JPAエンティティ(Java標準のJPA実装では非推奨)

Recordを活用することで、より簡潔で保守性の高いJavaコードを書けるようになります。ぜひ日々の開発に取り入れてみてください。

参考リンク