エンティティと値オブジェクト — ドメインモデルの構成要素
「同じ 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のまま
end3種類の割引計算ロジックが混在し、使う場所によって結果が微妙に異なっていた。ある画面では100円引きになるのに、別の画面では99円引きになる。誰もなぜ違うのか理解していなかった。
ドメインオブジェクトの2種類
DDDでは、ドメインオブジェクトを大きく2種類に分類する。
この分類は哲学的な問いに基づく。「このオブジェクトは、同じ属性を持つ別のオブジェクトと区別する必要があるか?」
エンティティ(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
endINFO
エンティティはIDを持ち、状態が変化する。Railsの ApplicationRecord はエンティティの永続化に使えるが、ビジネスロジックはドメインオブジェクト側に書く。ActiveRecordモデルはデータアクセス層に徹する。
値オブジェクト(Value Object)
値オブジェクトは属性によって定義されるイミュータブルなオブジェクトだ。IDを持たず、同じ属性を持つ2つのオブジェクトは等値とみなす。
FreshCartの例:
- Money(金額) — 1000円と1000円は等しい。「ID:1の1000円」と「ID:2の1000円」は存在しない
- DeliveryAddress(配送先) — 同じ住所なら等しい。それが同一人物の住所か否かは値オブジェクトの関心外
- DateRange(期間) — 同じ開始日・終了日なら等しい
- OrderStatus(注文状態) —
pendingはpendingだ。どの注文の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
endMoney を使った割引計算(一元化)
# 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
endOrderStatus 値オブジェクト(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
endActiveRecordと値オブジェクトの統合
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 # => trueWARNING
値オブジェクトをJSONカラムに保存する方法もあるが、検索・インデックス・集計が困難になる。WHERE delivery_prefecture = '東京都' のようなクエリができなくなる。基本的には通常のカラムに展開して保存することを推奨する。
エンティティ vs 値オブジェクト の判断基準
どちらにすべきかを判断するためのガイド。
| 質問 | Yes → | No → |
|---|---|---|
| この概念にIDが必要か? | Entity | Value Object |
| 同じ属性でも「別物」になるか? | Entity | Value Object |
| 時間とともに状態が変化するか? | Entity | Value Object |
| コピーして使い回せるか? | Value Object | Entity |
| この概念の「履歴」を追跡したいか? | Entity | Value 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
end1つのテストファイルで完結する。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なしでテスト可能、テストが書きやすい
次の章では、エンティティをグループ化して整合性の境界を作る「集約」を学ぶ。在庫の二重引き当てバグが、集約によって解決される瞬間を見ていこう。