mybook

エンティティと値オブジェクト — ドメインモデルの構成要素

「同じ Money を表すのに、なんでこんなに書き方がバラバラなんだ」

リナはコードを読んでいて気がついた。価格の表現が price_yen, amount_in_yen, total_price, cost... と散乱していた。しかも Integer で持っているところもあれば、BigDecimal で持っているところもある。

# 割引計算 — 3パターンの実装が混在
def apply_discount_v1(price, rate)
  price * (1 - rate / 100.0)  # 浮動小数点の誤差が出る
end
 
def apply_discount_v2(price_yen, discount_percentage)
  (price_yen * discount_percentage / 100).floor  # floor か ceil か混在
end
 
def apply_discount_v3(total, pct)
  BigDecimal(total.to_s) * (1 - BigDecimal(pct.to_s) / 100)
  # BigDecimalを使っているが返値がBigDecimalのまま
end

3種類の割引計算ロジックが混在し、使う場所によって結果が微妙に異なっていた。ある画面では100円引きになるのに、別の画面では99円引きになる。誰もなぜ違うのか理解していなかった。

ドメインオブジェクトの2種類

DDDでは、ドメインオブジェクトを大きく2種類に分類する。

Loading diagram...

この分類は哲学的な問いに基づく。「このオブジェクトは、同じ属性を持つ別のオブジェクトと区別する必要があるか?」

エンティティ(Entity)

エンティティはIDによって識別されるオブジェクトだ。属性が変わっても、同じIDを持てば同じオブジェクトとみなす。

FreshCartの例:

  • Order(注文) — IDが同じなら同じ注文(statusがpendingからconfirmedに変わっても同じ注文)
  • Customer(顧客) — IDが同じなら同じ顧客(住所が変わっても同じ顧客)
  • Product(商品) — IDが同じなら同じ商品(価格が変わっても同じ商品)

エンティティの同一性

# エンティティは同一性(identity)で比較する
order_a = Order.new(id: 1, status: :pending, total: 1000)
order_b = Order.new(id: 1, status: :confirmed, total: 1000)
 
order_a == order_b  # => true(同じIDなので同じ注文)
 
# 住所が変わっても同じ顧客
customer_a = Customer.new(id: 42, address: "東京都渋谷区...")
customer_b = Customer.new(id: 42, address: "大阪府大阪市...")
 
customer_a == customer_b  # => true(同じIDなので同じ顧客)

Railsでのエンティティ実装

module OrderContext
  class Order
    attr_reader :id, :customer_id, :status, :order_items,
                :delivery_address, :confirmed_at, :cancelled_at
 
    def initialize(id:, customer_id:, delivery_address:)
      @id = id
      @customer_id = customer_id
      @delivery_address = delivery_address
      @status = OrderStatus::PENDING
      @order_items = []
      @domain_events = []
    end
 
    # エンティティの同一性: IDで比較する
    def ==(other)
      return false unless other.is_a?(Order)
      id == other.id
    end
 
    alias eql? ==
 
    def hash
      id.hash
    end
 
    # 状態遷移メソッド — エンティティは時間とともに変化する
    def confirm!
      raise InvalidStateTransition,
        "#{status}状態の注文は確定できません" unless can_confirm?
      raise EmptyOrder, "注文商品が1つもありません" if order_items.empty?
 
      @status = OrderStatus::CONFIRMED
      @confirmed_at = Time.current
      add_event(OrderConfirmed.new(
        order_id: id,
        customer_id: customer_id,
        total_amount: total_amount,
        confirmed_at: @confirmed_at
      ))
      self
    end
 
    def cancel!(reason:)
      raise InvalidStateTransition,
        "#{status}状態の注文はキャンセルできません" unless can_cancel?
 
      @status = OrderStatus::CANCELLED
      @cancellation_reason = reason
      @cancelled_at = Time.current
      add_event(OrderCancelled.new(
        order_id: id,
        customer_id: customer_id,
        reason: reason,
        cancelled_at: @cancelled_at
      ))
      self
    end
 
    def add_item(product_id:, product_name:, unit_price:, quantity:)
      raise OrderAlreadyConfirmed, "確定済み注文には追加できません" unless pending?
 
      existing = find_item(product_id)
      if existing
        existing.increase_quantity(quantity)
      else
        @order_items << OrderItem.new(
          product_id: product_id,
          product_name: product_name,
          unit_price: unit_price,
          quantity: quantity
        )
      end
      self
    end
 
    def total_amount
      order_items.sum(&:subtotal)
    end
 
    def item_count
      order_items.sum(&:quantity)
    end
 
    def domain_events
      @domain_events.dup.freeze
    end
 
    def clear_events
      @domain_events.clear
      self
    end
 
    def pending?
      status == OrderStatus::PENDING
    end
 
    def confirmed?
      status == OrderStatus::CONFIRMED
    end
 
    private
 
    def find_item(product_id)
      @order_items.find { |item| item.product_id == product_id }
    end
 
    def can_confirm?
      status == OrderStatus::PENDING
    end
 
    def can_cancel?
      [OrderStatus::PENDING, OrderStatus::CONFIRMED].include?(status)
    end
 
    def add_event(event)
      @domain_events << event
    end
  end
end

INFO

エンティティはIDを持ち、状態が変化する。Railsの ApplicationRecord はエンティティの永続化に使えるが、ビジネスロジックはドメインオブジェクト側に書く。ActiveRecordモデルはデータアクセス層に徹する。

値オブジェクト(Value Object)

値オブジェクトは属性によって定義されるイミュータブルなオブジェクトだ。IDを持たず、同じ属性を持つ2つのオブジェクトは等値とみなす。

FreshCartの例:

  • Money(金額) — 1000円と1000円は等しい。「ID:1の1000円」と「ID:2の1000円」は存在しない
  • DeliveryAddress(配送先) — 同じ住所なら等しい。それが同一人物の住所か否かは値オブジェクトの関心外
  • DateRange(期間) — 同じ開始日・終了日なら等しい
  • OrderStatus(注文状態)pendingpending だ。どの注文の pending かは状態自体に関係ない

値オブジェクトの特徴

# 値オブジェクトは属性で比較する
money_a = Money.new(amount: 1000, currency: :jpy)
money_b = Money.new(amount: 1000, currency: :jpy)
 
money_a == money_b  # => true(同じ属性なので等値)
money_a.equal?(money_b)  # => false(Rubyのオブジェクトとしては別物)
# equal? はRubyのオブジェクト同一性(同じメモリアドレス)
# == は定義した等価比較
 
# イミュータブル: 値を変えると新しいオブジェクトを返す(元は変化しない)
money_c = money_a.add(Money.new(amount: 500, currency: :jpy))
money_c  # => Money(1500 JPY)
money_a  # => Money(1000 JPY)(元のオブジェクトは変化しない)

Money 値オブジェクトの完全実装

module SharedKernel
  class Money
    include Comparable
 
    attr_reader :amount, :currency
 
    SUPPORTED_CURRENCIES = %i[jpy usd eur].freeze
 
    def initialize(amount:, currency: :jpy)
      raise ArgumentError, "金額は0以上である必要があります: #{amount}" if amount.to_r < 0
      raise ArgumentError, "未対応の通貨: #{currency}" unless SUPPORTED_CURRENCIES.include?(currency.to_sym)
 
      # Rationalで精度を保持(浮動小数点の誤差を避ける)
      @amount = Rational(amount)
      @currency = currency.to_sym
      freeze  # イミュータブルにする
    end
 
    # 値オブジェクトは属性で等価比較
    def ==(other)
      return false unless other.is_a?(Money)
      amount == other.amount && currency == other.currency
    end
 
    alias eql? ==
 
    def hash
      [amount, currency].hash
    end
 
    # <=> は Comparable で >, <, >=, <= を提供するために必要
    def <=>(other)
      return nil unless other.is_a?(Money) && same_currency?(other)
      amount <=> other.amount
    end
 
    # 演算: 常に新しい値オブジェクトを返す(破壊的変更なし)
    def add(other)
      raise CurrencyMismatch, "通貨が異なります: #{currency} vs #{other.currency}" unless same_currency?(other)
      Money.new(amount: amount + other.amount, currency: currency)
    end
 
    alias + add
 
    def subtract(other)
      raise CurrencyMismatch, "通貨が異なります" unless same_currency?(other)
      raise NegativeAmount, "引き算の結果が負になります" if amount < other.amount
      Money.new(amount: amount - other.amount, currency: currency)
    end
 
    alias - subtract
 
    def multiply(factor)
      # 端数は切り上げ(顧客に有利な方向)
      Money.new(amount: (amount * factor).ceil, currency: currency)
    end
 
    def apply_discount_percentage(percentage)
      raise ArgumentError, "割引率は0〜100の間です" unless (0..100).include?(percentage)
      discount_amount = (amount * Rational(percentage, 100)).floor
      Money.new(amount: amount - discount_amount, currency: currency)
    end
 
    def zero?
      amount.zero?
    end
 
    def positive?
      amount > 0
    end
 
    def to_i
      amount.to_i
    end
 
    def to_f
      amount.to_f
    end
 
    def to_s
      case currency
      when :jpy then#{amount.to_i.to_s(:delimited)}"
      when :usd then "$#{format('%.2f', amount.to_f)}"
      when :eur then "€#{format('%.2f', amount.to_f)}"
      end
    end
 
    def to_h
      { amount: amount.to_i, currency: currency.to_s }
    end
 
    # 単純合計のためのクラスメソッド
    def self.sum(moneys, currency: :jpy)
      moneys.reduce(Money.new(amount: 0, currency: currency), :add)
    end
 
    private
 
    def same_currency?(other)
      currency == other.currency
    end
 
    class CurrencyMismatch < StandardError; end
    class NegativeAmount < StandardError; end
  end
end

Money を使った割引計算(一元化)

# Before: 3種類の割引計算が散在していた
# After: Money値オブジェクトに統一された計算ロジック
 
order_total = SharedKernel::Money.new(amount: 5000, currency: :jpy)
 
# 会員ランク10%割引
discounted = order_total.apply_discount_percentage(10)
# => ¥4,500
 
# 送料加算
shipping = SharedKernel::Money.new(amount: 550, currency: :jpy)
final_total = discounted.add(shipping)
# => ¥5,050
 
# 比較
free_shipping_threshold = SharedKernel::Money.new(amount: 3000, currency: :jpy)
order_total > free_shipping_threshold  # => true(送料無料)
 
# テストが書きやすい
expect(order_total.apply_discount_percentage(10))
  .to eq(SharedKernel::Money.new(amount: 4500, currency: :jpy))

DeliveryAddress 値オブジェクト

module OrderContext
  class DeliveryAddress
    attr_reader :postal_code, :prefecture, :city, :street, :building, :recipient_name
 
    PREFECTURE_LIST = %w[
      北海道 青森県 岩手県 宮城県 秋田県 山形県 福島県
      茨城県 栃木県 群馬県 埼玉県 千葉県 東京都 神奈川県
      新潟県 富山県 石川県 福井県 山梨県 長野県
      岐阜県 静岡県 愛知県 三重県
      滋賀県 京都府 大阪府 兵庫県 奈良県 和歌山県
      鳥取県 島根県 岡山県 広島県 山口県
      徳島県 香川県 愛媛県 高知県
      福岡県 佐賀県 長崎県 熊本県 大分県 宮崎県 鹿児島県 沖縄県
    ].freeze
 
    def initialize(postal_code:, prefecture:, city:, street:, building: nil, recipient_name:)
      validate!(postal_code, prefecture, city, street, recipient_name)
 
      @postal_code = postal_code.delete('-')  # 正規化: ハイフン除去
      @prefecture = prefecture
      @city = city
      @street = street
      @building = building.presence
      @recipient_name = recipient_name
      freeze
    end
 
    def ==(other)
      return false unless other.is_a?(DeliveryAddress)
      to_a == other.to_a
    end
 
    alias eql? ==
 
    def hash
      to_a.hash
    end
 
    def full_address
      parts = ["〒#{formatted_postal_code}", prefecture, city, street]
      parts << building if building
      parts.join(" ")
    end
 
    def formatted_postal_code
      "#{postal_code[0..2]}-#{postal_code[3..6]}"
    end
 
    # 離島・沖縄かどうか(送料計算に使用)
    def remote_area?
      prefecture == '沖縄県' || prefecture == '北海道'
    end
 
    # 配送不可地域かどうか(冷凍食品など)
    def island_area?
      # 島嶼部の郵便番号チェック(簡略版)
      postal_code.start_with?('894', '895', '896', '897', '898', '899')
    end
 
    def to_h
      {
        postal_code: formatted_postal_code,
        prefecture: prefecture,
        city: city,
        street: street,
        building: building,
        recipient_name: recipient_name,
        full_address: full_address
      }
    end
 
    protected
 
    def to_a
      [postal_code, prefecture, city, street, building, recipient_name]
    end
 
    private
 
    def validate!(postal_code, prefecture, city, street, recipient_name)
      clean_code = postal_code.to_s.delete('-')
      raise ArgumentError, "郵便番号は7桁の数字です: #{postal_code}" unless clean_code.match?(/\A\d{7}\z/)
      raise ArgumentError, "都道府県が不正です: #{prefecture}" unless PREFECTURE_LIST.include?(prefecture)
      raise ArgumentError, "市区町村は必須です" if city.blank?
      raise ArgumentError, "番地は必須です" if street.blank?
      raise ArgumentError, "受取人名は必須です" if recipient_name.blank?
    end
  end
end

OrderStatus 値オブジェクト(Enumパターン)

module OrderContext
  class OrderStatus
    PENDING   = new('pending')
    CONFIRMED = new('confirmed')
    SHIPPED   = new('shipped')
    DELIVERED = new('delivered')
    CANCELLED = new('cancelled')
 
    ALL = [PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED].freeze
    ALL_VALUES = ALL.map(&:to_s).freeze
 
    # 有効な状態遷移を定義
    TRANSITIONS = {
      PENDING   => [CONFIRMED, CANCELLED],
      CONFIRMED => [SHIPPED, CANCELLED],
      SHIPPED   => [DELIVERED],
      DELIVERED => [],
      CANCELLED => []
    }.freeze
 
    attr_reader :value
 
    def initialize(value)
      @value = value.to_s
      freeze
    end
 
    def can_transition_to?(next_status)
      TRANSITIONS.fetch(self, []).include?(next_status)
    end
 
    def valid_next_statuses
      TRANSITIONS.fetch(self, [])
    end
 
    def ==(other)
      other.is_a?(OrderStatus) && value == other.value
    end
 
    alias eql? ==
 
    def hash
      value.hash
    end
 
    def to_s
      value
    end
 
    def self.from_string(str)
      ALL.find { |s| s.value == str.to_s } ||
        raise(ArgumentError, "不明な注文状態: #{str}")
    end
  end
end

ActiveRecordと値オブジェクトの統合

Railsのデータベースには値オブジェクトをどう保存するか。

Composed Of を使う方法(Rails標準)

class OrderRecord < ApplicationRecord
  # composed_of で値オブジェクトとActiveRecordカラムをマッピング
  composed_of :delivery_address,
    class_name: 'OrderContext::DeliveryAddress',
    mapping: [
      %w[delivery_postal_code postal_code],
      %w[delivery_prefecture prefecture],
      %w[delivery_city city],
      %w[delivery_street street],
      %w[delivery_building building],
      %w[delivery_recipient_name recipient_name]
    ],
    allow_nil: true
 
  composed_of :total_amount,
    class_name: 'SharedKernel::Money',
    mapping: [
      %w[total_amount_cents amount],
      %w[currency currency]
    ]
end
# 使用例
order_record = OrderRecord.find(1)
 
# 値オブジェクトとして取得できる
address = order_record.delivery_address
# => OrderContext::DeliveryAddress オブジェクト
 
puts address.full_address
# => 〒150-0001 東京都 渋谷区 神宮前1-1-1
 
# 比較も自然にできる(IDではなく属性で比較)
same_address = OrderContext::DeliveryAddress.new(
  postal_code: '1500001',
  prefecture: '東京都',
  city: '渋谷区',
  street: '神宮前1-1-1',
  recipient_name: 'テスト 太郎'
)
order_record.delivery_address == same_address  # => true

WARNING

値オブジェクトをJSONカラムに保存する方法もあるが、検索・インデックス・集計が困難になる。WHERE delivery_prefecture = '東京都' のようなクエリができなくなる。基本的には通常のカラムに展開して保存することを推奨する。

エンティティ vs 値オブジェクト の判断基準

どちらにすべきかを判断するためのガイド。

質問Yes →No →
この概念にIDが必要か?EntityValue Object
同じ属性でも「別物」になるか?EntityValue Object
時間とともに状態が変化するか?EntityValue Object
コピーして使い回せるか?Value ObjectEntity
この概念の「履歴」を追跡したいか?EntityValue Object

難しいケース: 住所

「住所」はどちらか?

  • 顧客の住所帳に保存された住所 → エンティティ(「この住所(ID:5)を更新したい」「この住所を削除したい」という操作がある)
  • 注文の配送先住所 → 値オブジェクト(「東京都渋谷区...」という値が重要。注文確定後に変わることはない)

同じ「住所」という概念でも、文脈によってエンティティか値オブジェクトかが変わる。

# 顧客の住所帳 → エンティティ
module CustomerContext
  class SavedAddress  # エンティティ
    attr_reader :id, :customer_id, :label, :address_details
 
    def initialize(id:, customer_id:, label:, address_details:)
      @id = id  # IDを持つ
      @customer_id = customer_id
      @label = label
      @address_details = address_details
    end
 
    def update_label!(new_label)
      @label = new_label  # 状態が変化する
    end
 
    def ==(other)
      other.is_a?(SavedAddress) && id == other.id  # IDで比較
    end
  end
end
 
# 注文の配送先 → 値オブジェクト
module OrderContext
  class DeliveryAddress  # 値オブジェクト
    attr_reader :postal_code, :prefecture, :city, :street, :recipient_name
 
    def initialize(...)
      # ...
      freeze  # イミュータブル
    end
 
    def ==(other)
      other.is_a?(DeliveryAddress) &&
        [postal_code, prefecture, city, street, recipient_name] ==
        [other.postal_code, other.prefecture, other.city, other.street, other.recipient_name]
      # 属性で比較(IDなし)
    end
  end
end

値オブジェクトのテスト

値オブジェクトはテストが非常に書きやすい。外部依存がなく、入力と出力のみを検証すればいい。

RSpec.describe SharedKernel::Money do
  describe '初期化' do
    it '正の整数で作成できる' do
      expect { described_class.new(amount: 1000, currency: :jpy) }.not_to raise_error
    end
 
    it '0円で作成できる' do
      money = described_class.new(amount: 0, currency: :jpy)
      expect(money.zero?).to be true
    end
 
    it '負の金額はエラーになる' do
      expect { described_class.new(amount: -1, currency: :jpy) }
        .to raise_error(ArgumentError, /0以上/)
    end
 
    it '未対応の通貨はエラーになる' do
      expect { described_class.new(amount: 100, currency: :btc) }
        .to raise_error(ArgumentError, /未対応/)
    end
  end
 
  describe '等価比較' do
    it '同じ金額・通貨は等値' do
      money_a = described_class.new(amount: 1000, currency: :jpy)
      money_b = described_class.new(amount: 1000, currency: :jpy)
      expect(money_a).to eq(money_b)
    end
 
    it '金額が異なれば等値でない' do
      money_a = described_class.new(amount: 1000, currency: :jpy)
      money_b = described_class.new(amount: 999, currency: :jpy)
      expect(money_a).not_to eq(money_b)
    end
 
    it '通貨が異なれば等値でない' do
      jpy = described_class.new(amount: 1000, currency: :jpy)
      usd = described_class.new(amount: 1000, currency: :usd)
      expect(jpy).not_to eq(usd)
    end
  end
 
  describe '#add' do
    it '同じ通貨の金額を加算できる' do
      money_a = described_class.new(amount: 1000, currency: :jpy)
      money_b = described_class.new(amount: 500, currency: :jpy)
 
      result = money_a.add(money_b)
 
      expect(result).to eq(described_class.new(amount: 1500, currency: :jpy))
    end
 
    it '元のオブジェクトは変化しない(イミュータブル)' do
      money_a = described_class.new(amount: 1000, currency: :jpy)
      money_b = described_class.new(amount: 500, currency: :jpy)
 
      money_a.add(money_b)
 
      expect(money_a.amount).to eq(Rational(1000))  # 変化していない
    end
 
    it '異なる通貨の加算はエラー' do
      jpy = described_class.new(amount: 1000, currency: :jpy)
      usd = described_class.new(amount: 10, currency: :usd)
 
      expect { jpy.add(usd) }.to raise_error(SharedKernel::Money::CurrencyMismatch)
    end
  end
 
  describe '#apply_discount_percentage' do
    context '10%割引' do
      it '1000円から100円割引される' do
        money = described_class.new(amount: 1000, currency: :jpy)
        expect(money.apply_discount_percentage(10))
          .to eq(described_class.new(amount: 900, currency: :jpy))
      end
 
      it '端数は切り捨て(顧客有利)' do
        money = described_class.new(amount: 999, currency: :jpy)
        result = money.apply_discount_percentage(10)
        # 999 * 0.1 = 99.9 → floor = 99 → 999 - 99 = 900
        expect(result).to eq(described_class.new(amount: 900, currency: :jpy))
      end
    end
  end
end

1つのテストファイルで完結する。DBのセットアップも、外部サービスのモックも不要。純粋に値の振る舞いをテストできる。

リナの気づき

Money を値オブジェクトにしたら、割引計算が1箇所に集まった」

以前は割引計算が3パターン散在していて、どれが正しいのか誰もわからなかった。Money 値オブジェクトを導入したことで、計算ロジックが一元化され、テストも書きやすくなり、結果が一貫するようになった。

さらに、型の力が助けてくれるようになった。

# Before: Integerを渡すのか BigDecimalを渡すのか不明
def process_payment(amount, currency_code)
  # amountの型は何? 通貨の単位は?
end
 
# After: Money値オブジェクトを渡す
def process_payment(money)
  # Moneyオブジェクトであることが保証されている
  # 金額の単位、通貨、演算方法が明確
  money.amount  # => Rational
  money.currency  # => :jpy
end

型がドキュメントになった。引数の意味を調べに行く必要がなくなった。

まとめ

  • エンティティ = IDで識別、状態が変化する(Order, Customer, Product)
  • 値オブジェクト = 属性で等価、イミュータブル(Money, DeliveryAddress, OrderStatus)
  • freeze = Rubyのfreeze()でイミュータブルを強制する
  • 等価比較 = エンティティはID、値オブジェクトは属性で定義する
  • Railsとの統合 = composed_of でActiveRecordと自然に統合できる
  • テスト = 値オブジェクトはDBなしでテスト可能、テストが書きやすい

次の章では、エンティティをグループ化して整合性の境界を作る「集約」を学ぶ。在庫の二重引き当てバグが、集約によって解決される瞬間を見ていこう。