値オブジェクト — 不変でビジネスルールを内包するドメインオブジェクト
値オブジェクトとは
前章で学んだ「ドメインオブジェクト」には、値オブジェクトとエンティティの2種類があります。本章では値オブジェクトについて詳しく見ていきます。
値オブジェクトは以下の特徴を持つドメインオブジェクトです。
- 識別子を持たない
- データとビジネスルールを持つ
- 全属性の値で同一性を判断する
- 不変である(一度作成したら変更できない)
add(Money): 同じ通貨同士のみ加算可能multiply(int): 数量を掛けて新しい Money を返す
なぜ値オブジェクトを使うのか
「プリミティブ型(int、stringなど)で十分では?」と思うかもしれません。値オブジェクトを使うメリットを見てみましょう。
メリット1:ビジネスルールをカプセル化できる
// プリミティブ型の場合:ルールが散らばる
$price = 1000;
$tax = $price * 0.1;
$total = $price + $tax; // 税込計算がコード各所に散らばる
// 値オブジェクトの場合:ルールが集約される
$price = new Money(1000, 'JPY');
$total = $price->withTax(0.1); // 税込計算がMoneyクラスに集約
メリット2:不正な値を防げる
// プリミティブ型の場合:不正な値が入りうる
$email = "invalid-email"; // バリデーションなし
// 値オブジェクトの場合:生成時にバリデーション
$email = new EmailAddress("invalid-email"); // 例外が発生
メリット3:型による安全性
// プリミティブ型の場合:引数の順序を間違えやすい
function createUser(string $name, string $email, string $phone) { ... }
createUser($email, $name, $phone); // 間違っても気づかない
// 値オブジェクトの場合:型で守られる
function createUser(UserName $name, EmailAddress $email, PhoneNumber $phone) { ... }
createUser($email, $name, $phone); // 型エラーで検出
不変性(イミュータビリティ)とは
値オブジェクトの重要な特徴の1つが**不変性(イミュータビリティ)**です。不変とは、一度作成したら内部状態を変更できないということです。
なぜ不変性が重要なのか
不変性には以下のメリットがあります。
| メリット | 説明 | 例 |
|---|---|---|
| 副作用の防止 | メソッド呼び出しで元のオブジェクトが変わらない | $total = $money1->add($money2)で$money1は変わらない |
| 予測可能性の向上 | 同じ入力なら常に同じ結果 | $money->add($other)は何度呼んでも安全 |
| スレッドセーフ | 複数のスレッドから同時アクセスしても安全 | 並行処理で状態が壊れない |
| 値の共有が安全 | 同じインスタンスを複数箇所で使える | コピー不要でメモリ効率的 |
可変(ミュータブル)vs 不変(イミュータブル)
// 可変オブジェクト(悪い例)
class MutableMoney
{
private int $amount;
public function add(MutableMoney $other): void
{
$this->amount += $other->amount; // 自身を変更
}
}
$price = new MutableMoney(1000);
$tax = new MutableMoney(100);
$price->add($tax); // $priceが1100に変更される!
// 問題:元の価格(1000円)が失われる
// 問題:予期しない場所で値が変わる可能性
// 不変オブジェクト(良い例)
final class Money
{
public function __construct(
private readonly int $amount,
) {}
public function add(Money $other): Money
{
return new Money($this->amount + $other->amount); // 新しいインスタンスを返す
}
}
$price = new Money(1000);
$tax = new Money(100);
$total = $price->add($tax); // $priceは変わらず、新しい$totalが作られる
// メリット:元の価格(1000円)が保持される
// メリット:値の変更を追跡しやすい
PHPで不変性を実現する方法
readonly修飾子を使う(PHP 8.1+)- プロパティを
privateにし、setterを作らない - 変更メソッドは新しいインスタンスを返す
finalクラスにして継承を防ぐ
値オブジェクトの実装パターン
パターン1:基本的な値オブジェクト(Money)
// final: このクラスは継承できない(値オブジェクトは継承させないのが一般的)
final class Money
{
public function __construct(
// PHP 8.1+: readonly修飾子で不変性を保証
// privateなのでクラス外から直接アクセス不可
private readonly int $amount,
private readonly string $currency,
) {
// コンストラクタでバリデーション:生成時に不正な値を防ぐ
// 値オブジェクトは「常に有効な状態」であることを保証する
if ($amount < 0) {
throw new InvalidArgumentException('金額は0以上である必要があります');
}
}
// 同一性判断:値オブジェクトは全属性で比較(エンティティは識別子で比較)
public function equals(Money $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
// 不変性:自身を変更せず、新しいオブジェクトを返す
// これにより副作用がなく、予測しやすいコードになる
public function add(Money $other): Money
{
// ビジネスルール:異なる通貨同士の加算は不可
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('通貨が異なります');
}
// 自身は変更せず、新しいMoneyを生成して返す
return new Money($this->amount + $other->amount, $this->currency);
}
public function multiply(int $multiplier): Money
{
return new Money($this->amount * $multiplier, $this->currency);
}
// 減算:加算と同じく同一通貨のみ許可する
public function subtract(Money $other): Money
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('通貨が異なります');
}
return new Money($this->amount - $other->amount, $this->currency);
}
// 割合計算:パーセント指定で金額を算出する(例: 10% 割引額)
// multiply は整数倍専用なので、率の計算は専用メソッドに分ける
// intdiv で整数除算(端数切り捨て)。丸め規則が必要ならここに集約する
public function percentage(int $percent): Money
{
return new Money(intdiv($this->amount * $percent, 100), $this->currency);
}
// 比較メソッド:金額の大小比較
public function isGreaterThan(Money $other): bool
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('通貨が異なります');
}
return $this->amount > $other->amount;
}
public function isGreaterThanOrEqual(Money $other): bool
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('通貨が異なります');
}
return $this->amount >= $other->amount;
}
// ゲッター:値を取得するためのメソッド
// プロパティを private にすることでクラス外からの読み書きを制限し、
// 意図的に公開する値だけをゲッターで提供する
public function amount(): int { return $this->amount; }
public function currency(): string { return $this->currency; }
}
パターン2:名前付きコンストラクタ(Named Constructor)
複数の生成方法がある場合、名前付きコンストラクタが便利です。
final class Money
{
private function __construct(
private readonly int $amount,
private readonly string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('金額は0以上である必要があります');
}
}
// JPYを作成する専用メソッド
public static function jpy(int $amount): self
{
return new self($amount, 'JPY');
}
// USDを作成する専用メソッド
public static function usd(int $amount): self
{
return new self($amount, 'USD');
}
// 文字列から作成
public static function fromString(string $value): self
{
// "1000 JPY" のような形式をパース
[$amount, $currency] = explode(' ', $value);
return new self((int)$amount, $currency);
}
// ... 他のメソッド
}
// 使用例
$price = Money::jpy(1000); // 意図が明確
$usdPrice = Money::usd(10);
$parsed = Money::fromString('1000 JPY');
パターン3:複雑なバリデーションを持つ値オブジェクト
final class EmailAddress
{
private function __construct(
private readonly string $value,
) {
// 複雑なバリデーションロジック
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("不正なメールアドレスです: {$value}");
}
// ドメイン制限などのビジネスルールも可能
$domain = substr($value, strpos($value, '@') + 1);
if ($domain === 'example.com') {
throw new InvalidArgumentException('example.comドメインは使用できません');
}
}
public static function fromString(string $value): self
{
return new self(trim(strtolower($value))); // 正規化してから生成
}
public function value(): string
{
return $this->value;
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(EmailAddress $other): bool
{
return $this->value === $other->value;
}
}
パターン4:範囲を持つ値オブジェクト
final class AgeRange
{
private function __construct(
private readonly int $min,
private readonly int $max,
) {
if ($min < 0 || $max < 0) {
throw new InvalidArgumentException('年齢は0以上である必要があります');
}
if ($min > $max) {
throw new InvalidArgumentException('最小値は最大値以下である必要があります');
}
}
public static function create(int $min, int $max): self
{
return new self($min, $max);
}
// 年齢が範囲内かチェック
public function contains(int $age): bool
{
return $age >= $this->min && $age <= $this->max;
}
// 範囲が重なっているかチェック
public function overlaps(AgeRange $other): bool
{
return $this->min <= $other->max && $other->min <= $this->max;
}
public function min(): int { return $this->min; }
public function max(): int { return $this->max; }
}
使用例
$money1 = new Money(1000, 'JPY');
$money2 = new Money(1000, 'JPY');
$money3 = new Money(1000, 'USD');
$money1->equals($money2); // true ← 全属性(amount, currency)が同じ
$money1->equals($money3); // false ← currencyが違う
// 不変性:addは新しいオブジェクトを返す
$total = $money1->add($money2); // new Money(2000, 'JPY')
// $money1は変更されない(まだ1000円)
実践パターン:ShippingAddress(配送先住所)
final class ShippingAddress
{
public function __construct(
private readonly string $prefecture,
private readonly string $city,
private readonly string $street,
) {
if (empty($prefecture) || empty($city) || empty($street)) {
throw new InvalidArgumentException('住所の各項目は必須です');
}
}
public function fullAddress(): string
{
return $this->prefecture . $this->city . $this->street;
}
public function equals(ShippingAddress $other): bool
{
return $this->prefecture === $other->prefecture
&& $this->city === $other->city
&& $this->street === $other->street;
}
// ゲッター
public function prefecture(): string { return $this->prefecture; }
public function city(): string { return $this->city; }
public function street(): string { return $this->street; }
}
識別子も値オブジェクト
エンティティ(次章で詳しく説明)の識別子(ID)も値オブジェクトとして実装することで、型安全性を高められます。
final class OrderId
{
public function __construct(private readonly int $value)
{
if ($value <= 0) {
throw new InvalidArgumentException('OrderIdは正の整数である必要があります');
}
}
public function value(): int { return $this->value; }
public function equals(OrderId $other): bool
{
return $this->value === $other->value;
}
}
識別子を値オブジェクトにするメリット
// プリミティブ型の場合
function findOrder(int $orderId): Order { ... }
function findUser(int $userId): User { ... }
findOrder($userId); // 間違いに気づかない!
// 値オブジェクトの場合
function findOrder(OrderId $orderId): Order { ... }
function findUser(UserId $userId): User { ... }
findOrder($userId); // 型エラー!
値オブジェクトの設計指針
| 指針 | 説明 |
|---|---|
| 不変にする | readonlyを使い、setterを作らない |
| 生成時にバリデーション | コンストラクタで不正な値を弾く |
| equals()を実装 | 同一性判断のためのメソッド |
| ビジネスロジックを持たせる | 関連する計算・変換をメソッドに |
値オブジェクトはエンティティに「属する」とは限らない
値オブジェクトは、必ずしもエンティティの一部である必要はありません。
Moneyのような値オブジェクトは、特定のエンティティに属さず、様々な場所で使われます。
参考資料
値オブジェクトについてさらに深く学びたい方は、以下のリソースを参照してください。
- ValueObject - Martin Fowler:値オブジェクトの概念を提唱したMartin Fowlerによる解説
次のチャプターでは、エンティティについて詳しく見ていきます。