Sansan Builders Box

Sansanのものづくりを支える技術やデザイン、プロダクトマネジメントの情報を発信

composed_of を使って Rails で値オブジェクトを扱う

DSOC サービス開発部でエンジニアをしている石畑です。普段は Rails で名寄せサービスを作っています。 今回は Rails で値オブジェクトを扱うのに ActiveRecord の composed_of が便利なので、紹介します。

値オブジェクト

値オブジェクトは DDD でも紹介されている概念です。多くのわかりやすい解説が世の中にあるので、詳しくは検索してもらえればと思いますが、ものすごく大雑把に説明すると「各属性で等価を判断できる不変なオブジェクト」です。

例えば「とあるスーパーでお肉を売る」を考えたときに、最初「300 円」で売っていた「お肉 A」を途中タイムセールで 100 円引きの「200 円」で売ったとしても「お肉 A」は値段を変更する前と「同一のお肉」です。

f:id:pekepek:20200331234918p:plain:w450
お肉のセール

そのため、「お肉」の同一性は属性で判断することはできず、バーコードのような識別子で同一性を追跡します。これは「値オブジェクト」ではありません(DDD ではエンティティと呼ばれます)。

これに対して、お肉の「値段」は「数字部分」と「貨幣単位」という二つの属性で等価かどうか判断することができます。例えば、「300 円」と「300 円」は常に同一の物(等価)で、「300 円」と「200 円」は常に別物です。また、「300 円」と「300 ドル」も常に別物です。そして、各値段は不変で、「数字部分」だけを取り替えることはできません(別物になってしまいます)。このような概念は「値オブジェクト」として扱えます。

f:id:pekepek:20200331235324p:plain:w380
どっちも同じ 100 円

※ 調子乗って「常に」とか言ってしまいましたが、値段を「値」として扱うかどうかはそのドメインによるもので、もしかしたらあるサービスでは「ある 300 円」と「ある 300 円」を別々に扱いたいこともあるかもしれません。とある概念をエンティティとして扱うか、値として扱うかはドメインによります。詳しくは調べてみてください 🙏

値オブジェクトの説明としては不十分な気もしますが、大方こんな感じです。

値オブジェクトで責務を分ける

例えば「品名、価格の値、通貨単位」を属性として持つ「食料品」クラスを考えてみます。

create_table :groceries do |t|
  t.string :name
  t.decimal :price_value, precision: 12, scale: 2
  t.string :price_currency
end

このとき「価格の値」と「通貨単位」はセットで初めて「値段」としての意味を持ちますが、そのままでは、それぞれ BigDecimal と String の型を持った Grocery の一属性でしかなく、その意味は失われてしまっています。そのため、利用する場合は、使用者が暗黙の意味を考えて使う必要があります。

class Grocery < ApplicationRecord
  CURRENCY_LIST = {'USD' => 'USドル', 'JPY' => '', 'EUR' => 'ユーロ'}.freeze

  def price
    "#{price_value.truncate.to_s(:delimited)} #{CURRENCY_LIST.fetch(price_currency)}"
  end
end

こんな感じで値段メソッドを実装しても、返ってくるのはただの文字列で、相変わらず使用者が意味を理解する必要があります。また、ここから「価格の値」や「通貨単位」を取り出すこともできません。

> grocery = Grocery.new(name: '', price_value: 1000, price_currency: 'JPY')
> grocery.price
=> "1,000 円"

そこで、「お金」を値として扱い、「食料品」はそれを活用するようにします。

class Grocery < ApplicationRecord
  def price
    Money.new(price_value, price_currency)
  end
end

class Money
  attr_reader :amount, :currency

  def initialize(amount, currency)
    @amount, @currency = amount, currency
  end

  def ==(other_money)
    amount == other_money.amount && currency == other_money.currency
  end
end

これで price に意味を持たせることができました。

> grocery = Grocery.new(name: '', price_value: 1100, price_currency: 'JPY')
> grocery.price
=> #<Money:0x00007fc21a173098 @amount=0.11e4, @currency="JPY">
> grocery.price.amount.to_i
=> 1100
> grocery.price.currency
=> "JPY"

これで「価格の値」や「通貨単位」を取り出すこともできますし、

class Money
  class UnknownCurrencyError < StandardError; end

  attr_reader :amount, :currency

  CURRENCY_LIST = {'USD' => 'USドル', 'JPY' => '', 'EUR' => 'ユーロ'}.freeze
  CURRENCY_EXCHANGE_RATE = {'USD' => 1, 'JPY' => 110, 'EUR' => 0.9}.freeze

  def initialize(amount, currency)
    raise UnknownCurrencyError unless CURRENCY_LIST.key?(currency)

    @amount, @currency = amount, currency
  end

  def exchange(other_currency)
    exchanged_amount = amount / CURRENCY_EXCHANGE_RATE.fetch(currency) * CURRENCY_EXCHANGE_RATE.fetch(other_currency)
    Money.new(exchanged_amount, other_currency)
  end

  def to_human_readable
    "#{amount.truncate.to_s(:delimited)} #{CURRENCY_LIST.fetch(currency)}"
  end

  def ==(other_money)
    amount == other_money.amount && currency == other_money.currency
  end
end

このようにお金に関するビジネスロジックが増えてきても、Grocery を汚すことなく拡張する事ができます。

> grocery = Grocery.new(name: '', price_value: 1100, price_currency: 'JPY')
> grocery.price.to_human_readable
=> "1,100 円"
> grocery.price.exchange('EUR').to_human_readable
=> "9 ユーロ"

これで値オブジェクトを使って責務を分離することができました。役割が分かれたことで拡張性が高くなっただけでなく、price_value, price_currency の意図が明確になったので扱いやすくなったと思います。

f:id:pekepek:20200331235519p:plain:h200

セッターを追加する

これだけでは price を置き換えることができず、相変わらず値段を変更するのに price_value, price_currency を直接操作しなければならないので不十分です。そこで、セッターも用意します。

class Grocery < ApplicationRecord
  def price=(money)
    self.price_value = money.amount
    self.price_currency = money.currency
  end

  def price
    Money.new(price_value, price_currency)
  end
end

無事「値段」を更新できるようになりました。

> grocery = Grocery.new(name: '', price: Money.new(1100, 'JPY'))
> grocery.price.to_human_readable
=> "1,100 円"
> grocery.update(price: Money.new(20, 'USD'))
> grocery.price.to_human_readable
=> "20 USドル"

composed_of を使う

これまでで値オブジェクトは扱えるようになりましたが、わざわざ自前でアクセッサを定義しなくても、実際は composed_of で簡単に書けます。

class Grocery < ApplicationRecord
  composed_of :price, class_name: 'Money', mapping: [%w(price_value amount), %w(price_currency currency)]
end
> grocery = Grocery.new(name: '', price: Money.new(1100, 'JPY'))
> grocery.price.to_human_readable
=> "1,100 円"
> grocery.update(price: grocery.price.exchange('USD'))
> grocery.price.to_human_readable
=> "10 USドル"

だいぶスッキリしましたね。 しかも、アクセッサを用意するだけでは検索ができなかったんですが、composed_of ならそれもよしなに Rails がやってくれます。

> Grocery.create(name: '赤福', price: Money.new(1100, 'JPY'))
> Grocery.where(price: Money.new(1100, 'JPY'))
  Grocery Load (2.4ms)  SELECT `groceries`.* FROM `groceries` WHERE `groceries`.`price_value` = 1100 AND `groceries`.`price_currency` = 'JPY'
=> [#<Grocery:0x00007fc2132808e8 id: 1, name: "赤福", price_value: 0.11e4, price_currency: "JPY">]

んー便利。これで完全に Money 型として price を扱えるようになりましたね 🎉

その他にも

例えばこんな User を用意して、

create_table :users do |t|
  t.string :first_name
  t.string :last_name
  t.string :email
  t.string :full_address
  t.string :address_prefecture
  t.string :address_municipality
  t.string :address_street
end

姓、名を属性に持つ「氏名」と Email を属性に持つ Email を値として定義してあげると、

class User < ApplicationRecord
  composed_of :full_name, mapping: [%w(first_name first_name), %w(last_name last_name)]
  composed_of :email
end

class FullName
  attr_reader :first_name, :last_name

  def initialize(first_name, last_name)
    @first_name, @last_name = first_name, last_name
  end
end

class Email
  attr_reader :email

  def initialize(email)
    @email = email
  end
end
> user = User.new(full_name: FullName.new('太郎', '山田'), email: Email.new('taro@sansan.com'))
> user.full_name
=> #<FullName:0x00007fb76cd94128 @first_name="太郎", @last_name="山田">
> user.email
=> #<Email:0x00007fb76cd94060 @email="taro@sansan.com">

FullName のように composed_of の第一引数とクラス名が対応している場合は class_name は省略できますし、属性さえも一致する場合は mapping も省略できます。 また、(例なんで良し悪しは置いておいて)「address_prefecture, address_municipality, address_street をタブ区切りで保持しているのが full_address」みたいな決まりごとがあるとしたら、

class User < ApplicationRecord
  composed_of :full_name, mapping: [%w(first_name first_name), %w(last_name last_name)]
  composed_of :email
  composed_of :address,
              mapping: [%w(full_address full_address), %w(address_prefecture prefecture), %w(address_municipality municipality), %w(address_street street)]
end

class Address
  attr_reader :prefecture, :municipality, :street

  def initialize(prefecture, municipality, street)
    @prefecture, @municipality, @street = prefecture, municipality, street
  end

  def full_address
    [prefecture, municipality, street].join("\t")
  end
end

こんな感じで mapping することもできます。

> User.new(address: Address.new("東京都", "渋谷区", "神宮前5丁目52-2青山オーバル13F")).attributes
=> {"id"=>nil,
 "first_name"=>nil,
 "last_name"=>nil,
 "email"=>nil,
 "full_address"=>"東京都\t渋谷区\t神宮前5丁目52-2青山オーバル13F",
 "address_prefecture"=>"東京都",
 "address_municipality"=>"渋谷区",
 "address_street"=>"神宮前5丁目52-2青山オーバル13F"}

また、constructor で初期化、converter で値の型と異なる型が渡されたときの処理を記述できます。

class User < ApplicationRecord
  composed_of :address,
              mapping: [%w(full_address full_address), %w(address_prefecture prefecture), %w(address_municipality municipality), %w(address_street street)],
              constructor: Proc.new { Address.new('default', '', '住所') },
              converter: Proc.new {|address| Address.new(*address.split("\t")) }
end
# 値を入れなくても
> u = User.new
=> #<User:0x00007f89e5469f58 id: nil, first_name: nil, last_name: nil, email: nil, full_address: nil, address_prefecture: nil, address_municipality: nil, address_street: nil>
# 値が代入されている
> u.address
=> #<Address:0x00007f89e5782f28 @municipality="の", @prefecture="default", @street="住所">

# 文字列を代入しても
> u.address = "東京都\t渋谷区\t神宮前5丁目52-2青山オーバル13F"
=> "東京都\t渋谷区\t神宮前5丁目52-2青山オーバル13F"
# Address 型に変換されている
> u.address
=> #<Address:0x00007f89e4f01ef8 @municipality="渋谷区", @prefecture="東京都", @street="神宮前5丁目52-2青山オーバル13F">

allow_nil オプションを渡すと nil を渡せます。

class User < ApplicationRecord
  composed_of :email
end

> User.new(email: nil)
NoMethodError: undefined method `email' for nil:NilClass
class User < ApplicationRecord
  composed_of :email, allow_nil: true
end

> User.new(email: nil).email
=> nil

これなら色々と融通が効きそうですね。

class User < ApplicationRecord
  composed_of :full_name, mapping: [%w(first_name first_name), %w(last_name last_name)]
  composed_of :email, allow_nil: true
  composed_of :address, mapping: [%w(full_address full_address), %w(address_prefecture prefecture), %w(address_municipality municipality), %w(address_street street)]
end

何にせよ、この User のように様々な属性を持つクラスは得てしてファットになりがちですが、このように composed_of を使うことで、個別事象のロジックで汚さずにスリムに保つことできます。

まとめ

Rails では値オブジェクトを扱うために composed_of というクラスメソッドが用意されています。これを使ってうまく責務を分離して、快適な Rails ライフを過ごしたいものですね。それでは。


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

© Sansan, Inc.