0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsで実践するSRP ― 単一責任の原則を守る設計とテストのすすめ

Posted at

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違反
  • サービスオブジェクトの導入で、責務を分離・明示しよう
  • テストのしやすさ、保守性の向上、変更に強い設計につながる
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?