Java 16で正式導入されたRecord型と、Java 17で導入されたSealed Classは、Javaの型システムを大きく進化させた機能です。筆者はこの2つの機能を本番コードに導入してから約2年が経つが、バグの減少とコードの可読性向上を実感しています。本記事では、これらの機能を組み合わせてイミュータブルな設計を実現する実践的な手法を解説します。
Record型は、不変のデータキャリアを簡潔に定義するための仕組みです。従来のJavaでは、単純なデータクラスを作るだけでも大量のボイラープレートコードが必要だった。
// 従来のJavaでのデータクラス
public final class UserDto {
private final String name;
private final String email;
private final int age;
public UserDto(String name, String email, int age) {
this.name = name;
this.email = email;
this.age = age;
}
public String getName() { return name; }
public String getEmail() { return email; }
public int getAge() { return age; }
@Override
public boolean equals(Object o) { /* 省略 */ }
@Override
public int hashCode() { /* 省略 */ }
@Override
public String toString() { /* 省略 */ }
}
// Record型を使った場合
public record UserDto(String name, String email, int age) {}
たった1行で、コンストラクタ、アクセサ、equals、hashCode、toStringのすべてが自動生成されます。しかしRecord型の真価は単なるコード削減ではありません。「このクラスは不変のデータです」という設計意図をコード上で明示できる点にある。
Record型では、コンパクトコンストラクタを使ってバリデーションロジックを組み込むことができます。
public record Email(String value) {
public Email {
if (value == null || !value.contains("@")) {
throw new IllegalArgumentException(
"無効なメールアドレス: " + value
);
}
value = value.toLowerCase().trim();
}
}
public record Age(int value) {
public Age {
if (value 150) {
throw new IllegalArgumentException(
"無効な年齢: " + value
);
}
}
}
このパターンは値オブジェクト(Value Object)の実装に最適です。プリミティブ型やStringをそのまま使うのではなく、ドメインの制約をRecord型で表現することで、不正な値がシステムに入り込む余地をなくせる。
Sealed Classは、あるクラスを継承できるサブクラスを明示的に制限する機能です。これにより、型の階層構造を完全にコントロールできます。
public sealed interface PaymentMethod
permits CreditCard, BankTransfer, DigitalWallet {
}
public record CreditCard(
String cardNumber,
String holderName,
YearMonth expiry
) implements PaymentMethod {}
public record BankTransfer(
String bankCode,
String accountNumber
) implements PaymentMethod {}
public record DigitalWallet(
String walletId,
WalletType type
) implements PaymentMethod {}
この設計の利点は、PaymentMethodの取りうる型が3つに限定されることです。将来的に新しい支払い方法を追加する場合は、必ずpermits句に追加する必要があるため、型の管理が明確になります。
Sealed ClassはJava 21のパターンマッチングswitch式と組み合わせることで、真価を発揮する。
public String processPayment(PaymentMethod method, BigDecimal amount) {
return switch (method) {
case CreditCard cc ->
"カード %s で %s円を決済".formatted(
maskCardNumber(cc.cardNumber()), amount
);
case BankTransfer bt ->
"銀行振込 %s-%s に %s円を送金".formatted(
bt.bankCode(), bt.accountNumber(), amount
);
case DigitalWallet dw ->
"ウォレット %s (%s) で %s円を決済".formatted(
dw.walletId(), dw.type(), amount
);
};
}
Sealed Classを使っているため、このswitch式はdefaultケースが不要です。コンパイラがすべてのケースが網羅されていることを検証してくれます。新しいサブクラスが追加された場合、このswitch式はコンパイルエラーになるため、対応漏れを防げる。
Sealed ClassとRecord型の組み合わせは、ドメインイベントの表現に非常に適しています。
public sealed interface OrderEvent {
LocalDateTime occurredAt();
record Created(
OrderId orderId,
CustomerId customerId,
List<OrderItem> items,
LocalDateTime occurredAt
) implements OrderEvent {}
record Confirmed(
OrderId orderId,
PaymentMethod paymentMethod,
LocalDateTime occurredAt
) implements OrderEvent {}
record Shipped(
OrderId orderId,
TrackingNumber trackingNumber,
LocalDateTime occurredAt
) implements OrderEvent {}
record Cancelled(
OrderId orderId,
String reason,
LocalDateTime occurredAt
) implements OrderEvent {}
}
このパターンでは、注文に関するすべてのイベントが一つのインターフェース内に集約され、各イベントの構造が明確に定義されます。イミュータブルなRecord型で表現されているため、イベントが後から改変されるリスクもない。
関数型プログラミングでおなじみのResult型も、Sealed ClassとRecord型で自然に表現できます。
public sealed interface Result<T> {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String errorCode, String message)
implements Result<T> {}
default <U> Result<U> map(Function<T, U> mapper) {
return switch (this) {
case Success<T> s ->
new Success<>(mapper.apply(s.value()));
case Failure<T> f ->
new Failure<>(f.errorCode(), f.message());
};
}
}
例外を投げる代わりにResult型を返すことで、エラーハンドリングが型システムに組み込まれます。呼び出し側はResult型のパターンマッチングを通じて、成功と失敗の両方を明示的に処理することを強制されます。
Record型とSealed Classは強力だが、万能ではありません。Record型はフィールドの変更ができないため、JPAのエンティティには不向きです。エンティティにはライフサイクルを通じた状態変更が必要であり、ここは従来のクラスを使うべきです。
また、Record型はシリアライズフレームワークとの互換性に注意が必要です。JacksonやGsonは対応しているが、設定が必要な場合もあります。チーム内での導入時は、まずDTOやValue Objectから始めて、段階的に適用範囲を広げていくのが現実的なアプローチです。
Record型とSealed Classを組み合わせることで、Javaでも関数型言語に匹敵するレベルの型安全なイミュータブル設計が実現できます。重要なのは、これらの機能を単なるシンタックスシュガーとして使うのではなく、ドメインモデルの表現力を高めるツールとして活用することです。パターンマッチングとの連携により、コンパイル時の安全性が格段に向上する。ぜひ実際のプロジェクトで試してみてほしい。