モノリシックな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のテストは高速ですが、それでもテストスイート全体の実行時間が伸びます。並列実行やテストの最適化を並行して進めることをお勧めします。
実装のステップ
- 現状の把握:コントローラーやモデルで複雑な処理が集中している箇所を特定する
- テストの追加:その箇所の既存の動作を統合テストで保証する
- Service Objectの抽出:ビジネスロジックを新しいクラスに移す
- 既存コードの修正:コントローラーやモデルから、新しいServiceを呼び出すように変更する
- テストの追加・修正:Service Objectのユニットテストと、既存の統合テストが両方パスすることを確認する
- 繰り返す:次の複雑な箇所に同じプロセスを適用する
このプロセスは、一度に全部を完成させる必要はありません。月に1、2個のService Objectを抽出するペースで、1年かけて全体を改善していく。そのくらいの気持ちで進めるのが現実的です。
まとめ
モノリシックなRubyアプリケーションの改善は、全面書き直しだけが選択肢ではありません。Service ObjectやQuery Objectといった比較的シンプルなパターンを使い、段階的に責務の境界を明確にしていく。この方法なら、運用を止めずに、チームの学習コストも抑えながら、アーキテクチャを改善できます。
完璧を目指さず、「今より少し良い構造」を地道に積み重ねる。そういう現実的なアプローチが、長く運用されるシステムでは最も効果的です。