「ユーザーが商品を検索する」を再モデリング | ドメイン駆動設計ハンズオン
Prev: 管理者がセール情報を登録する
フィーチャーツリーとユースケース
フィーチャーツリーは以下の通りでした。
- 決済機能
- ユーザー情報変更
- 商品カタログ
- (実装済み)商品の検索
- (実装済み)商品管理
- (実装済み)セール管理
- 注文
- ショッピングカート
- 注文受付機能
- 配送状態確認
ユースケースは以下の通りです。
- (実装済み)管理者が商品を登録する
- (実装済み)ユーザーが商品を検索する
- (実装済み)管理者がセールを登録する
- ユーザーがショッピングカートに商品を追加する
- ユーザーがショッピングカートの中身を確認する
セール機能の仕様を再確認する
前回、セールを登録する機能が追加され、機能要求として以下が挙げられました。
- セールの開始日と終了日が指定できる
- セールの種類は以下の2種類のいずれか
- 10%OFFのような一律の割引
- 5000円以上の商品500円OFFのような条件付き値引き
- 値引き後価格が0円以下にならないようにする
- セール期間が重複した場合は値引き金額が一番大きいセールだけが適用される
- 商品一覧に表示する価格はセール適用前と適用後の両方を併記する
これらの文章はすべてモデリング手法で言及したビジネスルールであることにお気づきでしょうか。
- セールの開始日と終了日が指定できる(ファクト)
- セールの種類は以下の2種類のいずれか(ファクト)
- 10%OFFのような一律の割引(計算)
- 5000円以上の商品500円OFFのような条件付き値引き(計算および制約)
- 値引き後価格が0円以下にならないようにする(制約)
- セール期間が重複した場合は値引き金額が一番大きいセールだけが適用される(アクションイネーブラ)
- 商品一覧に表示する価格はセール適用前と適用後の両方を併記する(ファクト)
まず以下二つについてはファクトです。どちらも前回登録できるように実装しました。今回影響するとしたら「今日がセール対象か」という点です。これは、RepositoryのSQLの条件として指定するのが自然なので、ドメインロジックとしては一旦忘れましょう。
- セールの開始日と終了日が指定できる(ファクト)
- セールの種類は以下の2種類のいずれか(ファクト)
次に以下の3つです。これは明らかな計算ルールなので、ドメインロジックとして実装する対象になります。「値引き後価格が0円以下にならないようにする(制約)」は前回実装していますが、ビジネスロジックとしても0円以下になったら1円にするようにしましょう。
- 10%OFFのような一律の割引(計算)
- 5000円以上の商品500円OFFのような条件付き値引き(計算および制約)
- 値引き後価格が0円以下にならないようにする(制約)
次に以下についてです。これから複数のセールを考慮しなければならないことがわかります。
- セール期間が重複した場合は値引き金額が一番大きいセールだけが適用される(アクションイネーブラ)
最後については、画面表示内容についてのファクトです。計算結果をそのまま表示すれば良いでしょう。
- 商品一覧に表示する価格はセール適用前と適用後の両方を併記する(ファクト)
挙げられているビジネスルールは全て確認しましたが、2点漏れている視点があります。1点は「150円の商品を3%OFFしたらいくらになるか?」です。小数点以下を切り捨てるのか切り上げるのかはっきりしていません。もう1点は「3%OFFの150円の商品を10個買ったらいくらになるか」です。
ここではプロダクトオーナーとの相談で、商品の価格で小数点以下がある場合は切り捨て、まとめ買い時は「価格*割引率*個数」の計算結果で小数点以下がある場合は切り捨てという仕様に決まりました。
Specificationの設計
では設計をしていきます。「セール期間が重複した場合は値引き金額が一番大きいセールだけが適用される(アクションイネーブラ)」のビジネスルールから以下のような感じになりそうなのは想像がつきます。
# セールの一覧から最も値引率の高いものを取得する
applicable_sale = sale_list.max_by { |sale| sale.discount_amount(price) }
次に、sale_list
をどうするかです。パーセント割引と固定額値引で区別なく一つのDiscountRule
クラスにしても良いですが、各々に適用されるビジネスルールが異なるため、PercentageDiscountRule
とFixedAmountDiscountRule
に分けることにします。
applicable_sale
を適用した値引き後金額を返すメソッドとしてdiscounted_price
メソッドを用意するとして、クラス構造は以下のようになるでしょう。
Specificationの実装
上記の図に則って実装していきます。Module(Package)は商品の検索機能で使われるのでProductCatalog::ProductSearch
にします。
# セールの割引価格を計算するビジネスルール
class ProductCatalog::ProductSearch::SaleDiscountRule
def initialize(sale_infos)
# sale_info[:percentage]があるならPercentageDiscountRule
# ないならFixedAmountDiscountRule
# のインスタンスを作って配列にして@sale_listに入れる
@sale_list = sale_infos.map do |sale_info|
if sale_info[:percentage].present?
next ProductCatalog::ProductSearch::PercentageDiscountRule.new(
sale_info[:percentage]
)
end
ProductCatalog::ProductSearch::FixedAmountDiscountRule(
sale_info[:target_price], sale_info[:fixed_amount]
)
end
end
def discounted_price(price)
applicable_sale = sale_list.max_by do |sale|
sale.discount_amount(price)
end
price - applicable_sale.discount_amount(price)
end
end
# パーセント割引の計算をするビジネスルール
class ProductCatalog::ProductSearch::PercentageDiscountRule
def initialize(percentage)
@percentage = percentage
end
def discount_amount(price)
price * @percentage / 100
end
end
# 固定額値引の計算をするビジネスルール
class ProductCatalog::ProductSearch::FixedAmountDiscountRule
def initialize(target_price, fixed_amount)
@target_price = target_price
@fixed_amount = fixed_amount
end
def discount_amount(price)
# 対象価格未満の価格は値引きしない
return 0 if price < @target_price
# 値引後価格が0以下になるなら値引後価格が1円になるように値引
return price - 1 if price <= @fixed_amount
@fixed_price
end
end
これで一旦はSpecificationが完成しましたが、実は1点バグがあります。このバグについてはあとで回収します。
商品一覧のAggregateの改修
商品一覧で値引後価格が表示できるように、商品一覧のAggregateも改修していきましょう。
# 商品一覧
class ProductCatalog::ProductSearch::ProductList
attr_reader :products
# sale_infosを追加。以下のような配列を想定
# [ { percentage: 10, target_price: nil, fixed_amount: nil },
# { percentage: nil, target_price: 5000, fixed_amount: 500 },
# ... ]
def initialize(products, sale_infos)
# SaleDiscountRuleをインスタンス化
sale_discount_rule =
ProductCatalog::ProductSearch::SaleDiscountRule.new(sale_infos)
@products = products.map do |product|
# sale_discount_ruleをコンストラクタの引数に追加
ProductCatalog::ProductSearch::Product.new(
product[:name], product[:price], sale_discount_rule
)
end
end
end
class ProductCatalog::ProductSearch::Product
attr_reader :name, :price
def initialize(name, price, sale_discount_rule)
@name = name
@price = price
@sale_discount_rule = sale_discount_rule
end
# 値引き後価格を返せるようにする
def discounted_price
@sale_discount_rule.discounted_price(@price)
end
end
ここまでで、「商品の価格で小数点以下がある場合は切り捨て、まとめ買い時は「価格*割引率*個数」の計算結果で小数点以下がある場合は切り捨て」以外の実装はできました。
価格計算に関する問題
問題は「商品の価格で小数点以下がある場合は切り捨て、まとめ買い時は「価格*割引率*個数」の計算結果で小数点以下がある場合は切り捨て」をどう実装するかです。ProductCatalog::ProductSearch::Product#discounted_price
で切り捨て、まとめ買いの計算をするときはそのときにまた切り捨てを行えば一旦は解決します。
しかし、切り捨てなのか切り上げなのか四捨五入なのか、実装箇所が増えるたびに確認して足並みを揃える必要があります。また、「切り捨てじゃなくて切り上げにしよう」という仕様変更が入ったとき修正するのがとても大変になります。なので、切り捨て処理は統一した方法をとった方が良いでしょう。
また、各所で計算を行うと基本的なミスを犯すことがあります。例えばProductCatalog::ProductSearch::PercentageDiscountRule#discount_amount
でprice * @percentage / 100
という計算をしていますが、Rubyにおいて整数同士の四則演算の結果は整数です。つまり、150 * 0.03
の計算は4.5
ですが、150 * 3 / 100
だと4
になってしまいます。各所でこういう計算を制限なく行ってしまうとわかりにくいバグを作り込む原因になります。また、適切に小数点を含む計算をしたとしても浮動小数になり精度の問題があります。なので、これについても計算処理は統一した処理を用いる方が良いといえます。
さらに、切り捨て処理は必要になったところで行うようにして、基本的には精度の高い値をずっと使えた方が都合が良いでしょう。というのも、どこで精度の高い値に基づいた計算が必要になるか想定することが難しいからです。
データディクショナリによる分析
なぜ、ここまで順調にSpecificationの実装を進めていたのに、ここにきてこんな問題にぶつかってしまったのでしょうか。原因は端的にいうとデータディクショナリを作成してこなかったためです。データディクショナリを作成してどういう値があるのかを洗い出しておけば、価格計算に関する問題は「金額」としてValueObjectを作っておけばスマートに解決できることに気づけたはずです。
- データ項目名:金額
- 説明:商品価格に対して四則演算した結果の額。必要に応じて小数点以下を切り捨てた額を指す
- 構成またはデータ型:有理数
- 長さ、値:無限精度
ValueObjectの実装
「金額」のValueObject、Amount
を実装します。
内部で保持する無限精度の数値は有理数が適しているので有理数で保持することにします。また、四則演算と大小比較が必要なのでそれらのメソッドを定義します。ValueObjectは基本的にイミュータブルにした方が良いため、各四則演算メソッドではインスタンス変数を変更せずに新しいインスタンスを返すようにします(普通の整数の四則演算と同じですね)。最後に、出力用の小数点以下切り捨てのメソッドを定義します。
class ProductCatalog::ProductSearch::Amount
include Comparable # 比較演算子のために必要
def initialize(value)
# to_rは数値を有理数に変換するメソッド
# 有理数で計算することで無限精度が得られる
@value = value.to_r
end
# rubyでは四則演算メソッドを定義できる
def +(other)
# self.classは自身のクラスつまり
# ProductCatalog::ProductSearch::Amount
# のことで、インスタンスメソッドの中で
# 新しいインスタンスを作っている
self.class.new(@value + other.to_r)
end
def -(other)
self.class.new(@value - other.to_r)
end
def *(other)
self.class.new(@value * other.to_r)
end
def /(other)
self.class.new(@value / other.to_r)
end
# 比較演算子のために必要
def <=>(other)
@value <=> other
end
def print_value
# 整数にすることで小数点以下を切り捨てる
@value.to_i
end
end
# 以下サンプル
x = ProductCatalog::ProductSearch::Amount.new(10)
((x + 4) / 2 * 3 - 10).print_value # => 11
ValueObjectを使う形で実装し直し
金額のValueObjectもできたので、これを使う形に実装しなおします。修正するのはProductCatalog::ProductSearch::SaleDiscountRule#discounted_price
とProductCatalog::ProductSearch::FixedAmountDiscountRule#discount_amount
だけです。
# セールの割引価格を計算するビジネスルール
class ProductCatalog::ProductSearch::SaleDiscountRule
def initialize(sale_infos)
@sale_list = sale_infos.map do |sale_info|
if sale_info[:percentage].present?
next ProductCatalog::ProductSearch::PercentageDiscountRule.new(
sale_info[:percentage]
)
end
ProductCatalog::ProductSearch::FixedAmountDiscountRule(
sale_info[:target_price], sale_info[:fixed_amount]
)
end
end
# discount_amountメソッドに直接priceを渡していたのをValueObjectを渡す形に変更
def discounted_price(price)
price_vo = ProductCatalog::ProductSearch::Amount.new(price)
applicable_sale = sale_list.max_by do |sale|
sale.discount_amount(price_vo)
end
price_vo - applicable_sale.discount_amount(price_vo)
end
end
# パーセント割引の計算をするビジネスルール
class ProductCatalog::ProductSearch::PercentageDiscountRule
def initialize(percentage)
@percentage = percentage
end
def discount_amount(price)
price * @percentage / 100
end
end
# 固定額値引の計算をするビジネスルール
class ProductCatalog::ProductSearch::FixedAmountDiscountRule
def initialize(target_price, fixed_amount)
@target_price = target_price
@fixed_amount = fixed_amount
end
# 戻り値がAmountになるように0や@fixed_priceもAmountのインスタンスにしている
def discount_amount(price)
# 対象価格未満の価格は値引きしない
if price < @target_price
return ProductCatalog::ProductSearch::Amount.new(0)
end
# 値引後価格が0以下になるなら値引後価格が1円になるように値引
return price - 1 if price <= @fixed_amount
ProductCatalog::ProductSearch::Amount.new(@fixed_price)
end
end
小数点以下の切り捨てについては、必要になったタイミングでProductCatalog::ProductSearch::Amount#print_value
を呼べば良いので、Aggregateはそのままで問題ありません。
まず、度々いっている通り全部を全部ValueObjectにするというのは実装コストやパフォーマンス、可読性の面から考えると、本当に必要なのかという問いをたてなければなりません。そして大体のところ、そんなことをする必要性はありません。
そうではなく、ProductCatalog::ProductSearch::FixedAmountDiscountRule
の@target_price
などをProductCatalog::ProductSearch::Amount
のインスタンスをした方が良いのではないかということであれば、明確に理由があります。
ProductCatalog::ProductSearch::Amount
は金額のValueObjectですが、より突き詰めて分析すると価格に対して計算した結果の金額で、単なる金額とは呼べません。そういう意味で、「対象価格」の@target_price
も「値引額」の@fixed_amount
もProductCatalog::ProductSearch::Amount
にしてしまうのは適切ではないということです。
ここで、「なら、”値引額”を返すdiscount_amount
と”値引後額”を返すdiscounted_price
がどちらもAmountを返しているのはおかしいのでは?」と思った人はとても鋭いです。ハンズオンだからいい加減な例にしているというのもありますが、わざわざ、クラスを分ける理由が実装上見つからず、実装が簡単だったというのが理由です。
また、@target_price
などをProductCatalog::ProductSearch::Amount
にしてしまうと、別の不都合が生じます。ProductCatalog::ProductSearch::Amount
の各メソッドで受け取った引数が、整数なのかAmountなのか区別して実装する必要が出てくるのです。もちろん、工夫のしようはいくらでもありますが、一番単純な実装にするために、このようにしているわけです。
Repositoryの修正
これで、セールに関するDomainModelの実装は終わりです。最後にセール情報を読み出す処理をRepositoryに追加しましょう。
class ProductCatalog::ProductSearch::ProductListRepository
def search(name: '', from_price: nil, to_price: nil, page: nil)
products = Product
products = base.where('name like ?', "%#{name}%") if name.present?
products = base.where('? < price', from_price) if from_price.present?
products = base.where('price < ?', to_price) if to_price.present?
products = products.page(page).per(20)
# ActiveRecordの配列から以下の形式のhashの配列に変換している
# [{ name: '商品名A', price: 10000 }, { ... }]
product_hash_list = products.select(:id, :name)
.map(&:attributes)
.map(&:symbolize_keys)
# ここの実装を追加。現在を起点にfrom toの範囲にマッチするセール情報を取得
sales = Sale.where('`from` <= ? and ? <= `to`',
Time.zone.now, Time.zone.now)
.select(:percentage, :target_price, :fixed_amount)
sale_hash_list = sales.map(&:attributes).map(&:symbolize_keys)
ProductCatalog::ProductSearch::ProductList.new(
product_hash_list, sale_hash_list
)
end
end
Next: ユーザーがショッピングカートに商品を追加する
チームの方針次第かなと思います。あくまで、データモデルはドメインモデルとしてのデータ構造を表現したいというなら、ValueObjectなどはエンティティの属性として表現されるでしょう。
逆に、クラス構造を反映した図が欲しいというなら、そういう方向で作ったら良いかと思います。
ただ言えるのは、クラス構造を反映するというのはエンジニア目線でしかないということです。ビジネスサイドやプロダクトオーナーもデータモデルの図は見るので、不必要に詳細が書かれてしまうとプロダクトオーナーたちにとっては読みづらい図になってしまうかもしれません。
また、クラス構造はプロダクトオーナーたちには見えないのでメンテが大変になるという問題もあります。純粋なデータモデルによるところならコードを見ずにドメイン知識のみに基づいて修正することができます。
「コードを見ずに」といっても、度々言っている通り、実装に反映できるドメインモデルである必要はあります。
Next: ユーザーがショッピングカートに商品を追加する