ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本の輪読会に参加しています。筆者は「Chapter 2 システム固有の値を表現する『値オブジェクト』」の発表を担当することになったので、自分なりに解釈した内容と感想をまとめました。
本書で取り扱われているサンプルコードのプログラミング言語はすべてC#が採用されています。しかし、筆者はC#に触れた経験がないため、本記事内におけるソースコードはすべてJavaを採用しました。
こんなコードはありませんか?
アプリケーションを開発していると、StringやList, Mapといった標準のクラスをそのままメソッドの返却値として利用してしまうことがあります。
public String getFullName();
しかし、本章では、必要とされる処理にしたがって、そのシステムならではの値の表現があるはずだと説いています。たとえば、単にStringクラスを返却するのではなく、そのシステムならではのFullNameクラスを作成して、それを返却するという方法です。
public FullName getFullName();
このFullNameクラスはオブジェクトでもあり、値でもあります。このように、システム固有の値を表したオブジェクトを値オブジェクトと呼びます。
値オブジェクトの性質
値オブジェクトは、下記の3つの性質を満たすように設計します。
不変である
作成したオブジェクトの中身は、後から変更できないようにします。setterなどは存在してはいけません。
利用するJavaのバージョンやライブラリによって、さまざまな不変クラスの作成方法があるのですが、それらを解説すると長くなります。このため、後日、別の記事にまとめたいと思っています。
交換が可能である
中身を後から変更できないのであれば、どのように処理の流れを実現するべきでしょうか。
それは「値オブジェクトの中身を変更」するのではなく「別の値オブジェクトを作成して代入する」ことで実現します。
// こうではない
FullName fullname = new FullName("Tanjiro", "Kamado");
fullname.setLastName("Hashibira");
fullname.setFirstName("Inosuke");
// このようにする
FullName fullname = new FullName("Tanjiro", "Kamado");
fullname = new FullName("Inosuke", "Hashibira");
等価性によって比較される
値オブジェクトの中身が同じであれば、同じオブジェクトであるとして扱います。Javaの場合、同じオブジェクトはequalsメソッドでの判定の結果がtrueになるようにします。
Java 16(プレビュー版ではJava 14)以上であれば、Recordを利用するとよいでしょう。
record FullName(String lastName, String firstName) {}
値オブジェクトのメリット
表現力を増す
単にプリミティブな値やString, List, Mapなどの標準クラスを使い回すのに比べて、値オブジェクトのためのクラスを定義すると、その構造に関する情報量が増えます。これを「自己文書化がされている」と言います。
たとえば、下記のコードでは、ModelNumberという値オブジェクトはproductCodeとbranchとlotによって構成されており、その文字列表現は半角ハイフンでつながれていることがわかります。
record ModelNumber(String productCode, String branch, String lot) {
@Override
public String toString() {
return productCode + "-" + branch + "-" + lot;
}
}
これを単に一つのStringオブジェクトで表してしまうと、文字列のどの部分がproductCode, branch, lotに相当するのかがわかりません。
不正な値を存在させない
プログラムを作成するときは、呼び出し元からどのような値が渡されてくる可能性があるのかを考慮する必要があります。しかし、値オブジェクトの作成時にルールのチェックをおこなっていれば、その後の個別の処理においては不正な値の存在を気にする必要はありません。
先述したRecordの場合は、コンパクト・コンストラクタで実装できます。
public record UserName(String value) {
public UserName {
if (value == null) {
throw new IllegalArgumentException("ユーザ名が未定義です: " + value);
}
if (value.length() < 3) {
throw new IllegalArgumentException("ユーザ名は3文字以上です: " + value);
}
}
}
ATMにおける処理の流れを想像すると分かりやすいと思います。
紙幣を取り込む際には、偽札や外国の紙幣を弾いています。このため、その後の処理では、それらの不正な値が渡ってくる可能性を考える必要がないのです。
もし、それらの不正な値を最初に弾いていなければ、不正な紙幣が渡ってくる可能性をずっと考慮した上で、最後の送金処理に至るまでチェック処理をする羽目になってしまいます。
誤った代入を防ぐ
値オブジェクトを導入すると、誤った代入を防ぐことができます。
例を挙げて説明します。
public record UserId(String value) {}
public record UserName(String value) {}
public record User(UserId id, UserName name) {}
public User createUser(UserId id, UserName name) {
return new User(name, id); // コンパイルエラー!
}
Userクラスのコンストラクタの第一引数はUserId型で、第二引数はUserName型です。渡す順番を誤った場合は、コンパイルエラーが発生し、誤りに気づくことができます。
もし、Userクラスのフィールドが両方ともStringオブジェクトである場合、逆に代入されていてもコンパイルエラーになりません。このため、誤りに気付けない可能性があります。
筆者も、過去に既存のシステムに機能追加をする際に、リファクタリングとして値オブジェクトを導入したことがあります。その際は、同じint型として書かれていた2つの異なるパラメータに対して、それぞれ別の値オブジェクトを作成して置換していきました。その結果、作業を誤っていないのにコンパイルエラーが発生する箇所があり、誤った代入がされていたこと(不具合が存在していたこと)が判明したという経験があります。
ロジックの散在を防ぐ
さきほどの「不正な値を存在させない」と近い内容になります。ルールを値オブジェクトの実装にまとめることで、ルールが追加・変更された際のコードの変更箇所をまとめることができます。
目まぐるしく環境が変化していく現代において、一つのソフトウェアが扱う世界も、当初の設計からはどんどん変わっていくことが前提になっています。このため、ルールを追加・変更しようとした際のコストが抑えられていることが重要であると言えます。
感想
最近、下記のツイートを見かけました。
ドメイン駆動設計(DDD)の世界には、有名なエリック・エヴァンスのドメイン駆動設計という書籍があります。しかし、レビューなどを読むと、内容が抽象的で難しすぎるという意見もあるようです。
これに対し、本書は、とても単純である値オブジェクトから説明されています。値オブジェクトの存在自体は以前から知っていましたが、今回改めて記事としてまとめてみると、その特徴やメリットが良く整理されていると感じました。このため、難しく考えすぎることなく、これまでの経験に照らし合わせて、すんなりと受け入れられました。
本書は「ボトムアップでわかる!」を謳っています。しかし、実際に個別のチームのボトムアップでドメイン駆動設計を導入しようとした際には、気をつけなければいけないことがあると思いました。それは、コードを書く人全員(少なくともコードレビュアー)が理解していなければならないという点です。設計を理解していないコードが紛れ込んだ場合、値オブジェクトのルールは破られ、ルールに関するロジックは散在し、メリットが失われてしまうためです。
このため、チームでの開発の際には、関連するコーディング規約を整備した上で、それが遵守されている環境の整備が重要なのではないかと思いました。