ユースケース(ユーザーストーリー)、スイムレーン - 要求分析駆動設計
DDDではApplicationService相当のもののため、具体的なドメインロジックは直接記述せずトランザクションの管理などを行う。Controllerからコードを独立させることにより、テストが容易になり、バッチ実行など画面から切り離した呼び出しが可能になる。 Interactor gemのように1クラスに1処理ではなく、1つのUseCaseクラスに複数のpublicメソッドを持たせる。 これは、ユースケースは複数の処理の塊であり、1クラス1処理としてしまったらその関係が見えなくなってしまうという考えからこうしている。
ECサイトでのユースケースの例
項目 | 例 |
---|---|
ユースケース名 | 商品注文 |
アクター | ユーザー |
説明 | ユーザーは検索条件を指定して商品を検索し、欲しい商品を見つけたらカートに追加する。カートの中身と請求金額を確認した後、注文の確定を行う。 |
ECサイトでのスイムレーンの例
コードとの対応
ファイルパスは以下の通りにする。
アクターごとのレイアウトを使う場合
app
└── use_cases
└── commerce_subject
└── user_actor
├── cancel_order_use_case.rb
└── order_goods_use_case.rb
スイムレーンごとのレイアウトを使う場合
app
└── use_cases
└── commerce_subject
└── order_goods_swim_lane
├── admin_actor.rb
├── delivery_system_actor.rb
└── user_actor.rb
アクターごとのレイアウトを使うか、スイムレーンごとのレイアウトを使うかは一連のユースケースに複数のアクターが関わるかどうかが一つの指標になる。
関連するユースケースが複数のアクターに跨ってしまい、全容が掴みにくくなることを回避したいならスイムレーンごとのレイアウトを使えば良い。
注意点としては、ユースケース1つ1つにレイアウトの選択肢があるのであって、全体としてアクターごとのレイアウトだけかスイムレーンごとのレイアウトだけを使うということではない。上記の例なら商品注文のユースケースはスイムレーンごとのレイアウトで注文のキャンセルはアクターごとのレイアウトといった具合である。
メソッドは、ユースケースの説明の中の以下が候補となる。
- ユーザーは検索条件を指定して商品を検索
- 欲しい商品を見つけたらカートに追加する
- カートの中身と請求金額を確認
- 注文の確定を行う
アクターごとのレイアウトを使うならuser_actor/order_goods_use_case.rb
に、スイムレーンごとのレイアウトを使うならorder_goods_swim_lane/user_actor.rb
に上記のメソッドを定義する。
ユーザーストーリーを使っている場合はイテレーションに収まる大きさという制約があるため、ユーザーストーリー1つ1つがメソッドの候補となると思われる。エピックがsubject
かuse_case
、swim_lane
に対応することになるが、余り物の名前をどうするか考えなければならない。適切な抽象度の名前を考えるか、システムが小さい場合は単に省略してしまうという選択肢もある。
あるいは、ストーリーマッピングを用いてストーリーを整理し、エピックをsubject
、テーマ(ステップ)をuse_case
、ストーリーをメソッドに割り当てる方法もある。
マイクロサービスでサーガパターンを実装をする場合、単純ならアクターごとのレイアウトを使いトランザクションを開始したUseCaseに全てまとめ、複雑ならスイムレーンごとのレイアウトを使い各サービスごとに*_service_actor.rb
を用意するのが良いだろう。1つのユースケースを達成するのに複数のサービスが連携するなら、サーガパターンに限らず同様の考え方でよいはずである。
コード例
module CommerceSubject
module UserActor
class OrderGoodsUseCase
attr_accessor :actor
# 実行するアクターのインスタンスをコンストラクタで受け取る
def initialize(actor)
self.actor = actor
end
# 「欲しい商品を見つけたらカートに追加する」に対応するメソッド
# Cart#to_order_featureやItem#to_order_featureについては後述するが、
# ActiveRecord(PofEAA)のインスタンスをEntityのインスタンスにマッピングするメソッドである
# Cart#to_order_featureはOrderFeature::Cartのインスタンスを返すように実装する
# OrderFeature::Cart#rawはCartのインスタンスを返す
# つまり actor.cart.to_order_feature.raw == actor.cart になる
# またバリデーションについてはEntityの中で
# ActiveRecord(Rails)のバリデーション機能を利用するズルをしているので、
# 他のフレームワークでは読み替えが必要である
def add_goods_to_cart(goods_id)
result = false
cart = actor.cart.to_order_feature
ApplicationRecord.transaction do
goods = Item.find(goods_id).to_order_feature
cart.add_goods(goods)
return { result: result, data: cart.raw } unless cart.valid?
cart.raw.save!
cart.raw.items.map(&:save!)
result = true
end
{ result: result, data: cart.raw }
rescue StandardError
{ result: false, data: cart.raw }
end
end
end
end
Gateway(Clean Architecture)を挟まずに、ApplicationService相当であるUseCaseがActiveRecord(Rails)に依存してしまっているのは、Gateway(Clean Architecture)があってもなくても修正コストが変わらないと予想されるからである。
RailsにおいてActiveRecord(Rails)を使わなくなることはまずないし、NoSQLに載せ替えるならどちらも同程度の修正コストが発生するはずである。 Gateway(Clean Architecture)を用意すれば修正箇所を集中させることはできるが、そのために全ての場所でGateway(Clean Architecture)を用意するのはYAGNI原則に照らして妥当かどうか考えた方が良い。
他の言語やフレームワークにおいては、ORMが変更になるリスク、修正することになった場合の修正コストなどを考えてGateway(Clean Architecture)を作るべきか判断して欲しい。
依存関係
UseCaseはContollerやバッチなどから依存される。ViewやHelperからUseCaseに依存するのは許されない。UseCaseからはActiveRecord(Rails)やActionMailer(Rails)、DomainのEntityやServiceに依存する。Controller(Action)-UseCase-Service(Entity)の関係は縦割りではない。あるUseCaseは複数のControllerから依存されうるし、ServiceやEntityも複数のUseCaseから依存されうる。
UseCaseのメソッドはトランザクションの単位のため、単一のActionから複数の更新系UseCaseメソッドを呼び出したい場合は、1つのUseCaseメソッドにまとめあげた方が良い。ただし、UseCaseから他のUseCaseを呼び出すのは依存関係が複雑になり、各UseCaseの独立性を下げるため推奨しない。回避策としてDomainのServiceに処理をくくり出して、各々のUseCaseからそのServiceを利用するようにさせる方法がとれるが、それでも幾らかの処理は重複することになるだろう。コードの重複回避をとるか、依存関係の複雑化の回避をとるかのトレードオフを選ぶ必要がある。
個人的には、各々のユースケースは独立した存在であり、包含関係にあるユースケースがあったとしても背後にある機能要求が一致しているだけで、ユースケースが包含関係にあるのは偶然の一致であると考えている。重複しているように見える部分は偶然の一致であり、実際には重複ではないという考え方である。
参照系UseCaseメソッドの場合は、単一のActionから複数のメソッドを呼び出すことは問題はない。カスタマイズ可能なダッシュボード画面などを想像すれば違和感はないはずである。
ただし、不必要にメソッドが分かれている場合もあるので注意しなければならない。「カートの中身と請求金額を確認」を「カートの中身の確認」と「請求金額の確認」に分ける必要はないかも知れない。
ユースケースをモデリングするときしないとき、UseCaseを作るとき作らないとき
こうすればいいという明確な基準はない。「ユースケースのモデリングをしないとまずいな」「ユースケースのモデリングをするほどではないな」という要求分析の嗅覚を鍛えるしかない。
役に立たない機械的な指標を上げておく。
シンプルなCRUD機能のような単純な機能の場合、ユースケースのモデリングもUseCaseの作成も不要だろう。このようなときは、Controllerから直接EntityやServiceに依存させてしまえばよい(EntityやServiceすら不要かも知れない)。
複数の場所から同じトランザクション処理が実行される場合や、複数のトランザクション処理が一つのユースケース内の逐次的処理の構成要素とみなせる場合などはUseCaseを作るべきだろう。
ドキュメンテーション
昨今のプロジェクトはJIRAなどでユーザーストーリーの記述を行っていると思う。ユースケースについてもそれに則る形で記述すれば良い。 追加変更が繰り返された結果、ユースケースの全体像が把握しにくくなった場合は、そのタイミングでJIRAのやりとりをもとにWikiなどに現在の姿をまとめれば良い。
機能を追加するタイミングではどういうユースケース(機能)なのか整理する目的でまとめる。 のちのち、機能変更のタイミングでどういう内容のユースケース(機能)なのか確認するためにそのチケットが参照されるため、後から見返しても内容が理解できるように記載しなければならない。 特に意思決定が行われた場合、意思決定の背景をチケットのコメントに残さなければならない。 そういう情報が残っていないと、覆していい意思決定なのか判断できなかったり、決定の責任者がわからないから誰に相談すればいいのかもわからないといった事態に陥る。
内容についてはユースケースのテンプレートをフルセット揃える必要はない。必要な情報が揃っていれば十分である。
課題
UseCaseのメソッドの戻り値はどういう形で返すのが良いかは詰め切れていない。トランザクションを伴うUseCaseのメソッドはトランザクションの成否を返さなければならないのは明らかだが、参照と計算のみのUseCaseのメソッドでは結果だけ返せば良いケースが大半である。失敗の表現も例外が適切かもしれない。
プロジェクトの設計方針にも影響されるだろうし、とりあえずこの文書ではトランザクションを伴うUseCaseのメソッドは{ result: ..., data: ... }
という形のHash、参照と計算のみのUseCaseのメソッドはKey名に制限のないHashの形にした。