複雑なビジネスロジックを整理するためのサービスレイヤー設計について解説します。
Main より Synchronizer のように具体的に以下、それぞれ詳しく説明していきます。
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 以上 | サブディレクトリ化 |
「とりあえずフラットで始めて、増えたら分ける」が実践的なアプローチです。
シンプルで見通しが良く、ファイルを探しやすいのが利点です。名前空間が浅いのでクラス名も短くなります。一方で、ファイル数が増えると管理しづらくなり、関連するファイルのグループ化ができないという欠点があります。
責務ごとにグループ化でき、大規模でも見通しが良くなります。チーム開発で担当分けしやすいのも利点です。ただし、名前空間が深くなる(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, 〜Handler | FileProcessor |
| データ保存 | 〜Saver, 〜Writer | DocumentSaver |
| データ取得 | 〜Provider, 〜Fetcher | ArchiveProvider |
| フィルタリング | 〜Filter | FileFilter |
| 通知 | 〜Notifier | SlackNotifier |
| 記録 | 〜Recorder, 〜Logger | HistoryRecorder |
複数のサービス間で共有する状態を管理するオブジェクトです。
オーケストレーターが複数のサービスを呼び出すとき、同じデータを何度も渡す必要があります。
# 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 はシンプルです。
# 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 設計で重要なのは、以下の3つです。
初期化後に状態を変更しないことで、予期しない副作用を防げます。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 が発生します。バグの早期発見につながります。
recorder など外部から注入する依存性がある場合、コンストラクタで受け取ることで初期化時点で依存関係が明確になります。
# 良い例: 依存関係が明確
context = Context.new(workspace, recorder: recorder)
# 避ける例: 後から setter で注入(状態変更につながる)
context = Context.new(workspace)
context.recorder = recorder
この方法は特に Dependency Inversion Principle(依存性逆転の原則)に沿った設計になります。例えば HistoryRecorder が Context に依存していた場合、Context を先に作成する必要が生じます。その場合は HistoryRecorder が先に作成され、Context の初期化パラメータとして渡すことで循環依存を避けられます。
複雑な計算や外部リソースへのアクセスが必要な場合、遅延初期化(||= パターン)も有効です。ただし初期化タイミングが不明確になるため、immutable な Context と組み合わせるときは注意が必要です。
# 遅延初期化の例: 必要になったときだけ計算
def github_app
@github_app ||= github_repo&.github_app
end
遅延初期化を使う場合でも、できるだけ初期化後に新しい値を追加しない設計を心がけましょう。
先ほど例に挙げた GitHub リポジトリとの同期処理について、実際のプロジェクトで使用している構造を紹介します。
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 にすることで、各サービスが予期しない状態変更から保護されます。
処理結果を構造化されたオブジェクトとして返すパターンです。単純な真偽値や副作用を避け、結果を明示的に表現することで、エラーハンドリングと結果の利用が明確になります。
サービスメソッドが複数の値を返す必要がある場合、副作用や不明確な戻り値に頼りがちです。
# 結果が不明確
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) # 結果オブジェクト経由
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 オブジェクトには、いくつかのメリットとデメリットがあります。
利点:
欠点:
複数の関連した値を返す必要がある場合に、最も効果的なパターンです。
1つのクラスが 100 行を超えたら分割を検討するとよいでしょう。ただし、無理に分割すると追いにくくなるので、論理的なまとまりを意識することが大切です。
オーケストレーターは統合テスト的に、各サービスはユニットテストで書くのがおすすめです。Context を使うとモックの注入が楽になります。