管理者が商品を登録する | ドメイン駆動設計ハンズオン
Prev: ユースケースの洗い出し
フィーチャーツリーとユースケース
フィーチャーツリーは以下の通りでした。
- 決済機能
- ユーザー情報変更
- 商品カタログ
- 商品の検索
- 商品管理
- 注文
- ショッピングカート
- 注文受付機能
- 配送状態確認
ユースケースは以下の通りです。
- 管理者が商品を登録する
- ユーザーが商品を検索する
- ユーザーがショッピングカートに商品を追加する
- ユーザーがショッピングカートの中身を確認する
この章では「管理者が商品を登録する」ユースケースで、「商品カタログ」グループの「商品管理」機能を実装していきます。
データモデリングの実施
商品を登録するので、登録する商品の情報のデータモデルを分析する必要があります。当然、どういう情報が必要なのかはビジネスサイドかプロダクトオーナーにしか判断ができないので、ビジネスサイドかプロダクトオーナーが中心となってデータモデリングを行う必要があります。モデリングに不慣れならエンジニアがサポートすることは当然必要になりますし、ドメイン駆動設計を行っているのですから、実装に反映できるドメインモデルを構築できるようにエンジニアも主体となってモデリングに参加する必要があります(実践モデラです)。
プロダクトオーナーと話し合って以下の情報を登録する必要があることがわかったとします(ハンズオンなので最低限のものにしています)。
- 商品名
- 価格(税別)
- 保管している倉庫(複数)
ここで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
Warehouse
はProduct
が複数持つことになるので、その点について実装が必要になります。選択肢は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: ユーザーが商品を検索する
はいおっしゃる通りです。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の中で実行する他に選択肢はないでしょう。
Aggregateでバリデーションを行っているからといって、基本的にORMやDBの制約は緩める必要はないでしょう。可能なら厳しくしておくほうが良いと思います。しかし、下書き保存と正式保存のようなバリデーションがスイッチするケースにおいて、双方のシチュエーションをサポートしたバリデーションを記述するのが煩雑なケースがあります。そういう場合においては、ORMのバリデーションやDBの制約は最大公約数的な制限にしておくのが良いだろうと思います。
Next: ユーザーが商品を検索する