Rails + AASMで業務ロジックの配置を考える
Railsで業務システムを作っていると、業務ロジックをModelに置くか、UseCaseに分けるかを判断する場面があります。
たとえば、売買取引を完了させる処理を考えてみます。
取引の状態をfulfillingからcompletedへ変更し、商品の引き渡し記録、支払いの状態、取引完了の記録を更新します。必要であればメールも送ります。
このとき、どこまでをModelに置くべきでしょうか。
trade.complete!
と呼ぶだけで、業務処理まで完了させるのか。
それとも、
CompleteTrade.call(trade_id)
というUseCaseを用意し、transactionや関連レコードの更新をそこへ集めるのか、という選択です。
この問題は、AASMを使うかどうかだけでは決まりません。AASMはRubyクラスやActiveRecord Modelに状態機械を追加する部品であり、アーキテクチャそのものではありません。AASM公式README
判断する対象は、業務動詞、状態遷移、transaction、外部副作用をどこへ置くかです。
まず、実装より先に業務を表現する
売買取引を例にすると、業務上の要素は次のように整理できます。
| 要素 | 内容 |
|---|---|
| 管理単位 | 売買取引 |
| 状態 | listed、reserved、paid、fulfilling、completed |
| 業務動詞 | 出品する、購入する、支払う、履行を開始する、完了する |
| 不変条件 | 完了済みの取引は取り消せない |
| 関連更新 | 支払い、引き渡し、完了記録 |
| 外部副作用 | メール、外部決済、検索インデックス更新 |
flowchart LR
A[取引] --> B[業務動詞]
A --> C[状態]
B --> D[関連レコードの更新]
B --> E[メール・外部副作用]
設計の違いは、これらの業務上の要素をどこに配置するかで生まれます。
1. Rails MVC + Rich ActiveRecord Model
最初の方法は、管理単位であるActiveRecord Modelに状態と業務動詞を集める方法です。
class Trade < ApplicationRecord
include AASM
aasm column: :status do
state :listed, initial: true
state :reserved
state :paid
state :fulfilling
state :completed
event :reserve do
transitions from: :listed, to: :reserved,
guard: :purchasable?
end
event :complete, after: :record_completion do
transitions from: :fulfilling, to: :completed
end
end
def record_completion
fulfillment_record.complete!
completion_record.create!
end
end
Controllerからは、業務動詞を呼び出します。
def complete
@trade.complete!
TradeCompletedMailer.notify(@trade).deliver_later
redirect_to @trade
end
この構成では、Modelが次の情報を持ちます。
- 取引にどのような状態があるか
- どの状態からどこへ遷移できるか
- 取引を完了するという業務動詞
- 完了時に必ず更新すべき関連レコード
AASMのActiveRecord連携では、状態遷移と保存をtransactionとして扱えます。遷移callbackや状態更新に失敗した場合、関連するDB変更をrollbackできます。AASM公式のTransaction Support
複数テーブルを更新していても、それだけでModelに置けないとは限りません。
取引という一つの管理単位を完了させるために複数の関連レコードを一貫した状態へ変更するなら、取引Modelに属する業務ルールとして扱えます。
この方法の良さ
- 業務管理単位と業務知識が同じ場所に集まる
- 状態と業務動詞を一緒に読める
trade.complete!という自然な表現になる- 関連レコードを含む一連の変更をまとめて扱える
- Controllerが薄くなる
- 業務処理を追うために多くのファイルを開かなくてよい
この方法の弱点
- Modelが大きくなりやすい
- AASM callbackに処理を追加するルールが必要になる
- メールや外部APIまでModelに入れると責務が広がる
- Modelの外側で行われる副作用は追いにくい
- Web以外の入口から呼び出す場合、メールなどの副作用をどう扱うか考える必要がある
この構成は、業務知識を管理単位へ集めることを重視します。
2. Rails MVC + DDD寄りのDomain Model / UseCase
別の方法は、業務モデルと処理全体を表すUseCaseを分ける方法です。RailsのControllerとViewを残したまま、業務処理の一部をDomain ModelやUseCaseへ切り出します。
flowchart LR
C[Controller] --> U[CompleteTrade UseCase]
U --> R[Repository / ActiveRecord]
R --> D[Domain Model]
U --> T[DB transaction]
U --> O[メール・Outbox・Job]
Domain Modelは、取引という業務管理単位を表します。
class Trade
include AASM
attr_reader :id
attr_accessor :status
def initialize(id:, status:)
@id = id
@status = status.to_sym
end
aasm column: :status do
state :fulfilling
state :completed
event :complete do
transitions from: :fulfilling, to: :completed
end
end
end
UseCaseは、Domain Modelを取得し、業務動詞を実行して永続化します。次のコードは、Repositoryやtransactionの実装を簡略化した例です。
class CompleteTrade
def call(trade_id)
TradeRecord.transaction do
record = trade_repository.find(trade_id)
trade = record.to_domain
trade.complete
trade_repository.save(trade)
outbox_repository.append!(
event_type: "trade_completed",
trade_id: trade.id
)
end
end
end
この構成では、Domain Modelが状態と不変条件を担当し、UseCaseが永続化を含む処理全体を担当します。
- transactionの範囲
- Repositoryからの読み込み
- Domain Modelの操作
- 複数の業務オブジェクトの調整
- OutboxやJobへの記録
- 外部処理へ渡すイベントの作成
UseCaseが薄いこと自体は、必ずしも問題ではありません。transactionの境界やアプリケーション処理の入口を明示できるなら、薄いUseCaseにも役割があります。
一方、UseCaseがModelのメソッドを一つ呼ぶだけで、transactionや外部副作用も持たないなら、Model中心の方が単純な場合もあります。
この方法の良さ
- transactionの境界が明示される
- 複数の業務オブジェクトを扱う処理を表現しやすい
- メールや外部APIなどの副作用を分離しやすい
- Controller以外の入口からも呼び出しやすい
- 処理全体の手順をUseCaseで読める
この方法の弱点
- Model、UseCase、Repositoryなど複数のファイルを読む必要がある
- 業務動詞の実装場所が分かりにくくなる
- UseCaseが薄くなりやすい
- 「これはModelかUseCaseか」という配置ルールが必要になる
- 同じ業務処理を追うための認知負荷が増える
Rails MVCの中でDDD寄りに分ける場合も、RailsやActiveRecordを完全に排除する必要はありません。Rails ModelをAggregateとして使いながら、UseCaseやDomain Modelを部分的に切り出せます。
3. ModelとUseCaseを両方使う中間形
実際には、両方を使う設計もあります。
例えば、次のようなルールを決める。
| 処理 | 配置 |
|---|---|
| 状態の定義 | Model + AASM |
| 状態遷移の条件 | Model + AASM |
| Aggregate内部の関連更新 | Model |
| 複数の管理単位の調整 | UseCase |
| transactionの境界 | UseCase |
| メールや外部API | UseCaseまたはJob |
この方法は両方の利点を得られる可能性がありますが、配置ルールが増えます。
実装者は毎回、次のどれに該当するかを判断する必要があります。
- これは状態遷移か
- これは状態遷移に伴う業務ルールか
- Aggregate内部の更新か
- 複数の管理単位の調整か
- transactionの境界か
- 外部副作用か
flowchart TD
A[処理を書く] --> B{何の処理か}
B -->|状態遷移| C[Model + AASM]
B -->|Aggregate内部の更新| D[Model]
B -->|複数単位の調整| E[UseCase]
B -->|メール・外部API| F[UseCase / Job]
このルールをチーム全員が理解していれば、処理を分けて配置できます。一方で、ルールを覚える必要があるため、単純なModel中心の設計より認知負荷は高くなります。
中間形は、両方の利点を無条件に得られる正解ではありません。
Modelへの凝集性と、UseCaseによる境界の明示性を得る代わりに、配置ルールと認知負荷を受け入れる設計
と考えた方がよい。
何を重視するかで選択は変わる
| 重視するもの | 向いている構成 |
|---|---|
| 業務知識を管理単位へ集めたい | Rich ActiveRecord Model |
| 状態と業務動詞を同じ場所で読みたい | Rich ActiveRecord Model |
| ファイルをまたいで処理を追いたくない | Rich ActiveRecord Model |
| transactionの範囲を明示したい | UseCase中心 |
| 外部副作用を管理したい | UseCase中心 |
| 複数の入口から同じ処理を呼びたい | UseCase中心 |
| Modelと処理手順を細かく分けたい | Model + UseCase |
| 配置ルールの増加を受け入れられる | Model + UseCase |
ModelかUseCaseかを先に決めるのではなく、次の順番で整理します。
- 何が業務管理単位か
- どの業務動詞がその単位に属するか
- どの状態と不変条件を一緒に守るか
- どのDB変更を一つの原子性単位にするか
- どこから先が外部副作用か
この順番で考えれば、Rails ModelにAASMを置く設計も、Domain ModelやUseCaseへ分ける設計も、同じ業務原型から説明できます。
まとめ
Rails MVCは、業務管理単位と業務動詞をModelへ近づけることで、凝集性と局所性を得やすくなります。AASMは、その単位の状態と許可された業務動詞を表す道具です。
DDD寄りに分ける場合は、業務モデルと永続化・処理調整を分けます。どちらもRails MVCの中で実装でき、違いは業務ロジックと永続化・処理順序をどこまで分けるかにあります。
両方を使う設計は、両方の利点を得られる一方で、どこに何を書くかというルールが増えます。
設計を選ぶときは、何を重視するかを決めます。
業務知識の凝集性を重視するならModel中心、transactionや処理境界の明示性を重視するならUseCase中心が候補です。両方を重視する場合は、配置ルールと認知負荷を受け入れて両方を使います。
どの設計が正しいかを先に決める必要はありません。
何を一か所に集めたいのか。
どの境界を明示したいのか。
そのために、どれだけのルールを覚えることを受け入れるのか。
そこを決めた結果として、Model中心、UseCase中心、あるいは両方を使う設計が選ばれます。