値オブジェクトとは?

値オブジェクトは、ビジネス上の重要な概念(メールアドレス、住所、金額など)を専用のクラスとして表現する設計パターンです。 主にオブジェクト指向プログラミングやドメイン駆動設計(DDD)で使われる重要な概念です。

現実世界のアナロジー

値オブジェクトを理解するために、3 つの具体例を見てみましょう!
  • メールアドレス: user@example.comは単なる文字列ではなく、特定の形式を持つ連絡先情報
  • 住所: 東京都渋谷区1-1-1は都道府県、市区町村、番地が組み合わさった場所の識別子
  • 金額: 1,000円は数値の 1,000 と通貨の JPY が組み合わさった価値を表現したもの
これらはすべて、複数の要素や制約が組み合わさって一つの意味を持つ概念です。

値オブジェクトを用いないで実装した場合の問題

値オブジェクトを用いない従来のプログラミングでは、これらの概念を基本型(文字列、数値)で表現しがちです。
email = "user@example.com"        # ただの文字列
address = "東京都渋谷区1-1-1"       # ただの文字列
price = 1000                      # ただの数値

なぜ問題なのか?

メールアドレスの場合

email = "user@example.com"
email = "invalid-email" # 不正な値を設定できてしまう問題
:
:
:
send_email(email)       # エラーが発生する可能性

住所の場合

original_address = "東京都渋谷区"
modified_address = original_address
modified_address.gsub!("渋谷", "新宿")
puts modified_address  # => "東京都新宿区"(想定通り)
puts original_address  # => "東京都新宿区"(想定外)

金額の場合

price1 = 1000        # これは何の金額?日本円?ドル?不明。。。
price2 = 1500        # これは何の金額?日本円?ドル?不明。。。
total = price1 + price2  # 意図の不明瞭な計算

値オブジェクトを用いない場合に起こり得る問題のまとめ

  1. バリデーション不足: 不正な値(不正なメール形式など)を防げない
  2. 不変性の欠如: 予期しない値の変更により、他の部分に影響を与えるリスク
  3. 意味が不明確: 1000が何を表すのかわからない(円?ドル?)
  4. 型安全性の欠如: 異なる種類の値を誤って混ぜてしまうリスク(円とドルの足し算など)

値オブジェクトによる解決のイメージ

値オブジェクトを使うことで、これらの問題をこう解決できます。

メールアドレスの場合

email = EmailAddress.new("user@example.com")
# EmailAddress.new("invalid-email")    # エラー:不正な値は作成できない

住所の場合

address = Address.new("東京都", "渋谷区", "1-1-1")
# address.city = "新宿区"  # エラー:freezeによる不変性で変更を防ぐ

金額の場合

price1 = Money.new(1000, "JPY")  # 何の値かが明確
price2 = Money.new(1500, "JPY")  # 同じ通貨の金額
total = price1 + price2  # Moneyクラスが計算を管理

そもそも、値オブジェクトの「値」とは何か?

値オブジェクトにおける「値」とは、初期化時に渡される引数の組み合わせのことです。

メールアドレスの場合

email1 = EmailAddress.new("user@example.com")      # 値:("user@example.com")
email2 = EmailAddress.new("user@example.com")      # 値:("user@example.com") ← email1と同じ値
email3 = EmailAddress.new("admin@example.com")     # 値:("admin@example.com") ← 異なる値

住所の場合

address1 = Address.new("東京都", "渋谷区", "1-1-1")  # 値:("東京都", "渋谷区", "1-1-1")
address2 = Address.new("東京都", "渋谷区", "1-1-1")  # 値:("東京都", "渋谷区", "1-1-1") ← address1と同じ値

金額の場合

money1 = Money.new(1000, "JPY")     # 値:(1000, "JPY")
money2 = Money.new(1000, "JPY")     # 値:(1000, "JPY") ← money1と同じ値
money3 = Money.new(1000, "USD")     # 値:(1000, "USD") ← money1とは異なる値

値オブジェクトの 3 つの特性

1. 不変性(変更できない)

一度作成されると、内容を変更することができません。
address = Address.new("東京都", "渋谷区")
# address.city = "新宿区"  # エラー:変更不可

2. 値による等価性: 同じ値なら同じオブジェクト

addr1 = Address.new("東京都", "渋谷区")
addr2 = Address.new("東京都", "渋谷区")
addr1 == addr2  # => true(同じ値なので等しい)

3. 自己完結性: 振る舞いとデータが一体化

値オブジェクトは、そのデータに関連するすべての処理(バリデーション、計算、変換など)を自分自身の中に持っています。他のオブジェクトには依存せず、外部に処理を委譲することもなく、それ単体で完結しているため「自己完結性」と呼びます。
# ❌ 自己完結していない例:処理が散在している
price = 1000
currency = "JPY"

# 通貨の変換処理が外部に分散
if currency == "JPY"
  price_in_usd = price / 110  # 為替レート処理が外部
end

# ✅ 自己完結している例:Moneyクラスがすべてを管理
money1 = Money.new(1000, "JPY")
money2 = Money.new(500, "JPY")

# 通貨の計算ロジックがMoneyクラスに内包されている
total = money1 + money2  # => Money.new(1500, "JPY")

# 異なる通貨の計算も自動でチェック
usd = Money.new(10, "USD")
# money1 + usd  # => エラー:異なる通貨は計算できない
これらの特徴により、バグの減少コードの可読性向上型安全性の向上を実現できます。 値オブジェクトを使用することで、先ほど見たような問題(異なる通貨の計算、不正な値の設定、予期しない値の変更など)をコンパイル時や実行時の早い段階で防ぐことができます。

値オブジェクトの実装方法

メールアドレスの値オブジェクト化

問題:文字列でメールアドレスを管理

email = "user@example.com"
email = "invalid-email"
問題点:
  • 不正なメールアドレス形式を設定できる
  • 正規化されない(大文字小文字の統一がされない)
  • メールアドレスとしての妥当性チェックがない

解決方法:値オブジェクトとして実装

class EmailAddress
  attr_reader :value

  def initialize(value)
    validate_format!(value)

    @value = value.downcase.freeze

    freeze
  end

  def ==(other)
    other.is_a?(EmailAddress) && value == other.value
  end

  def to_s
    @value
  end

  private

  def validate_format!(value)
    pattern = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
    return if value =~ pattern

    raise ArgumentError, 'Invalid email format'
  end
end
値オブジェクトの導入により改善された点:
  1. バリデーション: 不正な形式を作成時に防ぐ
    EmailAddress.new("invalid-email")
    # => email_address.rb:49:in `validate_format!': Invalid email format (ArgumentError)
    
  2. 正規化: 大文字小文字を統一
    > EmailAddress.new("user@example.com") == EmailAddress.new("USER@example.com")
    => true
    
  3. 型安全性: 文字列ではなく専用の型として扱える
    def send_notification(email)
      # emailがEmailAddressクラスのインスタンスであることが保証される
      puts "Sending to: #{email.value}"
    end
    
    # 型が明確なので、誤って文字列を渡してもその間違いに気づきやすい
    send_notification(EmailAddress.new("user@example.com"))  # OK
    send_notification("user@example.com")  # NoMethodError: undefined method `value' for String
    
  4. 一貫した状態: 作成後に内部状態が変更されないため、常に有効な状態を保つ
    email = EmailAddress.new("user@example.com")
    
    # 作成時点で有効なメールアドレスであることが保証されているため、
    # その後、状態が変わることはない
    
    # 1時間後...
    send_welcome_email(email)  # 有効なメールアドレスであることが保証される
    
    # 1日後...
    send_newsletter(email)     # まだ有効なメールアドレスであることが保証される
    
    # 文字列の場合は途中で変更される可能性があるが、値オブジェクトは不変
    puts email.value # => "user@example.com"
    
    email.value = "invalid-email" # NoMethodError: undefined method `value=' for EmailAddress
    
  5. 不変性の保証: freezeにより作成後の変更を防ぐ
    email = EmailAddress.new("user@example.com")
    
    # freezeによってオブジェクト自体が凍結されているため、
    # インスタンス変数を後から変更しようとしてもエラーになる
    
    # 通常のRubyでは instance_variable_set で強制的に変更できるが...
    email.instance_variable_set(:@value, "hacker@evil.com")
    # => FrozenError: can't modify frozen EmailAddress
    
    # 内部の文字列も freeze されているため変更不可
    email.value.upcase!
    # => FrozenError: can't modify frozen String
    

練習問題

(1) 住所の値オブジェクト化

問題:文字列で住所を管理

address = "東京都渋谷区1-1-1"
address = ""  # 空文字を設定できてしまう
問題点:
  • 値が後から書き変わってしまう
  • 不正な値(空文字など)を防げない

解決方法:値オブジェクトとして実装

# シンプルなAddressクラス
class Address
  # 読み取り専用のアクセサメソッドを定義
  # prefecture、city、streetメソッドが自動生成される
  attr_reader :prefecture, :city, :street

  def initialize(prefecture, city, street)
    # バリデーション:値オブジェクトは作成時に必ず有効な状態にする
    # ArgumentErrorは不正な引数に対する標準的な例外クラス
    raise ArgumentError, "都道府県が空です" if prefecture.nil? || prefecture.empty?
    raise ArgumentError, "市区町村が空です" if city.nil? || city.empty?
    raise ArgumentError, "番地が空です" if street.nil? || street.empty?

    # インスタンス変数への代入(setterメソッドは作らない)
    # 各要素をfreezeして個別に変更不可にする
    @prefecture = prefecture.freeze
    @city = city.freeze
    @street = street.freeze

    freeze
  end

  # 値による等価性の実装
  # 住所の全要素が同じなら等しいとみなす
  def ==(other)
    other.is_a?(Address) &&
      prefecture == other.prefecture &&
      city == other.city &&
      street == other.street
  end

  # 住所の文字列表現(表示用)
  def to_s
    "#{prefecture}#{city}#{street}"
  end
end
address = Address.new("東京都", "渋谷区", "1-1-1")

# Address.new("", "渋谷区", "1-1-1")  # => ArgumentError
改善された点:
  1. バリデーション: 不正なデータを作成時に防ぐ
  2. 不変性: 一度作成されたオブジェクトは変更できない
  3. 明確な意味: コードを読むだけで住所であることがわかる
  4. 複数属性の管理: 関連する複数の値を一つのオブジェクトで安全に管理
  5. 構造化された比較: 全要素を考慮した等価性判定

(2) 金額の値オブジェクト化

問題:数値で金額を管理

price1 = 1000          # 何の通貨?円?ドル?
price2 = 1500          # 通貨が不明
total = price1 + price2  # 2500...でも何の2500?

# 異なる通貨を誤って計算してしまう例
yen_price = 1000       # 日本円のつもり
usd_price = 10         # ドルのつもり
total = yen_price + usd_price  # 1010...円とドルを足してしまった!
問題点:
  • 通貨が不明で、円なのかドルなのか区別できない
  • 異なる通貨同士を誤って計算してしまうリスク
  • 金額の計算ロジックが散在し、一貫性がない

解決方法:値オブジェクトとして実装

異なる通貨の混合や不正な金額を防ぐための値オブジェクトです。
require 'bigdecimal'
require 'bigdecimal/util'

class Money
  include Comparable

  attr_reader :amount, :currency

  def initialize(amount, currency = 'JPY')
    validate_arguments(amount, currency)

    @amount = to_bigdecimal(amount)
    @currency = currency.freeze

    freeze
  end

  def +(other)
    check_same_currency(other)

    Money.new(amount + other.amount, currency)
  end

  def -(other)
    check_same_currency(other)

    result_amount = amount - other.amount
    raise ArgumentError, 'Result would be negative' if result_amount.negative?

    Money.new(result_amount, currency)
  end

  def *(other)
    raise ArgumentError, 'Factor must be a number' unless other.is_a?(Numeric)
    raise ArgumentError, 'Factor cannot be negative' if other.negative?

    Money.new(amount * to_bigdecimal(other), currency)
  end

  def /(other)
    raise ArgumentError, 'Divisor must be a number' unless other.is_a?(Numeric)
    raise ArgumentError, 'Cannot divide by zero' if other.zero?
    raise ArgumentError, 'Divisor cannot be negative' if other.negative?

    Money.new(amount / to_bigdecimal(other), currency)
  end

  def ==(other)
    other.is_a?(Money) && amount == other.amount && currency == other.currency
  end

  def <=>(other)
    return nil unless other.is_a?(Money)

    check_same_currency(other)
    amount <=> other.amount
  end

  def hash
    [amount, currency].hash
  end

  def eql?(other)
    self == other
  end

  def to_s
    "#{currency} #{format('%.2f', amount)}"
  end

  private

  def validate_arguments(amount, currency)
    raise ArgumentError, 'Amount must be a number' unless amount.is_a?(Numeric)
    raise ArgumentError, 'Amount cannot be negative' if amount.negative?
    raise ArgumentError, 'Currency code must be 3 characters' unless currency.match?(/^[A-Z]{3}$/)
  end

  def check_same_currency(other)
    return if currency == other.currency

    raise ArgumentError, 'Cannot perform operations between different currencies'
  end

  def to_bigdecimal(value)
    case value
    when BigDecimal
      value
    when Integer, Float
      BigDecimal(value.to_s)
    when String
      BigDecimal(value)
    else
      raise ArgumentError, 'Cannot convert to BigDecimal'
    end
  end
end

まとめ

値オブジェクトは、ビジネス上重要な概念を安全で明確な形で表現するための強力なパターンです。

バグを防ぐ3つの原則

1. 不変性(Immutability)

一度作成されたら内容を変更できない原則です。これにより以下のメリットが得られます:
  • 予期しない値の変更によるバグを防ぐ: オブジェクトの状態が途中で変わることがないため、意図しない副作用が発生しません
  • 複数の場所で同じオブジェクトを安心して共有できる: 他の処理で値が変更される心配がありません
  • スレッドセーフになる: 並行処理でも安全に使用できます
email = EmailAddress.new("user@example.com")
# email.value = "hacker@evil.com"  # できない!FrozenError

2. 値による等価性(Value Equality)

同じ値を持つオブジェクトは等しいとみなす原則です。これにより以下のメリットが得られます:
  • オブジェクトの比較が直感的になる: 内容が同じなら等しいという自然な振る舞い
  • 意図しない参照の比較によるバグを防ぐ: オブジェクトのIDではなく値で比較されます
  • テストが書きやすくなる: 期待値との比較がシンプルになります
addr1 = Address.new("東京都", "渋谷区", "1-1-1")
addr2 = Address.new("東京都", "渋谷区", "1-1-1")
addr1 == addr2  # => true(同じ値なので等しい)

3. 自己完結性(Self-contained)

データとそれに関連する振る舞いを一体化する原則です。これにより以下のメリットが得られます:
  • バリデーションロジックの散在を防ぐ: 検証処理が値オブジェクト内に集約されます
  • 不正な状態のオブジェクトが作られることを防ぐ: コンストラクタで必ず検証されます
  • ビジネスロジックが一箇所に集約される: 重複や矛盾を防ぎ、保守性が向上します
# すべての金額計算ロジックがMoneyクラスに集約
money1 = Money.new(1000, "JPY")
money2 = Money.new(500, "JPY")
total = money1 + money2  # 通貨チェックも自動で実行
これらの原則により、「作成時点で有効性が保証され」「その後変更されず」「関連する処理が一箇所に集約される」ため、多くのバグを設計レベルで防ぐことができます。 値オブジェクトを使うことで、より安全で保守しやすいコードを書くことができます。

参考資料