管理者がセール情報を登録する | ドメイン駆動設計ハンズオン

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

Prev: ユーザーが商品を検索する

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

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

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

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

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

割り込みの機能追加

ここで、プロダクトオーナーが1つ機能を忘れていたと言い出しました(そういうことにしてください)。「このECサイトではセールを行うことがあり、セールの情報を登録できる必要がある」とのことで、商品カタロググループにセール管理機能を追加し、ユースケースに「管理者がセールを登録する」を追加するとのことでした。新しいフィーチャーツリーとユースケース配下の通りです。

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

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

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

機能要求としては

  • セールの開始日と終了日が指定できる
  • セールの種類は以下の2種類のいずれか
    • 10%OFFのような一律の割引
    • 5000円以上の商品500円OFFのような条件付き値引き
      • 値引き後価格が0円以下にならないようにする
  • セール期間が重複した場合は値引き金額が一番大きいセールだけが適用される
  • 商品一覧に表示する価格はセール適用前と適用後の両方を併記する

というものでした。

商品一覧の価格の適用はともかくとして、まずは「管理者がセールを登録する」を実装していきましょう。

データモデリング

セールは「10%OFFのような一律の割引」というパーセント割引、「5000円以上の商品500円OFFのような条件付き値引き」という固定額値引が排他で登録されます。データモデルもこれに習って、パーセント割引と固定額値引で分けることにしましょう。

セールのER図

Aggregateの実装

ER図の通りに実装します。

# セール Aggregateルート
class ProductCatalog::SaleManagement::Sale
  attr_reader :from, :to, :percentage_discount, :fixed_amount_discount

  def initialize(from, to, percentage: nil,
                 target_price: nil, fixed_amount: nil)
    @from = from
    @to = to
    if percentage.present?
      @percentage_discount =
        ProductCatalog::SaleManagement::PercentageDiscount.new(percentage)
      return
    end

    @fixed_amount_discount =
      ProductCatalog::SaleManagement::FixedAmountDiscount.new(
        target_price, fixed_amount
      )
  end
end

# パーセント割引
class ProductCatalog::SaleManagement::PercentageDiscount
  attr_reader :percentage

  def initialize(percentage)
    @percentage = percentage
  end
end

# 固定額値引
class ProductCatalog::SaleManagement::FixedAmountDiscount
  attr_reader :target_price, :fixed_amount

  def initialize(target_price, fixed_amount)
    @target_price = target_price
    @fixed_amount = fixed_amount
  end
end

ここでバリデーションについて考えます。固定額値引の場合は「値引き後価格が0円以下にならないようにする」という機能要求があるのでこのバリデーションが必要になります。問題はこのバリデーションをどこで実装して、どうチェックするかです。やり方はいろいろ考えられますが、ここではバリデーションの実行に必要な情報を持つEntityでバリデーションを実行し、親のEntityで子供のEntityのバリデーションメソッドの結果を集約するという方法を取ります。

# セール Aggregateルート
class ProductCatalog::SaleManagement::Sale
  attr_reader :from, :to, :percentage_discount, :fixed_amount_discount

  # パーセント割引も固定額値引も指定されていないケースを考慮していないが
  # ハンズオンなので省略
  def initialize(from, to, percentage: nil,
                 target_price: nil, fixed_amount: nil)
    @from = from
    @to = to
    if percentage.present?
      @percentage_discount =
        ProductCatalog::SaleManagement::PercentageDiscount.new(percentage)
      return
    end

    @fixed_amount_discount =
      ProductCatalog::SaleManagement::FixedAmountDiscount.new(
        target_price, fixed_amount
      )
  end

  def valid?
    return @percentage_discount.valid? if @percentage_discount.present?

    @fixed_amount_discount.valid?
  end
end

# パーセント割引
class ProductCatalog::SaleManagement::PercentageDiscount
  attr_reader :percentage

  def initialize(percentage)
    @percentage = percentage
  end

  # パーセンテージの指定がされているかというチェックもできるが
  # ハンズオンなので省略
  def valid?
    true
  end
end

# 固定額値引
class ProductCatalog::SaleManagement::FixedAmountDiscount
  attr_reader :target_price, :fixed_amount

  def initialize(target_price, fixed_amount)
    @target_price = target_price
    @fixed_amount = fixed_amount
  end

  def valid?
    return false if @target_price <= @fixed_amount

    true
  end
end

Repositoryの実装

Repositoryの実装は特に難しいところはありません。単にAggregateからテーブルに詰め替えるだけです。ただ、テーブルは1つのテーブルにパーセント割引と固定額値引の区別なく入れることにします(※特に理由はありません。テーブル設計は本題ではないしハンズオンなのでいい加減に決めています)。

class ProductCatalog::SaleManagement::SaleRepository
  # 引数は ProductCatalog::SaleManagement::Sale のインスタンス
  # percentage_discount&.percentage などの&.は
  # percentage_discountがnil(null)だった場合に
  # percentageメソッドを呼び出さずにnilを返すメソッド呼び出し方法
  def save(sale)
    percentage_discount = sale.percentage_discount
    fixed_amount_discount = sale.fixed_amount_discount
    sale = Sale.new(from: sale.from,
                    to: sale.to,
                    percentage: percentage_discount&.percentage,
                    target_price: fixed_amount_discount&.target_price,
                    fixed_amount: fixed_amount_discount&.fixed_amount)

    sale.save!
  end
end

ApplicationServiceの実装

ApplicationServiceの実装も特に難しいところはありません。「管理者がセールを登録する」ユースケースなのでAdministratorRegisterSaleとします。

# 管理者がセールを登録するユースケース
class AdministratorRegisterSale
  def perform(from, to, percentage: nil,
              target_price: nil, fixed_amount: nil)
    sale = ProductCatalog::SaleManagement::Sale.new(
             from, to, percentage: percentage,
             target_price: target_price, fixed_amount: fixed_amount
           )
    return false if !sale.valid?

    repository = ProductCatalog::SaleManagement::SaleRepository.new

    result = false
    ActiveRecord::Base.transaction do
      repository.save(sale)

      result = true
    end

    result
  end
end

Next: 「ユーザーが商品を検索する」を再モデリング