値オブジェクトとは
ざっくり言うと、現実世界の物をクラスで表現したもの。
エンティティと似ているが、その違いについてはエンティティの章で解説する。
例:ユーザー名
例えば、ユーザー名について考えてみる。
特に何も考えなければ、ユーザー名は以下のように定義して扱えば正常に動作する。
const userName = "yamada taro";
しかし、DDDではこれを「UserName」クラスとして定義してみてはどうか?という考え方である。
なぜならば現実世界でユーザー名はただの文字列ではないからだ。
実際のシステムでは、ユーザー名に対して何らかのルールが設けられていることが多い。
- 姓と名に分けて取り出すことがある
- 最低でも2文字以上であること(姓と名で1文字ずつ必須)
これをそのまま実装した場合と、値エンティティとして実装した場合で比較してみる。
そのまま実装した場合
名だけを取り出す時
let userName = "yamada taro";
const firstName = userName.split(" ")[1];
console.log(firstName); // 「taro」が出力
ユーザー名を設定する時
let userName: string;
const inputFirstName = "a"; // 名の入力値
const inputLastName = "b"; // 姓の入力値
if (inputFirstName.length >= 1 && inputLastName.length >= 1) {
userName = `${inputFirstName} ${inputLastName}`;
console.log(`ユーザー名が設定されました: ${userName}`);
} else {
console.error("2文字以上ではないためエラー");
}
値オブジェクトとして実装した場合
まずはUserName
クラスを定義する。
class UserName {
firstName: string;
lastName: string;
constructor(firstName: string, lastName: string) {
if (firstName.length >= 1 && lastName.length >= 1) {
this.firstName = firstName;
this.lastName = lastName;
} else {
throw new Error("2文字以上ではないためエラー");
}
}
// 名を取り出すメソッド
getFirstName() {
return this.firstName;
}
}
名だけを取り出す時
const userName = new UserName("yamada", "taro");
console.log(userName.getFirstName());
ユーザー名を設定する時
const userName = new UserName("", "taro");
値オブジェクトではこれらの実装を行い、加えて以下3つの性質を持たせる。
性質①:不変であること
値オブジェクトは不変であり、中身を変更してはいけない。
姓を変更したい場合を考える。
const userName = new UserName("yamada", "taro");
// NG(こんなメソッドは用意しない)
userName.changeFirstName("tanaka");
// ここでuserNameを使うと「tanaka taro」に変わってる
この性質によって、一度定義した「userName
」の中身は知らないところで勝手に書き換えられることなく、
常に「yamada taro」であることが保証される。
性質②:交換可能であること
値の書き換えはしてはいけないが、値の交換はしても良い。
ユーザー名を書き換える場合は次のように実装する。
let userName = new UserName("yamada", "taro");
// OK
userName = new UserName("tanaka", "taro");
性質③:等価性によって比較できること
class UserName {
// 略
// 姓と名が両方一致していれば同じものと判定
equals(userName: UserName) {
return this.firstName == userName.firstName && this.lastName == userName.lastName;
}
}
使う時
const userName1 = new UserName("yamada", "taro");
const userName2 = new UserName("tanaka", "taro");
if (userName1.equals(userName2)) {
// ユーザー名が同じ場合の処理
}
何が良いのか?
ただこのコードを見ただけだと何が良いのか分からないと思う。
ここからは開発者が受けるメリットを説明する。
- クラスそのものが仕様書になる
- 不正な値が存在しなくなる
- 誤った代入ができなくなる
- ロジックが分散せず、改修に強いコードになる
メリット①:クラスそのものが仕様書になる
例えば以下のようなユーザーIDを受け取って何か処理する関数を作りたいとする。
function doSomething(userId: string) {
// IDを使って何か処理をする
}
このとき、ユーザーIDに普通の文字列を使っていた場合
- どんなフォーマットか?
- 使ってはいけない文字があるのか?
- 長さはどれくらいか?空文字の場合はあり得るのか?
ということを、プログラムの処理を追いかけて理解する必要がある。
これが値オブジェクトとして定義されていた場合、ユーザーIDクラスを参照すれば
「1文字以下の文字列はありえない」ということが容易に分かる。
class UserId {
id: string;
constructor(id: string) {
if (id.length >= 2) {
this.id = id;
} else {
throw new Error("エラー");
}
}
}
メリット②:不正な値が存在しなくなる
上記のようにUserId
クラスを定義しておくと、「1文字以下」のようなありえないケースが存在しなくなる。
// stringでユーザーIDを持つ場合
const userId = "a"; // この時点ではエラーにならない
// UserIdクラスでユーザーIDを持つ場合
const userId = new UserId("a"); // この時点でエラーになる
そのため、「不正な値が混入し、処理しようとしたときに落ちる」ということが発生しにくくなる。
メリット③:誤った代入ができなくなる
// ユーザー名とユーザーIDをstringで持つ場合
const userId = "aaa";
const userName = userId; // 代入できる
// ユーザー名とユーザーIDをそれぞれ値オブジェクトで持つ場合
const userId = new UserId("aaa");
const userName = userId; // 型が異なるためエラーになる
メリット④:ロジックが分散せず、改修に強いコードになる
今まではユーザー名が姓と名という前提のもと話を進めてきたが、
新たな仕様として「ミドルネーム」が加わったとする。
値オブジェクトとして定義していなかった場合、
ユーザー名を作成している箇所と使っている箇所を洗い出し、全て修正していく必要がある。
もし値オブジェクトとして定義していたならば、修正箇所はUserName
クラスのみとなる。
まとめ
- 値オブジェクトとは、ざっくり言うと「現実世界の物をクラスで表現したもの」
- 性質
- 不変である
- 交換可能である
- 等価比較可能である
- メリット
- クラスそのものが仕様書になる
- 不正な値が存在しなくなる
- 誤った代入ができなくなる
- ロジックが分散せず、改修に強いコードになる