Java Records: いつ使うべきか、いつ避けるべきか
1. 概要
Java 14前は、不変データのみを含むclassが必要なら、constructorやgetter、equals()、hashCode()、toString()を自分で書く必要がありました。何度も繰り返すボイラープレートです。Recordはまさにこの問題を解決します。1行で宣言するだけで、Javaがすべてのmethodやaccessorを自動生成します。
この記事では、recordの基本的な構文、validationのカスタマイズ方法、通常のclassやLombokとの比較、そして何よりいつrecordを使うべきか、いつ避けるべきかを説明します。読み終わると、実際のSpring Bootアプリケーションでrecordを正しく適用できるようになります。
2. Recordとは
Recordは特別なclassの一種で、不変データをモデル化するために使われます。recordを宣言することは、その「コンポーネント」を定義することと同じです。
public record Point(int x, int y) {
}
この1行で、compilerが自動生成するのは:
xとyを受け取るConstructor。x()とy()のaccessorメソッド(getプレフィックスなし)。- すべてのcomponentに基づいた
equals()とhashCode()。 Point[x=1, y=2]の形式のtoString()。
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1);
System.out.println(p1.equals(p2));
System.out.println(p1.x());
Point[x=1, y=2]
true
1
注意: Recordは自動的にfinalです。recordを拡張することはできず、そのフィールドもfinalなので、初期化後に再割り当てできません。
3. Recordのコンポーネント
3.1. Constructorと検証
Recordには正規constructor(canonical constructor)が自動生成されますが、validationを追加するために上書きできます。これをcompact constructorと呼びます。パラメータを再度宣言する必要がありません:
public record Order(String id, BigDecimal amount) {
public Order {
if (amount == null || amount.signum() < 0) {
throw new IllegalArgumentException("amountは>= 0である必要があります");
}
}
}
public Order { ... }の行はパラメータリストを持たず、recordのパラメータと共有されます。Validationはフィールドが割り当てられる前に実行されるため、オブジェクト作成時点で不正なデータをブロックできます。
new Order("O1", new BigDecimal("-10"));
Exception in thread "main" java.lang.IllegalArgumentException: amountは>= 0である必要があります
3.2. カスタムメソッドの追加
Recordは通常のclassと同様に、methodやstatic field、static methodを含むことができます:
public record Money(String currency, BigDecimal value) {
public Money add(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalStateException("異なる通貨です");
}
return new Money(currency, value.add(other.value));
}
public static Money zero(String currency) {
return new Money(currency, BigDecimal.ZERO);
}
}
valueはfinalなので、add()メソッドは現在のオブジェクトを変更しません。新しいMoneyを返します。これがまさにrecordが目指す不変のスピリットです。
3.3. Interfaceの実装
Recordはinterfaceを実装できます(ただし、他のclassを拡張することはできません):
public interface Shape {
double area();
}
public record Rectangle(double width, double height) implements Shape {
@Override
public double area() {
return width * height;
}
}
4. Spring BootでのRecord: DTOとrequest/response
Recordの最も一般的なユースケースはDTO(REST API経由で送受信されるデータ)です。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CustomerRequest(
@NotBlank String name,
@Email String email
) {
}
@RestController
@RequestMapping("/customers")
public class CustomerController {
@PostMapping
public ResponseEntity<CustomerResponse> create(@Valid @RequestBody CustomerRequest request) {
Customer saved = new Customer(1L, request.name(), request.email());
return ResponseEntity.ok(new CustomerResponse(saved.id(), saved.name()));
}
}
public record CustomerResponse(Long id, String name) {
}
Spring Bootで事前に構成されているJacksonは、正規constructorを通じてJSONをrecordに逆シリアライズします。setterが不要です。Bean Validation annotationは、recordのパラメータに直接配置されます。Springがannotationを自動認識するため、通常どおり機能します。
POST /customers
{"name": "", "email": "a@b.com"}
HTTP 400 Bad Request
注意: recordをJPA entityとして使用する場合、問題が発生します。JPAはmutableなentityが必要で、no-arg constructorを持つ必要があり、lazy loadingが機能するためのプロキシをサポートする必要があります。Recordはこれらを満たすことができないため、recordを@Entityとして使用しないでください。
5. Recordと通常のclassの比較
| 基準 | 通常のclass | Record |
|---|---|---|
| ボイラープレート | 手動でgetter、equals、hashCode、toStringを記述 |
自動生成 |
| 変更可能性 | 可能 | できない(フィールドは常にfinal) |
| 継承 | 他のclassを拡張できる | 拡張できない(implicit final) |
| 適している用途 | Entity、stateが変更されるclass | DTO、value object、不変データ |
同じvalue object FullNameでの具体的な比較:
// 古い書き方
public final class FullNameOld {
private final String first;
private final String last;
public FullNameOld(String first, String last) {
this.first = first;
this.last = last;
}
public String getFirst() { return first; }
public String getLast() { return last; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof FullNameOld other)) return false;
return Objects.equals(first, other.first) && Objects.equals(last, other.last);
}
@Override
public int hashCode() {
return Objects.hash(first, last);
}
@Override
public String toString() {
return "FullNameOld[first=" + first + ", last=" + last + "]";
}
}
// recordでの書き方
public record FullName(String first, String last) {
}
この2つのclassは動作的に同等ですが、recordは非常に短く、equals/hashCodeを誤って記述する可能性がありません(手動でコードを書くときの典型的なエラー)。
6. RecordとLombok @Valueの比較
多くのチームはboilerplateを減らすためにLombokを使用しています。これら2つの方法は同様の問題を解決しますが、本質的に異なります:
// Lombok
@Value
public class ProductDto {
String sku;
BigDecimal price;
}
// Record
public record ProductDto(String sku, BigDecimal price) {
}
主な違い:
- RecordはnativeなJava機能です。追加の依存関係やannotationプロセッサが不要です。
- Recordはsku()形式のaccessorを自動生成します(getがありません)。一方Lombokはgetsku()を生成します。
- Recordは継承やフィールドの任意追加をサポートしません。これはLombokのようなbuilder patternほど簡単ではありません。
注意: プロジェクトがLombokを広く使用しており、多くのオプショナルフィールドを持つDTOに@Builderが必要な場合は、その場合Lombokを保持することができます。RecordはシンプルなDTO、フィールドが少ない、builderが不要な場合により適切です。
7. Recordを使うべき場合
次のような場合にrecordを使用してください:
- 本質的に不変なデータ:座標、金額範囲、時間範囲、計算結果。
- layer間で送信されるDTO:APIのrequest/response、クエリから返されたデータ、キューを通じて送信されるメッセージ。
- domain model内のvalue object:
Money、EmailAddress、Coordinatesなど。値で比較されるもので、identityではなく。 - 処理ロジック内の一時的な結果:methodから返される複数の値を集めるが、複雑なclassを個別に作成する必要がない場合。
public record ValidationResult(boolean valid, List<String> errors) {
public static ValidationResult ok() {
return new ValidationResult(true, List.of());
}
}
8. Recordを使用すべきでない場合
8.1. JPA entity
セクション4で述べたように、JPAはmutableなentityが必要で、no-arg constructorを持つ必要があり、lazy loadingのためのプロキシをサポートする必要があります。Recordはこれらを満たすことができません。
// このようにしてはいけません
@Entity
public record Customer(@Id Long id, String name) {
}
Hibernateはrecordのプロキシを作成しようとするときエラーが発生するか、予期しない動作が発生します。通常のclassを使用するか、Lombokの@Entity + @Getter/@Setterをentityのために使用してください。
8.2. 状態を頻繁に変更する必要があるオブジェクト
オブジェクトのstateが何度も変更される場合(例えば、ShoppingCartが継続的にアイテムを追加/削除する)、それをrecordに強制することは、変更するたびに新しいオブジェクトを作成する必要があり、効率が悪く、不自然です。
// 不合理:アイテムを追加するたびに新しいcartを作成
public record ShoppingCart(List<Item> items) {
public ShoppingCart addItem(Item item) {
List<Item> newItems = new ArrayList<>(items);
newItems.add(item);
return new ShoppingCart(newItems);
}
}
この場合、List
8.3. 継承が必要な場合
Recordはimplicitly finalで、他のclassを拡張することはできません。interfaceを実装するだけです。domain modelがclassの階層が必要な場合(Employee → Manager、Developerなど)、recordは適切ではありません。通常のclassを使用するか、sealed interface/classを使用してください。各リーフブランチのrecordを組み合わせます。
public sealed interface Shape permits Circle, Square {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
これはrecordの合理的な使用方法です。hierarchy内で、recordが各リーフブランチを不変にし、sealed interfaceが集合とサブタイプを管理します。
8.4. 複雑なserialization互換性が必要な場合
RecordはSerializableを実装できます。ただし、カスタムserializationが複雑で、versioning、backward compatibilityが何年も必要な場合は、通常のclassを使用する方がより詳細な制御を持つため、より安全です。
9. Recordのテスト
equals()とhashCode()がcomponentに基づいて自動生成されるため、テストは非常にシンプルです。AssertJを使用してください:
class MoneyTest {
@Test
void shouldBeEqualWhenSameValues() {
Money a = new Money("VND", new BigDecimal("1000"));
Money b = new Money("VND", new BigDecimal("1000"));
assertThat(a).isEqualTo(b);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
@Test
void shouldThrowWhenAddingDifferentCurrency() {
Money vnd = new Money("VND", BigDecimal.TEN);
Money usd = new Money("USD", BigDecimal.ONE);
assertThatThrownBy(() -> vnd.add(usd))
.isInstanceOf(IllegalStateException.class)
.hasMessage("異なる通貨です");
}
}
assertThat(a).isEqualTo(b)は自動生成されたequals()の正しい動作をチェックします。コンポーネント値が同じ2つのrecordは、メモリ内の異なるインスタンスであっても等しくなければなりません。
10. 結論
Recordは不変データのためにJavaが提供する最高のツールです。少ないコード、少ないエラー、明確な意図があります。DTO、value object、一時的な戻り値として使用してください。JPA entity、状態が継続的に変更されるオブジェクト、継承による複雑なclass hierarchyが必要な場合は使用しないでください。正しいツールを選択し、正しい目的のために使用します。recordは通常のclassを完全には置き換えませんが、特定の問題グループのためのより簡潔な選択肢を補完します。