「ドメイン駆動設計入門」では、氏名を題材に値オブジェクトの説明をしています。
本書では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の実践
さて、実践!
一覧記事の方に、題材とする機能を記載しているので、こちらに沿って今回学んだ値オブジェクトをソースに落とし込んでいきます。
実際のコードを書いていきますが、値オブジェクトにするかどうかの議論は要件によって変化すると考えています。
どこまでをドメインオブジェクトとするかは本書でも難しいとの記載があります。
こういう書き方もあるんだなという広い心で見ていただけると嬉しいです。
考慮するドメイン
今回、登場するドメインとしては以下が考えられると思います。
- ユーザ
- サークル
また、これらには以下のドメインオブジェクトが必要です。
- ユーザ
- ユーザID(ユーザをシステム上で識別するためのID)
- ユーザ名
- サークル
- サークルID(サークルをシステム上で識別するためのID)
- サークル名
さて、上記で値オブジェクトとして扱うのは、どの項目になるでしょうか。考えてみてください。
ユーザ、サークルについて
結論からになりますが、ユーザ、サークルは値オブジェクトとしては定義をしません。
値オブジェクトは、あくまで値です。
「身長」というのは値と言えますね。
「年齢」も値と言えそうです。
では、ユーザは値ですか・・・?
どちらかというと、ユーザやサークルはシステムにおいて、「ドメインオブジェクト」、「オブジェクト」、「エンティティ」などと呼ばれる部類になるでしょう。
実は、次のChpater3にて紹介しますが、これらはドメイン駆動設計において「エンティティ」として定義されます。
次回に扱う内容ですので、今回は値オブジェクトを探すことに専念します。
サークル名について
では次に、サークル名はどうでしょうか。
思い出してください。サークル名には以下のルールがありました。
- サークル名は3文字以上、20文字以下
- サークル名はすべてのサークルで重複しない
上記の内容はドメインオブジェクトのルールと言えそうなので、値オブジェクトとして扱います。
サークルID
要件には、サークルIDに関するルールの記載はありません。
ただ、一般的にエンティティの識別を行う際は、そのサークルであることを判別するために、一意となるIDを用意する必要がありそうです。
また、このIDが空である、つまり、一意のIDが決まらないオブジェクトはプログラム上で扱いづらいです。
不正な値を存在させないでも紹介した通り、IDが空でオブジェクトを生成させるのを防げば、不具合の回避に繋がりそうです。
上記のことは、その値オブジェクトのルールと言えるでしょう。
- サークルには、サークルIDは必ず1つ決まる
上記のルールを表現するためにも、サークルIDも値オブジェクトとして扱います。
ユーザID、ユーザ名
ユーザID、ユーザ名は、サークルIDと同様の理由で値オブジェクトとして定義します。
値オブジェクトの実装
では実際に、実装をしてみましょう。
とは言っても、値オブジェクトの実装はクラスを定義するだけですので、味気ないかもしれません。
package com.example.demo.model.circle;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
/**
* サークルのサークル名を表現する値オブジェクト
*/
public class CircleName {
/**
* サークル名を保持するフィールド
* 値オブジェクトは値が変化しないので、finalとする
*/
private final String circleName;
/**
* コンストラクタ
*/
public CircleName(String value) {
if (StringUtils.isBlank(value) || value.length() < 3 || value.length() > 20) {
throw new IllegalArgumentException("サークル名は3文字以上20文字以下である必要があります。");
}
this.circleName = value;
}
/**
* getter
* @return サークル名
*/
public String getCircleName() {
return this.circleName;
}
/**
* 等価性を確認するために定義
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof CircleName)) {
return false;
}
CircleName other = (CircleName) obj;
return Objects.equals(this.getCircleName(), other.getCircleName());
}
}
package com.example.demo.model.circle;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
/**
* サークルのサークルIDを表現する値オブジェクト
*/
public class CircleId {
/**
* サークル名を保持するフィールド
* 値オブジェクトは値が変化しないので、finalとする
*/
private final String circleId;
/**
* コンストラクタ
*/
public CircleId(String value) {
if (StringUtils.isBlank(value)) {
throw new IllegalArgumentException("サークルIDがnull、または空文字です");
}
this.circleId = value;
}
/**
* getter
* @return サークルID
*/
public String getCircleId() {
return this.circleId;
}
/**
* 等価性を確認するために定義
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof CircleId)) {
return false;
}
CircleId other = (CircleId) obj;
return Objects.equals(this.getCircleId(), other.getCircleId());
}
}
ユーザ名、ユーザIDはサークルIDとほとんど変わらないです。
気になる方は以下を確認ください。
ユーザ名、ユーザIDの値オブジェクト実装
package com.example.demo.model.user;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
/**
* ユーザー名を表現する値オブジェクト
*/
public class UserName {
/**
* ユーザー名の値を保持するフィールド
* 値オブジェクトは値が変化しないので、finalとする
*/
private final String userName;
/**
* コンストラクタ
* @param value ユーザー名
*/
public UserName(String value) {
if (StringUtils.isBlank(value)) {
throw new IllegalArgumentException("ユーザー名がnull、または空文字です");
}
this.userName = value;
}
/**
* getter
* @return ユーザー名
*/
public String getUserName() {
return this.userName;
}
/**
* 等価性を確認するために定義
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof UserName)) {
return false;
}
UserName other = (UserName) obj;
return Objects.equals(this.getUserName(), other.getUserName());
}
}
package com.example.demo.model.user;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
/**
* ユーザーのIDを表現する値オブジェクト
*/
public class UserId {
/**
* ユーザーIDの値を保持するフィールド
* 値オブジェクトは値が変化しないので、finalとする
*/
private final String userId;
/**
* コンストラクタ
* @param value ユーザーID
*/
public UserId(String value) {
if (StringUtils.isBlank(value)) {
throw new IllegalArgumentException("ユーザーIDがnull、または空文字です");
}
this.userId = value;
}
/**
* getter
* @return ユーザーID
*/
public String getUserId() {
return this.userId;
}
/**
* 等価性を確認するために定義
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof UserId)) {
return false;
}
UserId other = (UserId) obj;
return Objects.equals(this.getUserId(), other.getUserId());
}
}
いくつかポイントだけ以下に記載します。
- 「値オブジェクトの性質」で紹介したように、値オブジェクトは不変ですので、フィールドはfinalとして定義します。
- また、不変であるため、セッターは定義しません。
- コンストラクタで値を受け取り、まずは値のチェックを行います。
- →上記を行うことで、不正な値を保持させるのを防ぎます。
- サークル名の重複は許されないルールがありました。そのため、重複しているか確認できるように、equalsメソッドをオーバライドしています。
- オーバライドによって、Javaの参照先の比較ではなく、値の比較を実現するためです。
参照の比較がよくわからない方は「Java プリミティブ、参照型 equals」などで検索してみてください。
以上で値オブジェクトの実装は完了です。
記事の途中でも少し触れましたが、次回は「エンティティ」についてご紹介できたらと思います。
次回の記事
記事一覧
-
DRY原則(ドライげんそく)とは、ソフトウェア開発の設計原則の一つで、「Don't Repeat Yourself(繰り返すな)」の略です。これは、同じ機能やロジックを複数箇所で重複して記述するのを避け、1箇所にまとめて記述することを推奨する原則です。 ↩
