「ドメイン駆動設計入門」では、氏名を題材に値オブジェクトの説明をしています。
本書ではC#で記述されていますが、Javaのコードにて書き換えたものを提示しながら説明します。
値オブジェクトとは
どんなシステムでも氏名を扱うことはよくあることだと思います。
// フルネームの表示を行う
String fullName = "yamada tarou";
System.out.println(fullName);
では、「姓」だけ扱いたいシチュエーションがあったとします。
以下のように修正したとします。
// 姓の出力を行う
String fullName = "yamada tarou";
String[] nameParts = fullName.split(" "); // ["yamada", "tarou"]という配列に
System.out.println(nameParts[0]); // 先頭の"yamada"を出力
姓の出力の実装ができました。
ただ、上記のソースは場合によっては期待通りに動作しない場合があります。
以下のように、「姓」と「名」が逆だった場合はどうでしょうか。
// 姓の出力を行う
String fullName = "john smith"; // ★姓は「smith」
String[] nameParts = fullName.split(" "); // ["john", "smith"]という配列に
System.out.println(nameParts[0]); // 先頭の"john"を出力
意図しない「名」が出力されることとなりました。
ただ、このようなケースはオブジェクト指向ではクラスを採用することで、解決することが可能です。
基本的には以下のように「姓」、「名」を分けて保持することが多いのではないかと思います。
public class FullName {
private String firstName;
private String lastName;
public FullName(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
}
上記を利用すれば、姓(lastName)を利用してどのような氏名(FullName)の構成でも、正しく「姓」が取得できます。
// 正しく「姓」(smith)を出力
FullName name = new FullName("john", "smith");
System.out.println("姓: " + name.getLastName());
少し回りくどい説明になりましたが、ここで理解したいのは値にはその値の性質に合った表現の仕方が存在するということです。
先に提示した具体例で言えば、「氏名」から「姓」のみを取得する場合、空白で区切られた文字列の最初の文字列が必ず「姓」になるとは限らないということになります。
上記のように、値によってそのシステムで扱われるべき表現の仕方があります。
ドメイン駆動設計において、このような値(氏名)はオブジェクトであり、値でもあるので、値オブジェクトと言います。
値オブジェクトの性質
値オブジェクトには性質が3つあると本書には記載されています。
- 不変である
- 交換が可能である
- 等価性によって比較される
1つずつ確認していきます。
不変である
?「不変?そんなことないでしょ。」
?「結婚したら、姓が変わることもあるし、名前だって成人すれば、変えることは可能だよ。」
String name = "tarou";
System.out.println(name); // "tarou"が出力
name = "kenji";
System.out.println("kenji"); // "kenji"が出力
?「ほーらね、値が変わってる。」
・・・はい、ちょっと待ってください。
上記のコードは値自体が変わっているのではなく、値の参照先が変わっているというのが正しいです。
"tarou"という文字列リテラルがメモリ上に展開され、参照先が「name」という変数に格納されます。
その後、"kenji"という文字列リテラルが追加でメモリ上に展開され、参照先が「name」という変数に上書きされます。
つまり、値自体は変更されていないのです。
JavaにおけるString
Javaにおいて、String は 不変(immutable)オブジェクトであり、一度作られた文字列の内容が書き換えられることはありません。
交換が可能である
上記の「値が不変である」という性質を見て、じゃあどうやって値更新したりすんねん!と思った方もいるのではないでしょうか。
大丈夫です。先ほどの説明で出てきたように、値を交換しましょうということです。
氏名の値オブジェクトであれば、新しいオブジェクトを生成し、上書き(くどいですが、参照先を変える)して、値を交換します。
FullName name = new FullName("tarou", "yamada");
name = new FullName("tarou", "tanaka"); // 姓が変わる
上記では、インスタンス自体を新しく生成して、変数「name」の値を交換しています。
最初に生成したインスタンス(値オブジェクト)の値は変えていないです。繰り返しますが、交換したのです。
個人的なモヤモヤ
個人的には、「値が不変である」、「交換が可能である」の説明は少しモヤっとするんです。
確かに値は変わらないというのは、そうだと思いますし、辻褄が合わない説明ではないのですが、なんだか腑に落ちない。
「エリック・エヴァンスのドメイン駆動設計」などを読むと、詳しく書いてあるんですかね・・・
腑に落ちる説明や、ご自身なりの解釈はぜひともコメントで記載してもらえると嬉しいです!
モヤっとする方も含め、そういう性質であるという理解で進めてもらえると良いかと思います。
等価性によって比較される
値オブジェクトはあくまで「値」なので、値オブジェクト同士の比較は実行できるのが正しいです。
System.out.println(0 == 0); // true
System.out.println("hello".equals("hello")); // true
FullName user1 = new FullName("tarou", "yamada");
FullName user2 = new FullName("tarou", "yamada");
System.out.println(user1.equals(user2)); // true
Javaにおけるクラスの等価性
特になにも対応していない場合、クラス同士のequalsメソッドでの比較は、参照先の比較(Objectクラスのequalsメソッド)になるため、参照比較となります。
そのため、以下のような実装がFullNameクラスで必要になるので注意してください。
public class FullName {
// 中略
// Objectsクラスのequalsメソッドをオーバーライド
@Override
public boolean equals(Object obj) {
// 同じ参照であれば、等価
if (this == obj) return true;
// 生成されたクラスが違うなら不等価
if (obj == null || getClass() != obj.getClass()) return false;
FullName other = (FullName) obj;
// 「姓」と「名」が合致しているかで等価性を判断
return Objects.equals(firstName, other.firstName) &&
Objects.equals(lastName, other.lastName);
}
}
値オブジェクトにする基準
ドメイン駆動設計において、考えなしに、なんでもかんでも値オブジェクトにするべきではないです。
以下のようにたくさん項目が出てくる場合を考えてみてください。
これらすべてを値オブジェクトとして扱うために、一つずつクラスを定義して、、、とするのは頭が痛くなります。
- 氏名
- 姓
- 名
- 生年月日
- 年
- 月
- 日
- 住所
- 郵便番号
- 都道府県
- 市区町村
- 番地
- 建物名
- 部屋番号
- etc・・・
判断基準は2つあると記載があります。
- そこにルールが存在しているか
- それ単体で取り扱いたいか
引用
少しつかれたので、サボります。
私は、どちらかというとソースを書きたいのです。笑
以下は具体的に氏名についての値オブジェクトとして扱うかどうかの考え方の引用です。
例えば、氏名には「姓と名で構成される」というルールが存在します。また、本文で例示したように単体で取り扱っています。筆者の判断基準に照らし合わせると氏名は値オブジェクトとして定義されます。
では、姓や名はどうでしょうか。いまのところ、姓や名にシステム上の制限はありません。姓だけ受け取ったり、名だけを利用したりというシーンはありません。筆者の判断基準からするとこれは、値オブジェクトにしないでしょう。
ふるまいを持った値オブジェクト
値オブジェクトには固有のふるまいを定義することが可能です。
値オブジェクトはいままで見てきた通り、クラスなので、ふるまいを自由に変えることができるのは当たり前です。
これは、プリミティブな値では表現が難しいです。
個人的に上記はかなり強力だと思います。なるほどなと思いました。
おそらくいままでもなんとなくやってはいたのですが、改めて言われると目からウロコでした。
お金の金額計算が例として記載されています。
public class Monny {
// 金額
private final BigDecimal amount;
// 通貨
private final String currency;
// コンストラクタ
public Monny(BigDecimal amount, String currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("引数が不正です。");
}
this.amount = amount;
this.currency = currency;
}
// 金額加算のためのメソッド
public Monny add(Monny other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("通貨が異なるため加算できません。");
}
return new Monny(this.amount.add(other.amount), this.currency);
}
}
使用例は以下です。
public class Main {
public static void main(String[] args) {
Monny jpy1 = new Monny(new BigDecimal("1000"), "JPY");
Monny jpy2 = new Monny(new BigDecimal("500"), "JPY");
Monny result = jpy1.add(jpy2);
System.out.println(result); // JPY 1500
Monny usd = new Monny(new BigDecimal("100"), "USD");
// Monny error = m1.add(usd); // IllegalArgumentException がスローされます
}
}
Monny.javaはお金の計算を表現した値オブジェクトの実装クラスです。
4つポイントを説明しておきます。
①不変というルールが表現できている
まず思い出してください。値オブジェクトは不変な値であるというルールがありました。
上記のMonnyクラスを見てみると、addメソッドで加算した際の結果は新しいインスタンスとして返しています。
これは、値を更新していません。仮に値を更新していたら、以下のようになるはずです。
Monny jpy1 = new Monny(new BigDecimal("1000"), "JPY");
Monny jpy2 = new Monny(new BigDecimal("500"), "JPY");
jpy1.add(jpy2); // この時点でjpy1が加算後の値となる。jpy1 = [1500, JPY]的な
ただ、上記のようなコードを許可すると、jpy1は値が変わっていることになります。
これは、値オブジェクトの性質に反します。
新しいインスタンスを返すことで、値として自然なふるまいとなります。
値は以下のようにあるべきです。
int sum1 = 1 + 2; // プリミティブな値のふるまい
BigDecimal a = new BigDecimal("20");
BigDecimal b = new BigDecimal("30");
BigDecimal sum2 = a.add(b); // 数値を新しいインスタンスで返す。20や30という値は変えない。
②不正な値を存在させない
Monnyクラスでは、コンストラクタで、nullを許容しないようになっています。
別のルールを考えてみましょう。
本来であれば、お金がマイナスであるという状態は現実世界ではあり得ないです。(借金している状態をマイナスとかかそういうのはここではなしとします。)
以下のようにコンストラクタを定義しておけば、不正な値をそもそも保持させないようにできます。
public class Monny {
// 中略
// コンストラクタ
public Monny(BigDecimal amount, String currency) {
if (amount == null || currency == null) {
throw new IllegalArgumentException("引数が不正です。");
}
// マイナスを許容しないルール
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("金額は 0 以上である必要があります。");
}
this.amount = amount;
this.currency = currency;
}
}
これはシステムにとって利益になります。
値の整合性の確認(値チェック、バリデーション等)はシステムにとって必要ですが、インスタンスの生成時にチェックすることで、チェック処理の散在を防ぎ、そもそも不正な値を扱わせないことに繋がります。
想定外の値が入り、エラーになるなんてことは、システム開発においてよくあることです。
想定外の値を防ぐことは、バグが埋もれることを防ぐことに繋がるのではないでしょうか。
③値オブジェクトがルールを表現している
このMonny.javaで実装された値オブジェクトはルールを表現できています。
仮に、あなたがとあるプロジェクトにアサインされ、初めてMonnyクラスを眺めたとき、以下のことが伝わるのではないでしょうか。
- Monnyクラスには「金額」と「通貨」の情報を持つ
- Monnyクラスを生成する際は、Nullは許容されない
- 通貨の加算はできる
- 通貨の種類が異なると加算することはできない
- (さらに言えば)通貨って掛け算や割り算はできない
1,2,3,4はわかると思います。
5に関してはどうでしょうか。
定義されないからこそわかることだと筆者は言っています。
定義しないことで、暗にそのようなふるまいは許可されないことを伝える事ができます。
フィールドだけ定義したMonny.javaがあったとしたら、それは開発者にとってなにも情報を与えてくれない無口な値オブジェクトになります。
新規参画したあなたは、周りのエンジニアやドキュメントを読み漁る必要が出てくることでしょう。
④コードの散在を防ぐ
addメソッドをユーティリティメソッドや業務ロジックとして、Monnyクラス以外に記載したらどうでしょうか。
動作自体は特に変わらず、問題はないでしょう。
以下は処理実行クラスに、addメソッドを移植してみた例です。
public class Main {
public static void main(String[] args) {
Monny jpy1 = new Monny(new BigDecimal("1000"), "JPY");
Monny jpy2 = new Monny(new BigDecimal("500"), "JPY");
Monny result = this.add(jpy1, jpy2); // 実行クラスに定義したメソッド
System.out.println(result); // JPY 1500
}
// 実行クラスに加算用のメソッドを切り出す
public Monny add(Monny a, Monny b) {
if (!a.getCurrency().equals(b.getCurrency())) {
throw new IllegalArgumentException("通貨が異なるため加算できません: " + a.getCurrency() + " vs " + b.getCurrency());
}
BigDecimal total = a.getAmount().add(b.getAmount());
return new Monny(total, a.getCurrency());
}
}
ただ、将来的にお金の加算処理は、ここだけに済むでしょうか?
他の処理実行クラスでもお金の加算の処理が必要になる可能性はないでしょうか?
上記の実装がそのままリリースされた後、同様のメソッドが必要となった場合、あなたはこのメソッドをMonnyクラスに戻しますか?もしくは、全く同じメソッドを異なるクラスに定義するでしょうか・・・
そんなことを考えると、恐ろしいです。
その値オブジェクトのルールは、値オブジェクト自身に任せてしまうことで、ロジックの散在を防ぐことが可能です。
DRY原則1を守ることに繋がる、良い実装方針と言えるのではないでしょうか。
本書では、もう少し、値オブジェクトを利用するメリットについて紹介していますが、すでにかなり長い内容になっているので、割愛します。
Chapter2の実践
さて、実践!
と行きたいところですが、疲れてきたので、以下の更新はまた今度にします。
気長にお待ち下さい。🙏
Commin Soon...
記事一覧
-
DRY原則(ドライげんそく)とは、ソフトウェア開発の設計原則の一つで、「Don't Repeat Yourself(繰り返すな)」の略です。これは、同じ機能やロジックを複数箇所で重複して記述するのを避け、1箇所にまとめて記述することを推奨する原則です。 ↩