管理者が商品を登録する | ドメイン駆動設計ハンズオン

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

Prev: ユースケースの洗い出し

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

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

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

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

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

この章では「管理者が商品を登録する」ユースケースで、「商品カタログ」グループの「商品管理」機能を実装していきます。

データモデリングの実施

商品を登録するので、登録する商品の情報のデータモデルを分析する必要があります。当然、どういう情報が必要なのかはビジネスサイドかプロダクトオーナーにしか判断ができないので、ビジネスサイドかプロダクトオーナーが中心となってデータモデリングを行う必要があります。モデリングに不慣れならエンジニアがサポートすることは当然必要になりますし、ドメイン駆動設計を行っているのですから、実装に反映できるドメインモデルを構築できるようにエンジニアも主体となってモデリングに参加する必要があります(実践モデラです)。

プロダクトオーナーと話し合って以下の情報を登録する必要があることがわかったとします(ハンズオンなので最低限のものにしています)。

  • 商品名
  • 価格(税別)
  • 保管している倉庫(複数)

ここでER図にしてみると以下のようになります。

登録する商品のER図

また、商品名は100文字までの制限があることもわかりました(本来はデータディクショナリを作って分析するべきですがハンズオンの都合で省略です)。

Aggregateの実装

では、上記のER図を元にAggregateを実装します。

まず、フィーチャーツリーをみて、どこの機能グループに属するかを考えます。今回の場合「商品カタログ」の「商品管理」に入れるのが良さそうです。そのため、Module(Package)はProductCatalog::ProductManagementにすることにしましょう。Aggregate内のEntityの名前は商品はProduct、保管している倉庫はWarehouseとします。

# 商品 Aggregateルート
class ProductCatalog::ProductManagement::Product
end

# 保管している倉庫
class ProductCatalog::ProductManagement::Warehouse
end

次にコンストラクタで値を受け取り、getterを定義します。

# 商品 Aggregateルート
class ProductCatalog::ProductManagement::Product
  attr_reader :name, :price

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

# 保管している倉庫
class ProductCatalog::ProductManagement::Warehouse
  attr_reader :warehouse_id

  def initialize(warehouse_id)
    @warehouse_id = warehouse_id
  end
end

WarehouseProductが複数持つことになるので、その点について実装が必要になります。選択肢はWarehouseのインスタンスのリストをProductのコンストラクタで受け取って保持するか、Warehouseのインスタンス化に必要な情報(今回の場合なら倉庫ID)をProductのコンストラクタが受け取ってWarehouseをインスタンス化するかのどちらかです。setterでWarehouseのリストを更新できるようにするというのは、そうするのが最適な場合を除いてお勧めしません。今回はコンストラクタでインスタンス化する方法をとりましょう。

# 商品 Aggregateルート
class ProductCatalog::ProductManagement::Product
  attr_reader :name, :price, :warehouses

  # warehouse_idsは倉庫IDの配列
  def initialize(name, price, warehouse_ids)
    @name = name
    @price = price
    # 倉庫IDの配列の要素を一つずつ取り出してWarehouseのインスタンスを作り
    # Warehouseのインスタンスの配列を作っている
    @warehouses = warehouse_ids.map do |warehouse_id|
      ProductCatalog::ProductManagement::Warehouse.new(warehouse_id)
    end
  end
end

# 保管している倉庫
class ProductCatalog::ProductManagement::Warehouse
  attr_reader :warehouse_id

  def initialize(warehouse_id)
    @warehouse_id = warehouse_id
  end
end

最後に商品名の長さに関するバリデーションを実装します。

# 商品 Aggregateルート
class ProductCatalog::ProductManagement::Product
  attr_reader :name, :price

  # warehouse_idsは倉庫IDの配列
  def initialize(name, price, warehouse_ids)
    @name = name
    @price = price
    # 倉庫IDの配列の要素を一つずつ取り出してWarehouseのインスタンスを作り
    # Warehouseのインスタンスの配列を作っている
    @warehouses = warehouse_ids.map do |warehouse_id|
      ProductCatalog::ProductManagement::Warehouse.new(warehouse_id)
    end
  end

  def valid?
    return false if 100 < @name.length

    true
  end
end

# 保管している倉庫
class ProductCatalog::ProductManagement::Warehouse
  attr_reader :warehouse_id

  def initialize(warehouse_id)
    @warehouse_id = warehouse_id
  end
end

これでDomainModel(AggregateとEntity)の実装は終わりです。

複雑なバリデーションが必要な場合はどうするか

バリデーションが複雑な場合やバリデーションメッセージが必要な場合はどうするのが良いのかという問題があります。1つの案はフレームワークの仕組みを利用してしまうものです。RailsならActiveModelという機能があるので、それを利用すればActiveRecordにあるようなバリデーション機能を利用することができます。しかし、この方法を取れば、即座にドメインレイヤがフレームワークに依存することになります。

もう一つの案は、以下のようにバリデーション結果を状態として持たせるものです。以下の実装は極めて雑ですが、RailsのActiveRecordは似たような仕組みでバリデーション結果を保持しています。多言語化が必要なら、もうひと工夫必要になるでしょう。

def valid?
  @errors = {}
  if 100 < @name.length
    @errors[:name] = { length: '100文字上限を超えている' }
  end

  return @errors.empty?
end

def errors
  @errors ||= {}
end

Repositoryの実装

Repositoryの実装は単純です。Aggregateを受け取って永続化するだけです。

# 以下の関連のテーブルがあるとする
# products ||--o{ product_warehouses }o--|| warehouses
# このクラスの中で呼んでいる Product.new は
# ActiveRecord の Product クラスをインスタンス化している
class ProductCatalog::ProductManagement::ProductRepository
  # 引数はProduct Aggregate
  def save(product)
    ar_product = Product.new(name: product.name, price: product.price)
    product.warehouses.each do |warehouse|
      ar_product.product_warehouses.build(
        warehouse_id: warehouse.warehouse_id
      )
    end
    ar_product.save!
  end
end

ApplicationServiceの実装

「管理者が商品を登録する」ユースケースなので、ApplicationServiceの名前はAdministratorRegisterProductとしましょう。実装はユーザーからの入力値を受け取って、Aggregateをインスタンス化してRepositoryを使って永続化するだけの単純なものです。

# 管理者が商品を登録するユースケース
class AdministratorRegisterProduct
  def perform(name, price, warehouse_ids)
    product = ProductCatalog::ProductManagement::Product.new(
                name, price, warehouse_ids
              )
    return false if !product.valid?

    repository = ProductCatalog::ProductManagement::ProductRepository.new

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

      result = true
    end

    result
  end
end

これで「管理者が商品を登録する」ユースケースの実装はおしまいです。

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

これだけならAggregateもRepositoryもいらないのでは?

はいおっしゃる通りです。Railsの場合、ActiveRecordパターンを実装したActiveRecordというORMがバリデーションやアソシエーションの機能を提供しているので、テーブル構造とDomainModelがほとんど同じなら、Rails Wayに則った実装をするのが良いでしょう。ActiveRecordパターンはDomainModelを実装するパターンの一つで、テーブル構造とDomainModelが近いなら有効なパターンなので、これは当然の話だったりします。

上記のような例なら、Aggregateを作らずにバリデーションはActiveRecordに持たせ、Repositoryの中でおこなっていることをApplicationServiceに移動してTransactionScriptパターンとして実装してしまうのもありでしょう。

しかし、これだけですまないのは開発経験の長い方なら理解できるかと思います。下書き保存と正式保存でバリデーションの内容を変えたり、コールバックによって保存直前に値を差し替えるようなことをしていると、どういう時にどういうデータが保存されるのかわけがわからないActiveRecordができあがります。それに対して、ユースケースごとにAggregateを作り、保存のシチュエーションごとにAggregateを分けると、各々のAggregateにシチュエーションごとのバリデーションやロジック(コールバック)が別れる形になり見通しがよくなります。

また、ActiveRecordパターンを実装していないORMを利用している場合、バリデーションやコールバックを書く場所が明確に用意されていない場合も考えられます。その場合、ApplicationServiceにTransactionScriptパターン的にバリデーションロジックなどを書くか、Aggregateを用意してAggregateの中で実行する他に選択肢はないでしょう。

ORMのバリデーションとAggregateのバリデーションの使い分け

Aggregateでバリデーションを行っているからといって、基本的にORMやDBの制約は緩める必要はないでしょう。可能なら厳しくしておくほうが良いと思います。しかし、下書き保存と正式保存のようなバリデーションがスイッチするケースにおいて、双方のシチュエーションをサポートしたバリデーションを記述するのが煩雑なケースがあります。そういう場合においては、ORMのバリデーションやDBの制約は最大公約数的な制限にしておくのが良いだろうと思います。

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