キーポイント
-
レコードは、不変データの透過的なキャリアとして機能するクラスであり、名目上のタプルと考えられる
-
レコードは、より予測可能なコードを書くのに役立ち、複雑さを軽減し、Javaアプリケーションの品質向上に役立つ
-
レコードは、ドメイン駆動設計(DDD)の原則を適用することでイミュータブル・クラスを記述し、よりロバストかつメンテナビリティに優れたコードを作成できる
-
Jakarta Persistence仕様は、リレーショナル・データベースに対する不変性をサポートしていないが、NoSQLデータベースでは不変性を実現できる
-
同時実行ケース、CQRS、イベント駆動アーキテクチャなど、さまざまな状況でイミュータブル・クラスを活用できる
Javaのリリース・ケイデンスや最新のLTSバージョンであるJava 17に精通しているのであれば、イミュータブル・クラスを可能にするJava Record機能の利用が可能だろう。
しかし、疑問は残る。この新機能を自分のプロジェクト・コードでどのように使えるのか?すっきりとした、優れた設計にするためにどう活用すればいいのか。このチュートリアルでは、古典的なデータ転送オブジェクト(DTO)を乗り超えるため、いくつかの例を紹介する。
Javaレコードとは何か?Javaレコードをなぜ使うのか?
まず最初に、Javaレコードとは何か?レコードは、イミュータブルデータの透過的なキャリアとして機能するクラスと考えられる。レコードはJava 14のプレビュー機能として導入された(JEP 359)。
Java 15で2回目のプレビューがリリースされた後(JEP 384)、Java 16で最終版がリリースされた(JEP 395)。レコードは名目タプルと考えることもできる。
前に述べたように、より少ないコードでイミュータブル・クラスを作成できる。データを変更できないという条件で、名前、誕生日、およびこの人物が生まれた都市の3つのフィールドを持つPerson
クラスを考えてみよう。
そこで、イミュータブル・クラスを作ってみよう。同じJava Beanパターンに従って、それぞれのフィールドとともにドメインをfinal
として定義する。
public final class Person {
private final String name;
private final LocalDate birthday;
private final String city;
public Person(String name, LocalDate birthday, String city) {
this.name = name;
this.birthday = birthday;
this.city = city;
}
public String name() {
return name;
}
public LocalDate birthday() {
return birthday;
}
public String city() {
return city;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
OldPerson person = (OldPerson) o;
return Objects.equals(name, person.name)
&& Objects.equals(birthday, person.birthday)
&& Objects.equals(city, person.city);
}
@Override
public String toString() {
return "OldPerson{" +
"name='" + name + '\'' +
", birthday=" + birthday +
", city='" + city + '\'' +
'}';
}
}
上記の例では、finalフィールドとgetterメソッドを持つクラスを作成したが、メソッドの前にgetを付けることで、Java Beanの原則に従っていない点に注意してほしい。
では、イミュータブル・クラスを作るために同じ道をたどってみよう。クラスをfinalとして定義し、フィールドを定義し、コンストラクタを定義する。再現可能となったら定型文を減らすことができるだろうか?答えはイエスだ。Record構文のおかげである。
public record Person(String name, LocalDate birthday, String city) {
}
ご覧のように、1 行で数行を削減できる。class
キーワードをrecord
キーワードに置き換えて使用する事で、魔法のようなシンプルさを実現したのだ。
record
キーワードがクラスであることを強調しておく必要がある。そのため、いくつかのJavaクラスがメソッドや実装といった機能を持たせることができる。それでは、次のセッションでRecord構文の使い方を説明していく。
Data Transfer Objects(DTO)
これは最初のケースであり、インターネット上でもっともよく使われるものだ。従って、ここではあまり焦点を当てる必要はないRecordの一例であり、決して珍しいものではない。
SpringでもMicroProfileでもJakarta EEでも構わない。現在、いくつかのサンプルケースがあるので、以下に列挙する。
値オブジェクトまたはイミュータブルデータ
ドメイン駆動設計(DDD)では、値オブジェクトは問題のドメインやコンテキストの概念を表す。これらのクラスは不変であり、例えばMoney
型やEmail
型などがある。そのため、レコードとしての値オブジェクトの両方が固まれば、それらの使用が可能だ。
最初の例では、バリデーションだけが必要なEメールを作成する。
public record Email (String value) {
}
他の値オブジェクトと同様に、メソッドや振る舞いを追加できるが、結果は別のインスタンスになるはずだ。Money
型を作成し、add
オペレーションを作成してみよう。そこで、同じ通貨かどうかをチェックするメソッドを追加し、結果として新しいインスタンスを作成する。
public record Money(Currency currency, BigDecimal value) {
Money add(Money money) {
Objects.requireNonNull(money, "Money is required");
if (currency.equals(money.currency)) {
BigDecimal result = this.value.add(money.value);
return new Money(currency, result);
}
throw new IllegalStateException("You cannot sum money with different currencies");
}
}
Money
Recordは単なる例であり、主に開発者は有名なライブラリであるJoda-Moneyの使用が可能だ。重要なのは、値オブジェクトやイミュータブルデータの作成が必要な場合、それにぴったり合うRecordを使用できるという点だ。
イミュータブルなエンティティ
でも待てよ?イミュータブルなエンティティーと言ったか?そんなことが可能なのか?珍しいことだが、エンティティが歴史的な過渡期にある場合などにはあり得ることだ。
エンティティは不変でありうるか?Eric Evans氏の著書『ドメイン駆動設計』でエンティティの定義を確認してみよう。Tackling Complexity in the Heart of Software(ソフトウェアの核心にある複雑性に取り組む)。
エンティティとは、ライフサイクルを通じて連続性を持ち、アプリケーションのユーザーにとって不可欠な属性とは独立した区別を持つものである。
エンティティは変更可能かどうかではなく、ドメインに関連している。したがって、不変のエンティティを持てるが、これもまた珍しいことだ。Stackoverflowにはこの質問に関連した議論がある。
Book
という名前のエンティティを作成しよう。このエンティティはID
、タイトル
、リリース年
を持つ。ィを編集したい場合はどうなるか?編集はしない。その代わりに、新しいエディションを作成する必要がある。そのため、エディション・フィールドも追加する。
public record Book(String id, String title, Year release, int edition) {}
これでOKだが、バリデーションも必要だ。そうしないと、このBookは一貫性のないデータを持つ。id、title、releaseにnull値を持つことは、ネガティブエディションとして意味をなさない。Recordを使えば、コンパクトなコンストラクタを使い、バリデーションをかけることができる。
public Book {
Objects.requireNonNull(id, "id is required");
Objects.requireNonNull(title, "title is required");
Objects.requireNonNull(release, "release is required");
if (edition < 1) {
throw new IllegalArgumentException("Edition cannot be negative");
}
}
必要に応じて、equals()
、hashCode()
、toString()
メソッドをオーバーライドできる。実際に、equals()
とhashCode()
をオーバーライドして、id
フィールドを操作してみよう。
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Book book = (Book) o;
return Objects.equals(id, book.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
このクラスの作成を簡単にするため、またはより複雑なオブジェクトがある場合には、メソッド ファクトリを作成するか、ビルダーを定義できる。以下のコードでは、Book
Recordメソッドにビルダーを作成している。
Book book = Book.builder().id("id").title("Effective Java").release(Year.of(2001)).builder();
Recordを持つイミュータブル・エンティティの最後に、Bookを新しいエディションに変更するためのchangeメソッドも含める。次のステップでは、Joshua Bloch氏による有名な本「Effective Java
」の第2版についてみてみよう。このように、かつてこの本の初版があったという事実を変更できない。これはライブラリー・ビジネスの歴史的な部分である。
Book first = Book.builder().id("id").title("Effective Java").release(Year.of(2001)).builder();
Book second = first.newEdition("id-2", Year.of(2009));
現在のところ、Jakarta Persistence仕様は互換性の理由から不変性をサポートできないが、Eclipse JNoSQLや Spring Data MongoDBのようなNoSQL API上では調べることができる。
これらのトピックの多くを取り上げた。そこで、コード設計の形式を表す別のデザインパターンに移ろう。
ステートの実装
コード内部でフローやステートを実装する必要がある状況がある。ステート・デザイン・パターンでは、注文の時系列的な流れを維持する必要がある注文があるeコマースのコンテキストを状況を調べる。当然ながら、注文がいつリクエストされ、いつ配送され、最終的にユーザーから受け取ったかを知りたい。
最初のステップはインターフェースを作ることだ。簡単にするために、商品を表すためにStringを使うが、そのためにオブジェクト全体が必要になることがわかる。
public interface Order {
Order next();
List<String> products();
}
このインターフェイスが使えるようになったので、そのフローに従って商品を返す実装を作ってみよう。商品の変更は避けたい。したがって、読み取り専用のリストを生成するために、Recordからproducts()
メソッドをオーバーライドする。
public record Ordered(List<String> products) implements Order {
public Ordered {
Objects.requireNonNull(products, "products is required");
}
@Override
public Order next() {
return new Delivered(products);
}
@Override
public List<String> products() {
return Collections.unmodifiableList(products);
}
}
public record Delivered(List<String> products) implements Order {
public Delivered {
Objects.requireNonNull(products, "products is required");
}
@Override
public Order next() {
return new Received(products);
}
@Override
public List<String> products() {
return Collections.unmodifiableList(products);
}
}
public record Received(List<String> products) implements Order {
public Received {
Objects.requireNonNull(products, "products is required");
}
@Override
public Order next() {
throw new IllegalStateException("We finished our journey here");
}
@Override
public List<String> products() {
return Collections.unmodifiableList(products);
}
}
ステートが実装できたので、Order
インターフェイスを変更してみよう。まず、注文を開始するための静的メソッドを作成する。そして、新しい侵入のステートを持たないようにするために、新しいオーダー・ステートの実装をブロックし、今あるものだけを許可する。そこで、sealedインターフェースの機能を使うことにする。
public sealed interface Order permits Ordered, Delivered, Received {
static Order newOrder(List<String> products) {
return new Ordered(products);
}
Order next();
List<String> products();
}
できた!では、商品リストを使ってコードをテストしてみよう。ご覧のように、レコードの機能を検索するフローができた。
List<String> products = List.of("Banana");
Order order = Order.newOrder(products);
Order delivered = order.next();
Order received = delivered.next();
Assertions.assertThrows(IllegalStateException.class, () -> received.next());
イミュータブルクラスを備えたステートは、エンティティのようなトランザクションの瞬間を考え、イベント駆動型アーキテクチャでイベントを生成できる。
結論
以上だ!この記事では、Javaレコードのパワーについて説明した。Javaレコードは、メソッドの作成、コンストラクタでのバリデーション、getter、hashCode()
、toString()
のオーバーライドなど、いくつかの利点を持つJavaクラスだと述べておく。
レコード機能はDTOを超えることができる。この記事では、Value Object、immutable entity、Stateなど、いくつかの機能について説明した。
同時実行ケース、CQRS、イベント駆動型アーキテクチャなど、さまざまな場面でイミュータブル・クラスを活用できることを想像してみてほしい。レコード機能は、あなたのコード設計を無限大、そしてそれ以上のものにできる!この記事を楽しんでいただけたら幸いだ。