Railsで開発を続けていると、いつの間にかモデルやコントローラが肥大化し、「どこに何を書くべきか分からない」という状態に陥ることがあります。
このようなコードの複雑化を防ぐための考え方のひとつが、設計原則のひとつ「単一責任の原則(SRP: Single Responsibility Principle)」です。
本記事では、Railsにおいてこの原則をどのように実現し、どのようにテストや保守がしやすくなるのかを、具体的なコード例を交えて紹介します。
「Fat Model / Controller から脱却したい」「テストが書きづらいと感じている」Rails開発者の方に特におすすめです。
SRP(単一責任とは)
SOLID原則の一つ「Single Responsibility Principle(単一責任の原則)」
変更理由が一つだけになるようにクラス・メソッドを設計する
Railsではコントローラやモデルが肥大化しがち → SRP違反が頻発
SRP違反の例
- コントローラに業務ロジックが書かれている
- モデルが複数の責務(バリデーション、通知、集計など)を持っている
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.user = current_user
if @order.save
# 業務ロジックが混在している
Inventory.adjust_stock(@order.items)
NotificationMailer.order_created(@order).deliver_later
ActivityLog.create(user: current_user, action: 'create_order', order_id: @order.id)
redirect_to @order, notice: '注文が作成されました。'
else
render :new
end
end
private
def order_params
params.require(:order).permit(:shipping_address, items: [:id, :quantity])
end
end
RailsでSRPを守る実践方法
サービスオブジェクトの活用
- Railsでは、サービスオブジェクトによって複数の責務を明確に分離できる
- コントローラの肥大化、ファットモデル、コールバック地獄などの問題に対処可能
コントローラのビジネスロジックを分離する
- app/servicesディレクトリを活用してビジネスロジックを外に出す
- 単一の目的に絞ったクラスを作ることで責務が明確になる
class OrdersController < ApplicationController
def create
service = OrderCreationService.new(order_params, current_user)
if service.call
redirect_to service.order, notice: '注文が作成されました。'
else
@order = service.order
render :new
end
end
end
# app/services/order_creation_service.rb
class OrderCreationService
attr_reader :order
def initialize(order_params, user)
@order = Order.new(order_params)
@order.user = user
end
def call
ActiveRecord::Base.transaction do
@order.save!
Inventory.adjust_stock(@order.items)
NotificationMailer.order_created(@order).deliver_later
ActivityLog.create(user: @order.user, action: 'create_order', order_id: @order.id)
end
true
rescue ActiveRecord::RecordInvalid
false
end
end
ファットモデルを避ける(ビジネスロジックの分離)
❌ Before:Userモデルがポイント加算とランク更新を担う
class User < ApplicationRecord
has_many :points
def add_points(amount)
points.create!(amount: amount)
update_rank
end
def update_rank
total = points.sum(:amount)
new_rank = if total >= 1000
'gold'
elsif total >= 500
'silver'
else
'bronze'
end
update!(rank: new_rank)
end
end
✅ After:サービスオブジェクトでビジネスロジックを分離
class UserPointService
def initialize(user)
@user = user
end
def add_points(amount)
@user.points.create!(amount: amount)
update_rank
end
private
def update_rank
total = @user.points.sum(:amount)
new_rank = case total
when 1000.. then 'gold'
when 500...1000 then 'silver'
else 'bronze'
end
@user.update!(rank: new_rank)
end
end
呼び出し側
UserPointService.new(user).add_points(100)
コールバック地獄からの脱却
❌ Before:after_createなどで副作用を連発
class User < ApplicationRecord
after_create :send_welcome_email, :log_creation
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
def log_creation
ActivityLog.create(user: self, action: 'created_user')
end
end
✅ After:サービスで明示的に処理を呼び出す
class UserRegistrationService
def initialize(user_params)
@user_params = user_params
end
def call
user = User.create!(@user_params)
UserMailer.welcome(user).deliver_later
ActivityLog.create(user: user, action: 'created_user')
user
end
end
呼び出し側
user = UserRegistrationService.new(params[:user]).call
SRPを意識するとテストがこう変わる!
サービスオブジェクト化でテストがしやすくなる
❌ Before(Fat Modelのままのテスト)
# spec/models/user_spec.rb
RSpec.describe User, type: :model do
describe '#add_points' do
it 'ポイントを加算し、ランクを更新する' do
user = create(:user)
create(:point, user: user, amount: 900)
user.add_points(200)
expect(user.points.sum(:amount)).to eq(1100)
expect(user.reload.rank).to eq('gold')
end
end
end
✅ After(サービスオブジェクトにした後のテスト)
# spec/services/user_point_service_spec.rb
RSpec.describe UserPointService, type: :service do
describe '#add_points' do
it 'ポイントを加算し、ランクを更新する' do
user = create(:user)
create(:point, user: user, amount: 900)
service = UserPointService.new(user)
service.add_points(200)
expect(user.points.sum(:amount)).to eq(1100)
expect(user.reload.rank).to eq('gold')
end
end
end
✅ 副作用のある処理もテストしやすくなる
# spec/services/user_registration_service_spec.rb
RSpec.describe UserRegistrationService, type: :service do
describe '#call' do
it 'ユーザー作成とメール送信、ログ作成を行う' do
params = attributes_for(:user)
expect(UserMailer).to receive_message_chain(:welcome, :deliver_later)
expect(ActivityLog).to receive(:create)
service = UserRegistrationService.new(params)
user = service.call
expect(user).to be_persisted
end
end
end
💬 補足:そもそも「副作用」ってなに?
副作用とは、「処理が外部の状態を変えること」を意味します。
✅ たとえば、以下はすべて「副作用」です
- メールを送る(外部のユーザーに通知が届く)
- ログを保存する(ファイルやDBに記録される)
- レコードを新規作成する(データベースの状態が変化する)
逆に 1 + 1
のような「内部で完結する処理」は副作用とは言いません。
副作用が見えにくい場所(モデルの中やコールバック)にあると、
- どこで何が起きているのか分かりにくい
- テスト時に余計なモックが必要になる
- 想定外のバグが起きやすくなる
サービスオブジェクトにすれば副作用が明示されるため、見通しが良くなりテストもしやすくなります。
SRPを意識することで得られるメリット
- テストしやすくなる(単一責任 → テスト対象が明確)
- 再利用性が高まる
- 変更に強くなる(変更理由が明確になるため)
まとめ
- コントローラ/モデルに業務ロジックや副作用を詰め込むのはSRP違反
- サービスオブジェクトの導入で、責務を分離・明示しよう
- テストのしやすさ、保守性の向上、変更に強い設計につながる