DSOC サービス開発部でエンジニアをしている石畑です。普段は Rails で名寄せサービスを作っています。 今回は Rails で値オブジェクトを扱うのに ActiveRecord の composed_of が便利なので、紹介します。
値オブジェクト
値オブジェクトは DDD でも紹介されている概念です。多くのわかりやすい解説が世の中にあるので、詳しくは検索してもらえればと思いますが、ものすごく大雑把に説明すると「各属性で等価を判断できる不変なオブジェクト」です。
例えば「とあるスーパーでお肉を売る」を考えたときに、最初「300 円」で売っていた「お肉 A」を途中タイムセールで 100 円引きの「200 円」で売ったとしても「お肉 A」は値段を変更する前と「同一のお肉」です。
そのため、「お肉」の同一性は属性で判断することはできず、バーコードのような識別子で同一性を追跡します。これは「値オブジェクト」ではありません(DDD ではエンティティと呼ばれます)。
これに対して、お肉の「値段」は「数字部分」と「貨幣単位」という二つの属性で等価かどうか判断することができます。例えば、「300 円」と「300 円」は常に同一の物(等価)で、「300 円」と「200 円」は常に別物です。また、「300 円」と「300 ドル」も常に別物です。そして、各値段は不変で、「数字部分」だけを取り替えることはできません(別物になってしまいます)。このような概念は「値オブジェクト」として扱えます。
※ 調子乗って「常に」とか言ってしまいましたが、値段を「値」として扱うかどうかはそのドメインによるもので、もしかしたらあるサービスでは「ある 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 の意図が明確になったので扱いやすくなったと思います。
セッターを追加する
これだけでは 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 ライフを過ごしたいものですね。それでは。