「ユーザーが商品を検索する」を再モデリング | ドメイン駆動設計ハンズオン

参考書籍一覧(Amazon アソシエイトリンク)

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クラスにしても良いですが、各々に適用されるビジネスルールが異なるため、PercentageDiscountRuleFixedAmountDiscountRuleに分けることにします。

applicable_saleを適用した値引き後金額を返すメソッドとしてdiscounted_priceメソッドを用意するとして、クラス構造は以下のようになるでしょう。

Specificationのクラス構造

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_amountprice * @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_priceProductCatalog::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で置き換えないのか

まず、度々いっている通り全部を全部ValueObjectにするというのは実装コストやパフォーマンス、可読性の面から考えると、本当に必要なのかという問いをたてなければなりません。そして大体のところ、そんなことをする必要性はありません。

そうではなく、ProductCatalog::ProductSearch::FixedAmountDiscountRule@target_priceなどをProductCatalog::ProductSearch::Amountのインスタンスをした方が良いのではないかということであれば、明確に理由があります。

ProductCatalog::ProductSearch::Amountは金額のValueObjectですが、より突き詰めて分析すると価格に対して計算した結果の金額で、単なる金額とは呼べません。そういう意味で、「対象価格」の@target_priceも「値引額」の@fixed_amountProductCatalog::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: ユーザーがショッピングカートに商品を追加する

データモデルのER図にValueObjectなどは反映しないのか

チームの方針次第かなと思います。あくまで、データモデルはドメインモデルとしてのデータ構造を表現したいというなら、ValueObjectなどはエンティティの属性として表現されるでしょう。

逆に、クラス構造を反映した図が欲しいというなら、そういう方向で作ったら良いかと思います。

ただ言えるのは、クラス構造を反映するというのはエンジニア目線でしかないということです。ビジネスサイドやプロダクトオーナーもデータモデルの図は見るので、不必要に詳細が書かれてしまうとプロダクトオーナーたちにとっては読みづらい図になってしまうかもしれません。

また、クラス構造はプロダクトオーナーたちには見えないのでメンテが大変になるという問題もあります。純粋なデータモデルによるところならコードを見ずにドメイン知識のみに基づいて修正することができます。

「コードを見ずに」といっても、度々言っている通り、実装に反映できるドメインモデルである必要はあります。

Next: ユーザーがショッピングカートに商品を追加する