Tanaka Soft

業務ロジックはModelに置くべきか、UseCaseに分けるべきか

Railsの業務管理単位・業務動詞・状態変化を起点に、Rich ActiveRecord ModelとUseCase中心の設計を比較します。

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
メールや外部APIUseCaseまたは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かを先に決めるのではなく、次の順番で整理します。

  1. 何が業務管理単位か
  2. どの業務動詞がその単位に属するか
  3. どの状態と不変条件を一緒に守るか
  4. どのDB変更を一つの原子性単位にするか
  5. どこから先が外部副作用か

この順番で考えれば、Rails ModelにAASMを置く設計も、Domain ModelやUseCaseへ分ける設計も、同じ業務原型から説明できます。

まとめ

Rails MVCは、業務管理単位と業務動詞をModelへ近づけることで、凝集性と局所性を得やすくなります。AASMは、その単位の状態と許可された業務動詞を表す道具です。

DDD寄りに分ける場合は、業務モデルと永続化・処理調整を分けます。どちらもRails MVCの中で実装でき、違いは業務ロジックと永続化・処理順序をどこまで分けるかにあります。

両方を使う設計は、両方の利点を得られる一方で、どこに何を書くかというルールが増えます。

設計を選ぶときは、何を重視するかを決めます。

業務知識の凝集性を重視するならModel中心、transactionや処理境界の明示性を重視するならUseCase中心が候補です。両方を重視する場合は、配置ルールと認知負荷を受け入れて両方を使います。

どの設計が正しいかを先に決める必要はありません。

何を一か所に集めたいのか。
どの境界を明示したいのか。
そのために、どれだけのルールを覚えることを受け入れるのか。

そこを決めた結果として、Model中心、UseCase中心、あるいは両方を使う設計が選ばれます。

参考資料