ユーザーがショッピングカートの中身を確認する | ドメイン駆動設計ハンズオン

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

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

フィーチャーツリーとユースケース

フィーチャーツリーは以下の通りでした。

  • 決済機能
  • ユーザー情報変更
  • 商品カタログ
    • (実装済み)商品の検索
    • (実装済み)商品管理
    • (実装済み)セール管理
  • 注文
    • ショッピングカート
    • 注文受付機能
    • 配送状態確認

ユースケースは以下の通りです。

  • (実装済み)管理者が商品を登録する
  • (実装済み)ユーザーが商品を検索する
  • (実装済み)管理者がセールを登録する
  • (実装済み)ユーザーがショッピングカートに商品を追加する
  • ユーザーがショッピングカートの中身を確認する

今回は「ユーザーがショッピングカートの中身を確認する」ユースケースを実装していきます。そして、これがハンズオンの最後になります。

機能要求の確認

プロダクトオーナーによると機能要求は以下の通りでした。

  • ショッピングカートに追加されている商品が一覧表示される
  • 商品は以下の情報を表示する
    • 商品名
    • 元々の価格
    • セール適用後の価格
    • 割引前小計
      • 元々の価格にショッピングカートに追加されている個数を掛けた額
    • 割引後小計
      • セールを適用した価格にショッピングカートに追加されている個数を掛けた額
  • ショッピングカート内のセールを適用した価格の合計金額を表示する
  • 表示する金額は小数点以下を切り捨てる
  • 途中の計算結果は小数点以下は切り捨てずに計算を行う

データモデリングの実施

機能要求を元にデータモデリングを行います。ショッピングカートを確認するユースケースなので、ルートはショッピングカートで商品の配列を持つという形で良いでしょう。他は挙げられている項目を属性として持たせれば良さそうです。

ショッピングカートのER図

Aggregateの実装

データモデリングの結果を元にAggregateを実装していきます。注文グループのショッピングカート機能なので、クラス名はOrder::ShoppingCartとします。

# ショッピングカート Aggregateルート
class Order::ShoppingCart
  def initialize(product_list, sale_infos)
    # 処理はうまくいくが別のModule(Package)に依存してしまっている
    sale_discount_rule =
      ProductCatalog::ProductSearch::SaleDiscountRule.new(sale_infos)
    @products = product_list.map do |product|
      Order::Product.new(product[:name], proeduct[:price],
                         product[:count], sale_discount_rule)
    end
  end

  def total_amount
    @products.map(&:discounted_subtotal).sum
  end
end

# 商品
class Order::Product
  attr_reader :name, :price

  def initialize(name, price, count, sale_discount_rule)
    @name = name
    @price = price
    @count = count
    @sale_discount_rule = sale_discount_rule
  end

  def discounted_price
    @sale_discount_rule.discounted_price(@price)
  end

  def subtotal
    @price * @count
  end

  def discounted_subtotal
    discounted_price * @count
  end
end

さて、ここで困ったことがあります。割引の計算および金額の計算は「ユーザーが商品を検索する」を再モデリングで作ったProductCatalog::ProductSearch::SaleDiscountRuleProductCatalog::ProductSearch::Amountが使えるので利用しているわけですが、これだとOrderモジュールがProductCatalog::ProductSearchモジュールに依存することになります。

モジュールの依存問題をどう解決するか

この処理だけみたら、大したことのないように思えるかもしれません。しかし、モジュール内のクラスが依存されるのは同じモジュールの中に限った方が良いでしょう。以下のコードのような依存関係が実際のコードで起こっていたらと想像してみてください。

# AモジュールのAaaクラス から BモジュールのBbbクラスに依存して
# BモジュールのBbbクラス から CモジュールのCccクラスに依存して
# CモジュールのCccクラス から AモジュールのXxxクラスに依存している
# モジュールのたらい回し状態
class A::Aaa
  def a
    B::Bbb.new.b
  end
end

class A::Xxx
  def x
  end
end

class B::Bbb
  def b
    C::Ccc.new.x
  end
end

class C::Ccc
  def c
    A::Xxx.new.x
  end
end

こんな悪夢のような依存関係になるとメンテナンスが不可能になります。なぜなら、あらゆるモジュールもクラスもどこから依存されているか把握するすべがないからです。なので、私はModule(Package)については以下のようにするのが良いと考えています。

A::A1::AaaクラスとA::B1::Bbbクラスの双方から使われるクラスXがあるならば
共通の祖先であるAパッケージの直下、つまりA::Xとして定義する

こうすることで、少なくともA::A1モジュールとA::B1モジュールでは、自分が属していない他のモジュールに依存されることがなくなります。Aモジュールにおいても自分の中のモジュールの中だけで依存が閉じます。

付け加えておくと、このクラスの置き場問題はValueObjectとSpecificationだけで起きます。こういった依存関係が許されるのがこの二つだけだからです。詳しくはドメイン駆動設計の戦術的設計とはを参照してください。

もし、Entityを共有したいと思うのならば、それはValueObjectまたはSpecificationとして切り出して共有するべきです。

Order::ShoppingCartの依存問題を解決する

ここまでの説明に倣うなら、最も単純なのはProductCatalog::ProductSearch::SaleDiscountRuleProductCatalog::ProductSearch::Amountなどをトップレベルのクラスにしてしまうことです。つまりモジュールを外してSaleDiscountRuleAmountにしてしまうことです。

しかし、フィーチャーツリーを見ると決済機能やユーザー情報変更など、明らかに使われない機能からの依存も許す状態になってしまいます。なのでフィーチャーツリーを見直しましょう。商品カタログも注文もショッピングのための機能です。なので、上位グループとして「ショッピング」を追加します。

  • 決済機能
  • ユーザー情報変更
  • ショッピング
    • 商品カタログ
      • (実装済み)商品の検索
      • (実装済み)商品管理
      • (実装済み)セール管理
    • 注文
      • ショッピングカート
      • 注文受付機能
      • 配送状態確認

ここまで実装してきた各クラスもこのフィーチャーツリーに合わせてModule(Package)構造を変更します。例えば、ProductCatalog::ProductSearch::ProductListShopping::ProductCatalog::ProductSearch::ProductListに変更します。Order::ShoppingCartShopping::Order::ShoppingCartに変更します。

これで、共通の祖先となるモジュールShoppingができたので、ProductCatalog::ProductSearch::SaleDiscountRuleなどもShopping::SaleDiscountRuleに移動することができ、依存問題を解決することができました。

# ショッピングカート Aggregateルート
class Shopping::Order::ShoppingCart
  def initialize(product_list, sale_infos)
    # 処理はうまくいくが別のModule(Package)に依存してしまっている
    sale_discount_rule = Shopping::SaleDiscountRule.new(sale_infos)
    @products = product_list.map do |product|
      Order::Product.new(product[:name], proeduct[:price],
                         product[:count], sale_discount_rule)
    end
  end
  # 以下略

まとめ

実装としてはなんとも中途半端なところですが、これでハンズオンは終わりになります。

色々と駆け足でしたが、フィーチャーツリーを作り、ユースケースの一覧をまとめ、ユースケースごとにデータモデリングとデータディクショナリ、ビジネスルールをまとめることで実装に落とし込む候補を洗い出すことが重要になります。また、必要に応じてモデリングし直すことでドメインモデルや設計を洗練させていくことも重要になります。

本来なら、作成したドメインモデル(データモデルやビジネスルールの一覧、データディクショナリなど)は見返したり再モデリングしたりするときのためにアクセスしやすいように管理しておくべきです。それらの資料はビジネスサイドやプロダクトオーナーにとっても利用価値のある成果物であり、Miroなどにその場限りで書き殴ったりするのは避けた方が良いでしょう。

Next: 落穂拾い