naopoyo.com
  • Docs
  • Tags
  • Bookmarks
  • Tools
  • About
  • Docs
  • Tags
  • Bookmarks
  • Tools
  • About

目次

© naopoyo

目次

最近更新された記事

🛤️

Rails 8.0 から 8.1 へのアップグレード手順

24分前·2026年02月04日
  • Rails
  • Ruby
🐙

octokit.rb で GitHub GraphQL API を使う方法

約3時間前·2026年02月04日
  • GitHub
  • GraphQL
  • Ruby
✒️

エンジニア個人ブログまとめ

約19時間前·2026年02月03日
  • デザイン
  • Next.js
  • Markdown
🏗️

Rails サービスレイヤー設計の実践ガイド

公開日約2時間前2026-02-04
履歴GitHubで見る
MarkdownRaw Content
  • Rails
  • Ruby

この記事のポイント

複雑なビジネスロジックを整理するためのサービスレイヤー設計について解説します。

  • ディレクトリ構造はシンプルに始める。ファイル数 15 以下ならフラット、20 以上になったらサブディレクトリ化を検討
  • 複雑な処理にはオーケストレーター(全体の流れを制御するクラス)を用意する
  • オーケストレーターの命名は Main より Synchronizer のように具体的に
  • 複数サービス間で共有するデータは Context オブジェクトにまとめて、引数のバケツリレーを避ける

以下、それぞれ詳しく説明していきます。

はじめに

Rails アプリケーションが成長すると、コントローラやモデルが肥大化しがちです。「Fat Model」「Fat Controller」問題を解決するために、サービスレイヤーを導入することが一般的になっています。

しかし、サービスクラスを導入したものの「ファイルが増えすぎて管理できない」「どこに何があるか分からない」という新たな問題に直面することも少なくありません。

この記事では、サービスレイヤーのディレクトリ構造と命名規則に焦点を当て、保守しやすい設計について考えます。

サービスレイヤーとは

サービスレイヤーは、複数のモデルにまたがる処理、外部 API との連携、複雑なビジネスロジック、トランザクションを伴う一連の処理などを切り出すための層です。

# コントローラがシンプルになる
class OrdersController < ApplicationController
  def create
    result = Orders::CreateService.call(
      user: current_user,
      cart: current_cart
    )

    if result.success?
      redirect_to result.order
    else
      render :new, alert: result.error
    end
  end
end

ディレクトリ構造の選び方

ファイル数で判断する

ファイル数推奨構造
15 以下フラット構造
15〜20状況により検討
20 以上サブディレクトリ化

「とりあえずフラットで始めて、増えたら分ける」が実践的なアプローチです。

フラット構造

  • app/services/orders/
    • create_service.rb
    • cancel_service.rb
    • refund_service.rb
    • notification_service.rb

シンプルで見通しが良く、ファイルを探しやすいのが利点です。名前空間が浅いのでクラス名も短くなります。一方で、ファイル数が増えると管理しづらくなり、関連するファイルのグループ化ができないという欠点があります。

ネスト構造

  • app/services/sync/
    • synchronizer.rb # エントリーポイント
    • context.rb # 状態共有
    • processing/ # 処理系
      • file_processor.rb
      • file_filter.rb
    • support/ # 補助機能
      • notifier.rb
      • history_recorder.rb

責務ごとにグループ化でき、大規模でも見通しが良くなります。チーム開発で担当分けしやすいのも利点です。ただし、名前空間が深くなる(Sync::Processing::FileProcessor のように)ため、小規模だと過剰設計になります。

判断のポイント

フラット構造を維持すべきなのは、ファイル数が 15 以下で、各サービスが独立して動作し、名前空間が既に適切にスコープ化されている場合です。

サブディレクトリ化を検討すべきなのは、ファイル数が 20 を超えている、処理のフェーズが明確に分かれている(取得→加工→保存など)、複数人で同時に開発する必要がある、といった場合です。

「このファイルどこだっけ?」と思う頻度が増えたら、そろそろ分割を検討するタイミングかもしれません。

オーケストレーターパターン

複雑な処理フローを管理するために、オーケストレーター(指揮者)となるクラスを配置するパターンです。

なぜオーケストレーターが必要か

GitHub リポジトリとの同期処理を例に考えてみましょう。この同期処理とは、GitHub 上のリポジトリからファイルを取得し、その内容をアプリケーションのデータベースに取り込む機能です。

この処理には、リポジトリからのファイル取得、フィルタリング、データベースへの保存、関連データの更新、通知の送信、履歴の記録といったステップがあります。

これらを1つのクラスに詰め込むと、数百行の巨大クラスになってしまいます。かといって、バラバラのクラスを呼び出し側で組み合わせると、呼び出し側が複雑になります。

そこで、全体の流れを制御する専用のクラスを用意します。

# app/services/sync/synchronizer.rb
module Sync
  class Synchronizer
    def initialize(workspace)
      @context = Context.new(workspace)
      @file_processor = FileProcessor.new(@context)
      @refresher = WorkspaceRefresher.new(@context)
      @notifier = Notifier.new(@context)
    end

    def self.call(workspace)
      new(workspace).call
    end

    def call
      ActiveRecord::Base.transaction do
        file_processor.process
        refresher.refresh
      end
    ensure
      notifier.notify
    end

    private

    attr_reader :context, :file_processor, :refresher, :notifier
  end
end

オーケストレーターの役割

オーケストレーターは、依存オブジェクトの初期化(必要なサービスを生成)、処理フローの制御(順序を管理、トランザクション境界を定義)、エラーハンドリング(例外処理、ロールバック)、後処理の保証(ensure で必ず実行する処理)を担います。

各サービスは自分の責務だけに集中でき、全体の流れはオーケストレーターが管理します。

命名規則

オーケストレーターの命名

Main という名前は何をするクラスか分からず、Service は他のサービスと区別できず、Manager は責務が曖昧になりがちです。これらは避けたほうがよいでしょう。

代わりに、Synchronizer や Importer のようなドメイン動詞を使うと何をするか明確になります。Orchestrator や Coordinator のように役割を明示する命名も有効です。

# 何をするか分からない
WorkspaceRepositories::Sync::Main

# 同期処理であることが明確
WorkspaceRepositories::Sync::Synchronizer

# より簡潔に
WorkspaceRepositories::Synchronizer

補助クラスの命名

役割命名パターン例
処理実行〜Processor, 〜HandlerFileProcessor
データ保存〜Saver, 〜WriterDocumentSaver
データ取得〜Provider, 〜FetcherArchiveProvider
フィルタリング〜FilterFileFilter
通知〜NotifierSlackNotifier
記録〜Recorder, 〜LoggerHistoryRecorder

Context オブジェクト

複数のサービス間で共有する状態を管理するオブジェクトです。

なぜ Context が必要か

オーケストレーターが複数のサービスを呼び出すとき、同じデータを何度も渡す必要があります。

# Context なし: 同じ引数を何度も渡す
def call
  file_processor.process(workspace, github_repo, recorder)
  refresher.refresh(workspace, github_repo, recorder)
  notifier.notify(workspace, github_repo, recorder)
end

Context オブジェクトを使うと、このバケツリレーを避けられます。

# Context あり: 共有状態をまとめる
def call
  file_processor.process
  refresher.refresh
  notifier.notify
end

Context の実装

基本的な Context はシンプルです。

# app/services/sync/context.rb
module Sync
  class Context
    attr_reader :workspace, :github_repo, :recorder

    def initialize(workspace, recorder:)
      @workspace = workspace
      @github_repo = workspace.github_repo
      @recorder = recorder
    end
  end
end

各サービスは Context を受け取り、必要なデータにアクセスします。

class FileProcessor
  def initialize(context)
    @context = context
  end

  def process
    # context.workspace, context.github_repo にアクセス
  end
end

Context 設計のポイント

Context 設計で重要なのは、以下の3つです。

1. Immutable 化で不変性を保証する

初期化後に状態を変更しないことで、予期しない副作用を防げます。Ruby では freeze を使って完全に immutable にできます。

class Context
  attr_reader :workspace, :github_repo, :recorder

  def initialize(workspace, recorder:)
    @workspace = workspace
    @github_repo = workspace.github_repo
    @recorder = recorder
    freeze  # 初期化後は状態変更不可
  end
end

freeze を呼び出すと、オブジェクトのインスタンス変数を変更しようとしたときに FrozenError が発生します。バグの早期発見につながります。

2. Constructor Injection で依存性を明確にする

recorder など外部から注入する依存性がある場合、コンストラクタで受け取ることで初期化時点で依存関係が明確になります。

# 良い例: 依存関係が明確
context = Context.new(workspace, recorder: recorder)

# 避ける例: 後から setter で注入(状態変更につながる)
context = Context.new(workspace)
context.recorder = recorder

この方法は特に Dependency Inversion Principle(依存性逆転の原則)に沿った設計になります。例えば HistoryRecorder が Context に依存していた場合、Context を先に作成する必要が生じます。その場合は HistoryRecorder が先に作成され、Context の初期化パラメータとして渡すことで循環依存を避けられます。

3. 遅延初期化は慎重に使う

複雑な計算や外部リソースへのアクセスが必要な場合、遅延初期化(||= パターン)も有効です。ただし初期化タイミングが不明確になるため、immutable な Context と組み合わせるときは注意が必要です。

# 遅延初期化の例: 必要になったときだけ計算
def github_app
  @github_app ||= github_repo&.github_app
end

遅延初期化を使う場合でも、できるだけ初期化後に新しい値を追加しない設計を心がけましょう。

実践例: GitHub 同期処理の設計

先ほど例に挙げた GitHub リポジトリとの同期処理について、実際のプロジェクトで使用している構造を紹介します。

ファイル構成

  • app/services/workspace_repositories/sync/
    • synchronizer.rb # オーケストレーター
    • context.rb # 状態共有
    • file_processor.rb # ファイル処理統括
    • file_filter.rb # フィルタリング
    • file_saver.rb # DB保存
    • zip_processor.rb # ZIP解析
    • configuration_handler.rb # 設定管理
    • workspace_refresher.rb # データ更新
    • history_recorder.rb # 履歴記録
    • notifier.rb # 通知
    • schedule_updater.rb # スケジュール更新

13 ファイルでフラット構造を維持しています。

クラス間の依存関係

Synchronizer (オーケストレーター)
├── Context (状態共有)
├── HistoryRecorder (履歴)
├── ConfigurationHandler (設定)
├── FileProcessor (ファイル処理)
│   ├── ZipProcessor
│   ├── FileFilter
│   └── FileSaver
├── WorkspaceRefresher (更新)
├── Notifier (通知)
└── ScheduleUpdater (スケジュール)

処理フロー

Synchronizer は依存オブジェクトを初期化して、全体のフローを制御します。

class Synchronizer
  def initialize(workspace)
    # HistoryRecorder を先に作成
    @recorder = HistoryRecorder.new(workspace)

    # Context を immutable で作成(recorder を注入)
    @context = Context.new(workspace, recorder: @recorder)

    # その他のサービスを初期化
    @config_handler = ConfigurationHandler.new(@context)
    @file_processor = FileProcessor.new(@context)
    @refresher = WorkspaceRefresher.new(@context)
    @notifier = Notifier.new(@context)
  end

  def call
    return if skip?

    @recorder.create_start_history

    ActiveRecord::Base.transaction do
      @config_handler.save_settings
      @file_processor.process
      @refresher.refresh
    end
  rescue => e
    @recorder.add_error(e)
    raise
  ensure
    @recorder.save
    @notifier.notify
  end
end

Context を immutable にすることで、各サービスが予期しない状態変更から保護されます。

Result オブジェクトパターン

処理結果を構造化されたオブジェクトとして返すパターンです。単純な真偽値や副作用を避け、結果を明示的に表現することで、エラーハンドリングと結果の利用が明確になります。

なぜ Result オブジェクトが必要か

サービスメソッドが複数の値を返す必要がある場合、副作用や不明確な戻り値に頼りがちです。

# 結果が不明確
def process_files
  @path_checksums = []
  @tree_file_paths = []
  # ...
  # 呼び出し元は @path_checksums や @tree_file_paths に直接アクセス
end

# 使い方が不自然
processor = FileProcessor.new(context)
processor.process_files
refresher.delete_unused_paths(processor.path_checksums)  # 内部状態に依存

Result オブジェクトを使うと、結果が明示的で、インターフェースがシンプルになります。

# 結果が構造化される
ProcessResult = Struct.new(:path_checksums, :tree_file_paths, keyword_init: true) do
  def processed_count
    path_checksums.length
  end
end

def process_files
  # ... 処理 ...
  ProcessResult.new(
    path_checksums: @path_checksums,
    tree_file_paths: @tree_file_paths
  )
end

# 使い方が明確
result = processor.process_files
refresher.delete_unused_paths(result.path_checksums)  # 結果オブジェクト経由

Result オブジェクトの実装

Ruby の Struct を使うと、シンプルに実装できます。

class FileProcessor
  # ファイル処理結果を表す Struct
  ProcessResult = Struct.new(
    :path_checksums,
    :tree_file_paths,
    keyword_init: true
  ) do
    # ヘルパーメソッドも追加可能
    def processed_count
      path_checksums.length
    end

    def any_paths?
      path_checksums.any? || tree_file_paths.any?
    end
  end

  def process_files
    files = collect_files
    prefetch_commit_times(files)
    save_files(files)

    ProcessResult.new(
      path_checksums: @path_checksums,
      tree_file_paths: @tree_file_paths
    )
  end
end

複雑な結果構造の場合

成功・失敗を区別する必要がある場合は、別の設計を検討します。

# シンプルな成功・失敗パターン
Result = Struct.new(:success?, :value, :error, keyword_init: true) do
  def initialize(success: true, value: nil, error: nil)
    super(success?, value, error)
  end
end

# または専用クラス
class ProcessResult
  attr_reader :status, :data, :error

  def initialize(status, data = nil, error = nil)
    @status = status
    @data = data
    @error = error
  end

  def success?
    status == :success
  end

  def failed?
    status == :failure
  end
end

ただし、単なる成功・失敗なら例外を使うほうがシンプルです。Result オブジェクトは「複数の戻り値が必要」という明確なユースケースで活躍します。

Result オブジェクトの利点と欠点

Result オブジェクトには、いくつかのメリットとデメリットがあります。

利点:

  • 処理結果の構造が明示的になる
  • 呼び出し元が内部状態に依存しない(カプセル化の改善)
  • ヘルパーメソッドで関連ロジックを集約できる

欠点:

  • Struct の定義が増える
  • 単純な場合には過剰設計になる可能性

複数の関連した値を返す必要がある場合に、最も効果的なパターンです。

設計についての補足

クラスの分割粒度について

1つのクラスが 100 行を超えたら分割を検討するとよいでしょう。ただし、無理に分割すると追いにくくなるので、論理的なまとまりを意識することが大切です。

テストの書き方について

オーケストレーターは統合テスト的に、各サービスはユニットテストで書くのがおすすめです。Context を使うとモックの注入が楽になります。

参考資料

  • Toptal - Rails Service Objects Tutorial
  • 37signals - Vanilla Rails is Plenty
  • Interactor gem