概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: The Case for before_validation callback: complex state normalization - Karol Galanciak - Ruby on Rails and Ember.js consultant
- 原文公開日: 2018/05/27
- 著者: Karol Galanciak
Rails: before_validation
コールバックで複雑なステートを正規化する(翻訳)
数か月前に投稿した別記事で、ActiveRecordコールバックのbefore_validation
がいかに誤った理由で乱用されているか、そしてほとんどの場合before_validation
は常用すべきでないことについて説明しましたが、before_validation
が適切なユースケースがひとつあることについて書き漏らしていました。before_validation
が適切なユースケースは、おそらく多くのRailsアプリに該当するでしょう。そこで再びbefore_validation
を取り上げ、before_validation
の別の姿をご覧いただきたいと思います。
問題の分析
Payment
モデルがあり、そこにamount
とcurrency
を保存する必要があるとしましょう。ただし、支払いの作成時点の為替レートを用いて正規化したUSドル表記の総額も保存しておいて、統計で使えるようにしたいとします。ここは私たちの(ビジネス)ドメインの重要な部分なので、amount_in_usd
属性のバリデーションも追加したいと思います。この時点のPayment
モデルは次のような感じになります。
class Payment < ApplicationRecord
validates :amount, :currency, :amount_in_usd, presence :true
end
ここで問題になるのは、「amount_in_usd
をどこから取得するか」と「amount_in_usd
をどのように代入するか」です。
解決方法
解決方法のひとつは、すべての属性を代入するタイミングで直接代入するというものです。この場合次のような感じになるでしょう。
Payment.new(currency: currency, amount: amount, amount_in_usd: CurrencyExchanger.exchange(amount, from: currency, to: "USD"))
この方法の問題は、支払いを初期化するたびにこのロジックを繰り返す必要がある点です。どのシナリオでも再利用できるファクトリークラスを1つ実装してDRYにする方法もあるといえばありますが、少々オーバーヘッドを伴うためRails界隈では人気がありません。しかも内部ステートを管理するので、これはPayment
モデル自身の責務であるように思われます。
以前の記事でも説明したように、amount_in_usd
はcurrency
とamount
という2つの属性に依存しているため、ライターをオーバライドする方法では解決できません。しかも、2つの属性が代入される順序も予測できません。
before_validation
はまさにこのような場合にうってつけなのです。before_validation
は、複数の属性が絡んでいる複雑なステートをかなりエレガントかつシンプルに正規化できます。
class Payment < ApplicationRecord
validates :amount, :currency, :amount_in_usd, presence :true
before_validation :assign_amount_in_usd
private
def assign_amount_in_usd
if currency && amount
self.amount_in_usd = CurrencyExchanger.exchange(amount, from: currency, to: "USD")
end
end
end
別の解決方法
本記事の最初のパラグラフで、この解決方法は特にRailsアプリでうまくいくと書きました。つまり、HTTP params由来の「プリミティブな」属性は通常モデルにマスアサインされるという事実があるということです。言うまでもなくRubyではあらゆるものがオブジェクトですが、シンプルに解決するのであれば数値型と文字列型をプリミティブとして扱いましょう。
しかしプリミティブでない値の場合はどうすればよいのでしょうか?上の事例の場合、広く使われているValue Object的なものを使う手もあります。つまりamount
とcurrency
を持つMoney
オブジェクトです。代入前の属性が他のドメイン指向オブジェクトにも対応付けられる場合は、次のようにさらにシンプルに解決できるでしょう。
money = Money.new(amount, currency)
Payment.new(money: money)
この場合Payment
モデルは次のようになるでしょう。
class Payment < ApplicationRecord
validates :amount, :currency, :amount_in_usd, presence :true
def money=(money_object)
self.amount = money_object.amount
self.currency = money_object.currency
self.amount_in_usd = CurrencyExchanger.exchange_money(money_object, to: "USD")
end
end
この方法は不要なオーバーヘッドを含むようにも見えますが、Value Objectは多くの場合コードをシンプルかつDRYにする傾向があります。さらに複雑なアプリでは、少々のオーバーヘッドを伴ってもValue Objectを使う価値があります。
最後に
before_validation
は有用な場合もあります。しかし、複雑なアプリならValue Objectを検討する価値もあるでしょう。