値オブジェクトで意味を型にする、Emailやお金をstringで持たない
値オブジェクトとは、メールアドレスや金額のような概念を専用の型に閉じ込め、生成時に検証し不変に保つ設計です。生のstringやintの取り違えを防ぎます。
stringやintは意味を持たない
メールアドレスを string、金額を int で持ち回っていると、そのうち引数の順番を取り違えたり、検証されていない生の文字列がドメインの奥まで入り込んだりします。型としては通っているのに意味としては壊れている、という状態ですね。値オブジェクトはこの「意味」を型に閉じ込めるための道具だと思っています。
final class Email
{
public function __construct(public readonly string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("不正なメール: {$value}");
}
}
}
コンストラクタで検証を通すので、Email 型を受け取った時点で「これは妥当なアドレスだ」と信じられます。検証があちこちに散らばらなくなるのが大きいですね。
不変にしておくと事故が減る
値オブジェクトは基本的に不変にします。readonly を付けて生成後に書き換えられないようにしておくと、どこかで勝手に中身が変わって混乱する事態を防げます。値を変えたいときは新しいインスタンスを作る、という発想ですね。金額のような計算が絡むものだと特に効いてきます。
final class Money
{
public function __construct(
public readonly int $amount, // 最小単位(円)で保持
public readonly string $currency = 'JPY',
) {}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new DomainException('通貨が違います');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
金額を float で持たないのも定番の注意点で、小数の丸め誤差を避けるために最小単位の整数で持つのが無難だと思います。
等価性は値で比較する
値オブジェクトは同一性ではなく値の等しさで比べたいので、比較用のメソッドを用意しておくと意図がはっきりします。中身が同じなら等しい、という当たり前を明示する感じですね。
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
まとめ
全部を値オブジェクトにする必要はないですが、検証や制約を持つ概念だけでも型にすると、コードが自分で自分を守ってくれるようになります。生の string や int が奥まで流れていくのを堰き止める防波堤、くらいの気持ちで使うのがちょうどいい気がします。
よくある質問
Q. 何でも値オブジェクトにすべきですか?
A. その必要はありません。検証や制約を持つ概念だけでも型にすると、コードが自分を守ってくれるようになります。
Q. なぜ不変にするのですか?
A. 生成後に書き換えられないようにしておくと、どこかで中身が変わって混乱する事態を防げるからです。
Q. 金額はfloatで持ってはいけませんか?
A. 小数の丸め誤差を避けるため、円などの最小単位の整数で持つのが無難です。