Rails Service Objects 2026年完全ガイド:デザインパターン、POROと技術面接対策

RailsのService Objectsを活用したPOROパターン、Resultモナド、クリーンアーキテクチャを解説。Rails 8対応の実務コード例と技術面接で頻出の質問を網羅。

Rails Service Objectsのデザインパターンとクリーンアーキテクチャの図解

Rails Service Objectsは、ビジネスロジックをコントローラーやモデルからPlain Old Ruby Objects(PORO)へ分離する設計パターンである。このパターンを適用することで、アプリケーションの規模が拡大しても保守性を維持できる。技術面接においても、クリーンアーキテクチャの理解度を測る指標として頻繁に出題される。

面接官が期待するポイント

Service Objectは単一のビジネス操作をカプセル化するクラスである。通常callという名前の公開メソッドを1つだけ持ち、依存関係はコンストラクタで注入し、予測可能な結果を返す。この設計が「なぜ」重要なのかを説明できる候補者は、単に実装方法を知っているだけの候補者よりも高く評価される。

Fat ModelとFat Controllerの問題を解決する理由

Railsはロジックをどこかに配置することを促すフレームワークである。明確な指針がなければ、チームは「Fat Model、Skinny Controller」の原則に従ってモデルにロジックを詰め込み、ActiveRecordクラスが数千行に膨れ上がることになる。逆にコントローラーに残すと、アクションを単体でテストすることが不可能になる。

Service Objectsは第三の選択肢を提供する。ドメインロジックは、明示的に必要とするもの以外Railsに依存しない専用クラスに配置される。この疎結合により、フルフレームワークをロードせずにテスト可能になり、異なるエントリポイント(コントローラー、バックグラウンドジョブ、コンソール)間で再利用可能になり、各クラスが単一の責務を持つため可読性が向上する。

Rails GuidesではService Objectsを明示的に規定していないが、コミュニティはこのパターンを標準として採用している。Solid QueueやSolid Cacheで外部依存を置き換えるRails 8のシンプルさへの重点は、POROをさらに魅力的にしている。Gemが少なく、純粋なRubyが多くなる。

本番環境向けService Objectの構成要素

Service Objectには3つの要素が必要である。依存関係を受け取るコンストラクタ、単一の公開callメソッド、成功または失敗を示す戻り値の型である。

ruby
# app/services/users/create_account.rb
module Users
  class CreateAccount
    def initialize(user_repo: User, mailer: UserMailer)
      @user_repo = user_repo
      @mailer = mailer
    end

    def call(params)
      user = @user_repo.new(params)
      return Result.failure(:validation_failed, user.errors) unless user.valid?

      user.save!
      @mailer.welcome_email(user).deliver_later
      Result.success(user)
    rescue ActiveRecord::RecordNotUnique
      Result.failure(:email_taken, "Email already registered")
    end
  end
end

コンストラクタはuser_repomailerをデフォルト値付きで受け取る。本番コードはデフォルトを使用し、テストではモックを注入する。callメソッドはバリデーション、永続化、メール送信を行い、すべての結果をResultオブジェクトでラップする。

Gemなしで最小限のResultクラスを構築する

一部のチームはすぐにdry-monadsを導入する。このGemは優れているが、学習コストがかかる。30行程度のResultクラスでほとんどのニーズをカバーできる。

ruby
# app/services/result.rb
class Result
  attr_reader :value, :error, :code

  def initialize(success:, value: nil, error: nil, code: nil)
    @success = success
    @value = value
    @error = error
    @code = code
  end

  def success? = @success
  def failure? = !@success

  def self.success(value) = new(success: true, value: value)
  def self.failure(code, error = nil) = new(success: false, code: code, error: error)

  def on_success
    yield(value) if success?
    self
  end

  def on_failure
    yield(code, error) if failure?
    self
  end
end

このResultはon_successon_failureによるチェーンをサポートし、構造化されたエラーコードを公開し、依存関係がゼロである。コントローラーはこれをクリーンに利用できる。

ruby
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    result = Users::CreateAccount.new.call(user_params)

    result
      .on_success { |user| redirect_to user, notice: "Account created" }
      .on_failure { |code, error| render :new, status: :unprocessable_entity }
  end

  private

  def user_params
    params.require(:user).permit(:email, :password, :name)
  end
end
dry-monadsを使用すべき場合

dry-rbエコシステムをすでに使用しているチーム、または複数の操作をチェーンするためのDo記法が必要なチームは、dry-monadsの恩恵を受ける。このGemはRuby 3.xのパターンマッチングをサポートするSuccessFailureMaybe型を提供する。

意図を伝える命名規則

主に2つの規則が存在する。

スタイル使用場面
動詞句CreateAccount, SendInvoice, RefundPayment状態を変更するコマンド
名詞 + -orAccountCreator, InvoiceSender, PaymentRefunder一部のチームが好む、やや一般的でない

動詞句スタイルは自然に読める。Users::CreateAccount.new.call(params)のように。-orサフィックスは機能するが、明確さを増さずに音節が増える。

ディレクトリ構造も重要である。ドメインでグループ化すると、関連するサービスがまとまる。

text
app/services/
  users/
    create_account.rb
    reset_password.rb
    update_profile.rb
  orders/
    place_order.rb
    cancel_order.rb
    calculate_totals.rb
  result.rb

Ruby on Railsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

テスト可能なサービスのための依存性注入

ハードコードされた依存関係はテストを困難にする。このサービスはデータベースにアクセスし、実際のメールを送信しなければテストできない。

ruby
# 避けるべき例:ハードコードされた依存関係
class CreateAccount
  def call(params)
    user = User.create!(params)        # 直接モデル呼び出し
    UserMailer.welcome(user).deliver   # 直接メーラー呼び出し
  end
end

コンストラクタを通じた注入がこれを解決する。

ruby
# 改善版:注入可能な依存関係
class CreateAccount
  def initialize(user_repo: User, mailer: UserMailer)
    @user_repo = user_repo
    @mailer = mailer
  end

  def call(params)
    user = @user_repo.create!(params)
    @mailer.welcome(user).deliver_later
    Result.success(user)
  end
end

テストではフェイクを代入できる。

ruby
# spec/services/users/create_account_spec.rb
RSpec.describe Users::CreateAccount do
  let(:fake_repo) { class_double(User) }
  let(:fake_mailer) { class_double(UserMailer) }
  let(:service) { described_class.new(user_repo: fake_repo, mailer: fake_mailer) }

  it "returns success with valid params" do
    user = instance_double(User, valid?: true)
    allow(fake_repo).to receive(:new).and_return(user)
    allow(user).to receive(:save!)
    allow(fake_mailer).to receive_message_chain(:welcome_email, :deliver_later)

    result = service.call(email: "test@example.com", password: "secure123")

    expect(result).to be_success
  end
end

データベースなし、メールなし、高速実行。RSpec documentationでは、型検証されたモックのためのclass_doubleinstance_doubleについて解説している。

Rails Service Objectsに関する面接質問

これらの質問は中級および上級Rails面接で出題される。コード例を含む具体的な回答を準備すること。

Q: ビジネスロジックはモデルとService Objectのどちらに置くべきですか?

モデルはバリデーション、アソシエーション、スコープ、単一レコードの振る舞いを所有する。Service Objectsは複数のモデルにまたがる操作、外部呼び出しを必要とする操作、または明示的なトランザクション境界が必要な操作を処理する。Userモデルはメール形式をバリデーションし、CreateAccountサービスはユーザーを作成し、ウェルカムメールを送信し、デフォルト設定をプロビジョニングする。

Q: 例外なしでService Objectsのエラーをどう処理しますか?

成功/失敗状態を持つResultオブジェクトを返す。これにより呼び出し側でエラー処理が明示的になり、例外ベースの制御フローを回避し、異なる失敗モードに対して構造化されたエラーコードを提供する。例外は、ビジネスルール違反ではなく、真に例外的な状況(データベースダウン、ネットワーク障害)に適切である。

Q: Service ObjectとInteractorの違いは何ですか?

Interactor(interactorなどのGemから)は特定のインターフェースを持つService Objectsである。contextハッシュを受け取り、それを変更し、context.fail!で失敗を通知する。POROとしてのService Objectsには規定されたインターフェースがなく、チームにより多くの柔軟性を与えるが、一貫性は低くなる。

Q: 外部APIを呼び出すService Objectをどうテストしますか?

HTTPクライアントを依存関係として注入する。テストでは、定型応答を返すスタブを渡す。WebMockVCRなどのツールでHTTPインタラクションを記録・再生できるが、コンストラクタ注入によりユニットテストでネットワーク呼び出しを完全に回避できる。

Rails面接対策の詳細については、RSpecとテストパターンを扱ったRails面接質問ガイドを参照。

複雑なワークフローのためのRailway指向設計

Railway指向プログラミングは、ワークフローを2つの並行トラックとして扱う。成功は前進し続け、失敗は即座に終了する。各ステップは成功トラックを進むか、失敗トラックに切り替わる。

ruby
# app/services/orders/place_order.rb
module Orders
  class PlaceOrder
    def initialize(
      inventory: InventoryService.new,
      payment: PaymentService.new,
      fulfillment: FulfillmentService.new
    )
      @inventory = inventory
      @payment = payment
      @fulfillment = fulfillment
    end

    def call(cart:, payment_method:)
      validate_cart(cart)
        .then { |items| reserve_inventory(items) }
        .then { |reservation| charge_payment(reservation, payment_method) }
        .then { |charge| create_fulfillment(charge) }
    end

    private

    def validate_cart(cart)
      return Result.failure(:empty_cart, "Cart is empty") if cart.items.empty?
      Result.success(cart.items)
    end

    def reserve_inventory(items)
      @inventory.reserve(items)
    end

    def charge_payment(reservation, payment_method)
      result = @payment.charge(reservation.total, payment_method)
      return result if result.failure?

      Result.success({ reservation: reservation, charge: result.value })
    end

    def create_fulfillment(data)
      @fulfillment.create(data[:reservation], data[:charge])
    end
  end
end

Resultクラスには成功時のみ進むthenメソッドが必要である。

ruby
# Add to app/services/result.rb
def then
  return self if failure?
  yield(value)
end

在庫予約が失敗すれば、決済は実行されない。決済が失敗すれば、フルフィルメントは実行されない。各ステップがResultを返し、最初の失敗でチェーンがショートサーキットする。

Service Objectsがアンチパターンになる場合

Service Objectsは実際の問題を解決するが、不必要に増殖することがある。以下の警告サインに注意すること。

  • 単一行のサービス: サービスが1つのモデルメソッドを呼び出すだけなら、価値なく間接層を追加している。ロジックが単純な場合、AuthenticateUser.new.call(email, password)よりUser.authenticate(email, password)の方が良い。

  • テストのためだけに存在するサービス: 依存関係の注入はテストを容易にするが、テスト可能性のためだけにモデルメソッドをラップするサービスを作成することは、本当の問題がアーキテクチャではなくテストのセットアップにあることを示唆している。

  • ロジックのない貧血サービス: 振る舞いを追加せずにモデルに委譲するだけのサービスは儀式にすぎない。Netguru engineering blogでこの過剰抽出問題について議論している。

代替案:POROを選択的に使用しつつ、共有モデル動作にはConcernsを、MoneyやDateRangeなどのドメイン概念にはValue Objectsを、単一レコード操作にはモデルメソッドを使用する。

Rails Service Object設計の重要ポイント

  • ロジックが複数のモデルにまたがる、外部呼び出しが必要、または明示的なエラー処理が必要な場合にService Objectへ抽出する
  • 生の値や例外ではなく、Resultオブジェクトを返す単一の公開callメソッドを使用する
  • 本番使用時の適切なデフォルト値とともにコンストラクタを通じて依存関係を注入する
  • CreateAccountPlaceOrderのようなアクションを説明する動詞句でサービスを命名する
  • フラットなディレクトリ構造ではなく、ドメイン別(users/orders/)にサービスを整理する
  • dry-monadsなどのGemを追加する前に最小限のResultクラスから始める
  • ビジネスルール違反ではなく、インフラ障害に対して例外を予約する
  • モデルが自然に処理できる単一行の操作に対してサービスを作成することを避ける

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

今日のチャレンジ

Ruby on Rails のバグを見つけられますか

実際のコード、隠れたバグ、1日1回。アカウントなしで試せます。

Anthony Fillion-Maillet

執筆

Anthony Fillion-Maillet

SharpSkill 創業者

10 年以上フルスタック開発に携わっています。SharpSkill を運営し、ここで公開される内容に責任を負っています。

2026年8月20日 更新

タグ

#ruby-on-rails
#design-patterns
#service-objects
#poro
#clean-architecture

共有

関連記事