一般社団法人 全国個人事業主支援協会

COLUMN コラム

  • JavaのRecord型とSealed Classで実現するイミュータブル設計

モダンJavaが目指すイミュータブル設計

Java 16で正式導入されたRecord型と、Java 17で導入されたSealed Classは、Javaの型システムを大きく進化させた機能です。筆者はこの2つの機能を本番コードに導入してから約2年が経つが、バグの減少とコードの可読性向上を実感しています。本記事では、これらの機能を組み合わせてイミュータブルな設計を実現する実践的な手法を解説します。

Record型の基本と真価

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による型の制限

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型による例外レスなエラーハンドリング

関数型プログラミングでおなじみの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でも関数型言語に匹敵するレベルの型安全なイミュータブル設計が実現できます。重要なのは、これらの機能を単なるシンタックスシュガーとして使うのではなく、ドメインモデルの表現力を高めるツールとして活用することです。パターンマッチングとの連携により、コンパイル時の安全性が格段に向上する。ぜひ実際のプロジェクトで試してみてほしい。

この記事をシェアする

  • Twitterでシェア
  • Facebookでシェア
  • LINEでシェア