モノリシックなRubyアプリケーションの段階的なモジュール化──全面書き直さない改善戦略

「全部書き直したい」という誘惑と、現実的な判断

Rubyで数年以上運用されているアプリケーションが、機能追加のたびに複雑さを増していく。コントローラーが肥大化し、モデルのメソッドが増え続け、ビジネスロジックがビューに漏れ出す。こういった状況で、エンジニアの心には「一度全部書き直したい」という気持ちが生まれます。気持ちはよくわかります。

ですが現場では、そう簡単に全面書き直しの決断はできません。既存システムは日々の運用を支えており、顧客や利用者がいます。書き直している間も新機能要望は来ますし、バグ報告も止まりません。書き直しが完了するまで、既存コードベースの保守を続けなければならないというジレンマが生じます。

ここで重要なのは、段階的なモジュール化という選択肢です。全体を一度に変えるのではなく、責務の明確な単位を少しずつ抽出し、既存のモノリシック構造の中に埋め込んでいく。この方法なら、運用を止めずに改善を進められます。

モノリシック構造が問題になる本当の理由

モノリシックなRubyアプリケーションの問題は、単に「ファイルが大きい」ことではありません。むしろ、責務の境界が曖昧になることです。

一つのモデルクラスに、データベース操作、ビジネスロジック、外部API呼び出し、メール送信、ログ出力が混在する。一つのコントローラーアクションで、パラメータ検証、複数のモデルの操作、キャッシュ制御、レスポンス形式の判定が行われる。こうなると:

  • テストが書きにくくなります(外部依存が多く、セットアップが重い)
  • 新しい人が機能を追加する際の判断基準が曖昧になります
  • リファクタリングの影響範囲を予測しにくくなります
  • 本番環境で問題が起きた時の原因特定に時間がかかります

現場では、こうした複雑さが「あの機能を追加するのに3日かかった」「修正が別の箇所に波及した」といった運用の重さになって表れます。

段階的モジュール化の実装パターン

1. Service Object の導出

最初の一歩は、ビジネスロジックをService Objectとして抽出することです。

既存のコントローラーアクションを見て、「複数のモデルの操作」や「複雑な条件判定」が含まれている部分を特定します。それを専用のクラスに移動させるだけです。

# 改善前:コントローラー内で複雑な処理
def create
  @order = Order.new(order_params)
  if @order.save
    # 在庫確認
    if @order.items.sum(&:quantity) > Stock.available_count
      @order.destroy
      render :error
      return
    end
    # 決済処理
    payment = PaymentGateway.charge(@order.total_price)
    @order.update(payment_id: payment.id)
    # 通知送信
    OrderMailer.confirmation(@order).deliver_later
    render :success
  else
    render :error
  end
end
# 改善後:Service Objectに責務を移譲
class OrderCreationService
  def initialize(order_params)
    @order_params = order_params
  end

  def call
    order = Order.new(@order_params)
    return failure(order) unless order.save

    return failure(order) unless check_stock(order)
    return failure(order) unless process_payment(order)

    notify_customer(order)
    success(order)
  end

  private

  def check_stock(order)
    order.items.sum(&:quantity) <= Stock.available_count
  end

  def process_payment(order)
    payment = PaymentGateway.charge(order.total_price)
    order.update(payment_id: payment.id)
  rescue => e
    false
  end

  def notify_customer(order)
    OrderMailer.confirmation(order).deliver_later
  end

  def success(order)
    { success: true, order: order }
  end

  def failure(order)
    { success: false, errors: order.errors }
  end
end

# コントローラーは簡潔に
def create
  result = OrderCreationService.new(order_params).call
  if result[:success]
    render :success, locals: { order: result[:order] }
  else
    render :error, locals: { errors: result[:errors] }
  end
end

このパターンの利点は:

  • テストが書きやすい。外部依存をスタブできます
  • ロジックの流れが明確です
  • 複数のコントローラーから同じロジックを呼び出せます
  • 既存のモデルやコントローラーを大きく変更しません

2. Query Object による検索ロジックの整理

複雑な検索条件がモデルクラスに散乱している場合、Query Objectで整理します。

# 改善前:モデルにスコープが増え続ける
class Order < ApplicationRecord
  scope :recent, -> { where('created_at > ?', 7.days.ago) }
  scope :high_value, -> { where('total_price > ?', 100000) }
  scope :pending_payment, -> { where(payment_status: 'pending') }
  scope :by_customer, ->(id) { where(customer_id: id) }
  # さらに複合条件のスコープが増える...
end

# 改善後:Query Objectで条件を組み立てる
class OrderQuery
  def initialize(relation = Order.all)
    @relation = relation
  end

  def recent
    @relation = @relation.where('created_at > ?', 7.days.ago)
    self
  end

  def high_value
    @relation = @relation.where('total_price > ?', 100000)
    self
  end

  def pending_payment
    @relation = @relation.where(payment_status: 'pending')
    self
  end

  def by_customer(id)
    @relation = @relation.where(customer_id: id)
    self
  end

  def results
    @relation
  end
end

# 使用例
orders = OrderQuery.new
  .recent
  .high_value
  .pending_payment
  .by_customer(123)
  .results

この方法は、スコープの複合利用が複雑になっている場合に特に有効です。テストも単純になります。

段階的導入で気をつけるべきポイント

新しい機能から始める

既存機能を無理に変更するのではなく、新しい機能追加時にService ObjectやQuery Objectを使うという戦略が現実的です。既存機能はそのまま動かしておき、新機能は新しいパターンで実装する。これなら既存の動作を壊す心配がありません。

段階的に既存機能を移行する

優先度の低い機能、あるいはテストカバレッジが低い機能から始めるのが安全です。リスク度合いが低い部分で新しいパターンに慣れ、チーム全体が理解してから、重要な機能に適用します。

テストを先に書く

既存機能をリファクタリングする場合、必ず事前にテストを追加します。特に統合テストで既存の動作を保証してから、内部構造を変えます。テストなしでリファクタリングすると、予期しない動作変化を本番環境で発見することになります。

よくある失敗パターン

「Service Objectを作ったが、結局複雑なままになった」

これは、Service Objectの粒度が大きすぎる場合に起こります。一つのServiceが複数の独立した責務を持つと、結局複雑さは減りません。目安として、一つのServiceは「ある業務プロセスの一つの工程」程度に留めるべきです。

「既存コードとService Objectが共存して、どちらを使うか曖昧になった」

新しいパターンを導入する際は、ガイドラインを明文化することが重要です。「新しい機能はService Objectを使う」「既存機能の改修時は段階的に移行する」といった決め事を、チーム内で共有しておきます。

「テストが増えたが、実行時間が長くなった」

Service Objectのテストは高速ですが、それでもテストスイート全体の実行時間が伸びます。並列実行やテストの最適化を並行して進めることをお勧めします。

実装のステップ

  1. 現状の把握:コントローラーやモデルで複雑な処理が集中している箇所を特定する
  2. テストの追加:その箇所の既存の動作を統合テストで保証する
  3. Service Objectの抽出:ビジネスロジックを新しいクラスに移す
  4. 既存コードの修正:コントローラーやモデルから、新しいServiceを呼び出すように変更する
  5. テストの追加・修正:Service Objectのユニットテストと、既存の統合テストが両方パスすることを確認する
  6. 繰り返す:次の複雑な箇所に同じプロセスを適用する

このプロセスは、一度に全部を完成させる必要はありません。月に1、2個のService Objectを抽出するペースで、1年かけて全体を改善していく。そのくらいの気持ちで進めるのが現実的です。

まとめ

モノリシックなRubyアプリケーションの改善は、全面書き直しだけが選択肢ではありません。Service ObjectやQuery Objectといった比較的シンプルなパターンを使い、段階的に責務の境界を明確にしていく。この方法なら、運用を止めずに、チームの学習コストも抑えながら、アーキテクチャを改善できます。

完璧を目指さず、「今より少し良い構造」を地道に積み重ねる。そういう現実的なアプローチが、長く運用されるシステムでは最も効果的です。