2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rubyでトランザクションスクリプトを書くのはやめよう

2
Last updated at Posted at 2026-02-18

1. トランザクションスクリプトとは何か

Martin Fowler の Patterns of Enterprise Application Architecture で定義されたパターンで、
1つのユースケース(トランザクション)を1つの手続きとして上から下へ書き下すスタイルです。

# これがトランザクションスクリプト
class OrderService
  def self.place_order(user_id, item_ids, coupon_code)
    user = User.find(user_id)
    items = Item.where(id: item_ids)

    # 在庫チェック
    items.each do |item|
      raise "在庫切れ: #{item.name}" if item.stock <= 0
    end

    # 合計金額計算
    total = items.sum(&:price)

    # クーポン適用
    if coupon_code.present?
      coupon = Coupon.find_by!(code: coupon_code)
      raise "期限切れ" if coupon.expired?
      total = total * (1 - coupon.discount_rate)
    end

    # 注文作成
    order = Order.create!(user: user, total: total)
    items.each do |item|
      order.order_items.create!(item: item)
      item.update!(stock: item.stock - 1)
    end

    # 通知
    UserMailer.order_confirmation(user, order).deliver_later

    order
  end
end

一見シンプルですが、これは 手続き型プログラミング です。


2. トランザクションスクリプトの何がダメなのか

2.1 ドメインモデルが貧血になる(Anemic Domain Model)

トランザクションスクリプトを書くと、モデルは getter/setter とバリデーションだけの「データの入れ物」になります。
ビジネスルールが分散し、モデルを見ただけではドメインの振る舞いが分かりません。

# 貧血モデルの例 - 何ができるオブジェクトなのか分からない
class Coupon < ApplicationRecord
  validates :code, presence: true
  validates :discount_rate, numericality: { in: 0..1 }
end

2.2 ロジックが重複する

「クーポンが有効かどうか」のチェックが、あちこちにコピペされます。

# OrderService にも...
raise "期限切れ" if coupon.expired_at < Time.current

# CouponValidationService にも...
return false if coupon.expired_at < Time.current

# Admin::CouponController にも...
if coupon.expired_at < Time.current
  # ...
end

モデルに expired? メソッドが1つあれば済む話です。

2.3 テストが書きにくい

トランザクションスクリプトは依存が多く、テストのセットアップが巨大になります。
「クーポンの期限切れ判定」をテストしたいだけなのに、ユーザー、商品、在庫、メーラーの全てをセットアップする必要が生じます。

2.4 変更に弱い

ビジネスルールの変更時に「どこのコードを直すべきか」が分かりません。
grep して全箇所を直す必要があり、修正漏れがバグになります。

2.5 オブジェクト指向の恩恵を捨てている

Ruby はオブジェクト指向言語です。トランザクションスクリプトは Ruby の強みである
カプセル化・ポリモーフィズム・継承を全て無視し、C言語の構造体+関数のように書くことです。


3. 「でも Service Object は便利では?」への回答

よくある誤解

「Fat Model を避けるために Service Object に切り出すのがベストプラクティス」

これは誤りです。Fat Model の解決策は Service Object ではなく PORO への分解 です。

Service Object がやっていることを分析すると、大半は次のどれかです:

Service がやっていること 本来の置き場所
1つのモデルのデータだけで完結するロジック そのモデル自身
バリデーション モデル or カスタムバリデータ
計算・変換 Value Object(PORO)
条件判定・認可 Policy Object(PORO)
複数モデルにまたがるドメイン概念 その概念を表すドメインモデル(PORO) を設計する
外部APIとの連携 Gateway / Adapter(PORO)

Service Object を作る前に、本当にそれがモデルやPOROに置けないか考えてください。


4. 正しいリファクタリングの実例

Before: トランザクションスクリプト

class ApplyCouponService
  def initialize(order, coupon_code)
    @order = order
    @coupon_code = coupon_code
  end

  def call
    coupon = Coupon.find_by!(code: @coupon_code)

    # クーポンの有効性チェック
    raise InvalidCouponError if coupon.expired_at < Time.current
    raise InvalidCouponError if coupon.usage_count >= coupon.usage_limit
    raise InvalidCouponError if coupon.minimum_amount > @order.total

    # 割引計算
    discount = if coupon.percentage?
                 @order.total * coupon.discount_rate
               else
                 coupon.discount_amount
               end
    discount = [discount, @order.total].min

    # 適用
    ActiveRecord::Base.transaction do
      @order.update!(discount: discount, coupon: coupon)
      coupon.increment!(:usage_count)
    end
  end
end

問題点:

  • クーポンの有効性判定ロジックが Service に漏れている
  • 割引額の計算ロジックが Service に漏れている
  • Coupon モデルを見ても何ができるか分からない

After: Rich Domain Model + PORO

# クーポンの有効性判定と割引計算はクーポン自身が知っている
class Coupon < ApplicationRecord
  def applicable?(order)
    !expired? && within_usage_limit? && meets_minimum_amount?(order)
  end

  def expired?
    expired_at < Time.current
  end

  def calculate_discount(order_total)
    raw_discount = percentage? ? order_total * discount_rate : discount_amount
    [raw_discount, order_total].min
  end

  def consume
    increment!(:usage_count)
  end

  private

  def within_usage_limit?
    usage_count < usage_limit
  end

  def meets_minimum_amount?(order)
    minimum_amount <= order.total
  end
end
# 注文へのクーポン適用は注文自身の責務
class Order < ApplicationRecord
  def apply_coupon(coupon_code)
    coupon = Coupon.find_by!(code: coupon_code)
    raise InvalidCouponError unless coupon.applicable?(self)

    transaction do
      update!(discount: coupon.calculate_discount(total), coupon: coupon)
      coupon.consume
    end
  end
end
# Controller はシンプルに
class OrdersController < ApplicationController
  def apply_coupon
    order = current_user.orders.find(params[:order_id])
    order.apply_coupon(params[:coupon_code])
    render json: order
  end
end

改善点:

  • Coupon を見れば「有効性判定」「割引計算」ができることが一目で分かる
  • Order を見れば「クーポン適用」ができることが一目で分かる
  • クーポンの有効性判定をテストするのに Order のセットアップが不要
  • Service クラスが不要になった

5. Fat Model を避けるための PORO 分解パターン

「モデルに書けと言うが、モデルが肥大化するのでは?」という懸念への回答です。

5.1 Value Object - 値の概念を表現する

# 在庫数をValue Objectに抽出
# app/models/item/quantity.rb
class Item::Quantity
  attr_reader :value

  def initialize(value)
    raise ArgumentError, "在庫数は0以上である必要があります" if value.negative?
    @value = value
  end

  def +(other)
    self.class.new(value + other.value)
  end

  def -(other)
    self.class.new(value - other.value) # 負になればArgumentErrorで不変条件を保護
  end

  def out_of_stock?
    value.zero?
  end

  # Value Object は等価比較できる
  def ==(other)
    value == other.value
  end
end
# Ruby 3.2+ なら Data.define でイミュータブルな Value Object を簡潔に定義できる
Item::Quantity = Data.define(:value) do
  def +(other)
    self.class.new(value + other.value)
  end

  def -(other)
    self.class.new(value - other.value)
  end

  def out_of_stock?
    value.zero?
  end
end
# Item モデルから Quantity に委譲する
class Item < ApplicationRecord
  composed_of :stock_quantity,
    class_name: "Item::Quantity",
    mapping: { stock: :value }

  def out_of_stock?
    stock_quantity.out_of_stock?
  end

  def deduct_stock(quantity)
    self.stock_quantity = stock_quantity - Item::Quantity.new(quantity)
    save! # 在庫数が負なら Quantity が ArgumentError を発生
  end
end

5.2 Policy Object - 条件判定を抽出する

# クーポン適用可否の判定ロジックが複雑になったら Policy Object に抽出する
# app/models/coupon/applicability_policy.rb
class Coupon::ApplicabilityPolicy
  def initialize(coupon, order)
    @coupon = coupon
    @order = order
  end

  def applicable?
    !expired? && within_usage_limit? && meets_minimum_amount? && eligible_items?
  end

  def rejection_reasons
    reasons = []
    reasons << "クーポンの有効期限が切れています" if expired?
    reasons << "クーポンの利用上限に達しています" unless within_usage_limit?
    reasons << "注文金額が最低利用金額に達していません" unless meets_minimum_amount?
    reasons << "対象商品が含まれていません" unless eligible_items?
    reasons
  end

  private

  def expired?
    @coupon.expired_at < Time.current
  end

  def within_usage_limit?
    @coupon.usage_count < @coupon.usage_limit
  end

  def meets_minimum_amount?
    @coupon.minimum_amount <= @order.total
  end

  def eligible_items?
    return true if @coupon.target_item_ids.blank?

    (@coupon.target_item_ids & @order.item_ids).present?
  end
end
# モデルから委譲する
class Coupon < ApplicationRecord
  def applicable?(order)
    applicability_policy(order).applicable?
  end

  def rejection_reasons(order)
    applicability_policy(order).rejection_reasons
  end

  private

  def applicability_policy(order)
    Coupon::ApplicabilityPolicy.new(self, order)
  end
end

5.3 複数モデルにまたがるドメイン概念

複数モデルの協調が必要なとき、まず考えるべきは「最も責務の近いモデルのメソッドにできないか」です。
たとえば注文確定は Order#checkout で十分です。

しかし、既存のどのモデルにも属さない独立したドメイン概念が存在する場合は、
それを表現するドメインモデル(PORO)を設計します。

ここでクラスの命名に注意が必要です。
クラス名が動詞的だと、トランザクションスクリプトに陥りやすくなります。

# ダメなパターン: クラス名が動詞的 → 自然と「手続きを実行するクラス」になる
class Order::ItemExchange
  def initialize(order:, original_item:, replacement_item:)
    # ...
  end

  def complete  # complete, call, execute, perform... 汎用的な名前は手続き的発想の証拠
    validate_return_window
    validate_stock
    ActiveRecord::Base.transaction do
      # 手続きの羅列...
    end
  end
end

クラス名を動詞(Exchange = 交換する)にすると、「交換を実行するクラス」という
手続き的な発想になり、completecall のような汎用的な単一エントリポイントに収束します。

クラス名は名詞にしてください。 名詞は「状態を持つもの」「振る舞いを持つもの」
という発想を誘い、複数のメソッドを持つ豊かなオブジェクトにつながります。

たとえば「返品申請」という名詞で設計すると:

# 良いパターン: クラス名が名詞 → 状態と複数の振る舞いを持つドメインモデルになる
# app/models/order/return_claim.rb
class Order::ReturnClaim
  include ActiveModel::Model
  include ActiveModel::Validations

  attr_reader :order, :item

  validate :within_return_window
  validate :item_in_order

  def initialize(order:, item:)
    @order = order
    @item = item
    super()
  end

  def exchange_for(replacement)
    return false unless valid?
    return false unless verify_stock(replacement)

    ActiveRecord::Base.transaction do
      item.return_to_stock
      replacement.deduct_stock
      order.record_exchange(item, replacement)
    end
    true
  end

  def refund
    return false unless valid?

    ActiveRecord::Base.transaction do
      item.return_to_stock
      order.record_refund(item)
    end
    true
  end

  private

  def within_return_window
    errors.add(:base, "返品可能期間を過ぎています") unless order.within_return_window?
  end

  def item_in_order
    errors.add(:item, "この注文に含まれていません") unless order.includes_item?(item)
  end

  def verify_stock(replacement)
    return true unless replacement.out_of_stock?

    errors.add(:base, "交換先の商品の在庫がありません")
    false
  end
end
# Controller は ReturnClaim に命じるだけ。判断はオブジェクトが行う
class OrdersController < ApplicationController
  def exchange
    claim = Order::ReturnClaim.new(
      order: current_user.orders.find(params[:id]),
      item: Item.find(params[:item_id])
    )

    if claim.exchange_for(Item.find(params[:replacement_item_id]))
      render json: claim.order
    else
      render json: { errors: claim.errors.full_messages }, status: :unprocessable_entity
    end
  end
end

なぜこれはトランザクションスクリプトではないのか:

  • Order::ReturnClaim(返品申請)は名詞であり、状態と複数の振る舞いを持つドメインモデル
  • exchange_forrefund という異なる振る舞いがある(単一エントリポイントではない)
  • 返品可否の判断はオブジェクト自身が行う(Tell, Don't Ask)
  • Controller は claim.exchange_for(replacement)命じるだけ
  • Order でも Item でもない、交差する概念に対してのみ別クラスを作っている

6. 判断フローチャート

新しいロジックを書く場所に迷ったら、以下の順で考えてください。

1. そのロジックは1つのモデルのデータだけで完結するか?
   → YES: そのモデルにメソッドを追加する
   → NO: 次へ

2. そのロジックは「値」や「計算」に関するものか?
   → YES: Value Object (PORO) を作る
   → NO: 次へ

3. そのロジックは「条件判定」「認可」に関するものか?
   → YES: Policy Object (PORO) を作る
   → NO: 次へ

4. 複数モデルにまたがるドメイン概念か?
   → YES: その概念を表すドメインモデル(PORO)を設計する
         命じれば自ら判断・行動できるオブジェクトにすること
   → NO: 次へ

5. 外部APIとの連携か?
   → YES: Gateway / Adapter (PORO) を作る
   → NO: 既存のモデルに追加できないか再検討する

7. Service Object を使ってよい場合

以下のような場合は、例外的に Service Object が許容されます。

  • インフラ層の処理: メール送信のオーケストレーション、外部APIバッチ呼び出しなど、ドメインロジックではない技術的関心事

ただし、以下をチェックしてください:

  • Service の中にビジネスルール(条件分岐、計算)が書かれていないか?
  • そのロジックはモデルや PORO に移せないか?
  • Service は単にモデルのメソッドを呼び出しているだけの「中間層」になっていないか?

8. コードレビューで指摘すべきサイン

以下のサインが見えたら、ドメインモデルにロジックを移すべきです:

  1. 新しい Service クラスが追加されている - そのロジックはモデルか PORO に置けないか検討する
  2. モデルの属性を外部から参照して条件分岐している - その判定はモデル自身のメソッドにすべき
  3. 同じ条件判定が複数箇所に現れる - モデルのメソッドとして一箇所に集約すべき
  4. モデルに validatesbelongs_to しかない - 貧血モデルの兆候。ビジネスロジックが別の場所に漏れている
  5. テストで大量の fixture/factory が必要 - ロジックの置き場所が間違っている。モデルに移せば単体でテストできる

9. まとめ

やめてほしいこと 代わりにやってほしいこと
ビジネスロジックを手続き的なクラスに羅列する モデル自身にビジネスロジックを持たせる
モデルの外にロジックを集める PORO (Value Object, Policy Object) に分解してモデルから委譲する
モデルをただのデータ入れ物にする Rich Domain Model を育てる

「このロジックはどのオブジェクトが知っているべきか?」

この問いを常に自分に投げかけてください。
答えは大抵、Service Object ではなく、ドメインモデルか、ドメインの概念を表す PORO です。


参考文献

  • Martin Fowler, Patterns of Enterprise Application Architecture - "Transaction Script" と "Domain Model" の定義
  • Martin Fowler, AnemicDomainModel - 貧血ドメインモデルのアンチパターン
  • Sandi Metz, Practical Object-Oriented Design in Ruby - Ruby における OOD の実践
2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?