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

Railsで開発を続けていると、いつの間にかモデルやコントローラが肥大化し、「どこに何を書くべきか分からない」という状態に陥ることがあります。

このようなコードの複雑化を防ぐための考え方のひとつが、設計原則のひとつ「単一責任の原則（SRP: Single Responsibility Principle）」です。

本記事では、Railsにおいてこの原則をどのように実現し、どのようにテストや保守がしやすくなるのかを、具体的なコード例を交えて紹介します。

「Fat Model / Controller から脱却したい」「テストが書きづらいと感じている」Rails開発者の方に特におすすめです。

# SRP（単一責任とは）
SOLID原則の一つ「Single Responsibility Principle（単一責任の原則）」
変更理由が一つだけになるようにクラス・メソッドを設計する
Railsではコントローラやモデルが肥大化しがち → SRP違反が頻発

# SRP違反の例
- コントローラに業務ロジックが書かれている
- モデルが複数の責務（バリデーション、通知、集計など）を持っている

```ruby
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ディレクトリを活用してビジネスロジックを外に出す
- 単一の目的に絞ったクラスを作ることで責務が明確になる

```ruby
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
```

```ruby
# 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モデルがポイント加算とランク更新を担う
```ruby
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：サービスオブジェクトでビジネスロジックを分離
```ruby
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
```

呼び出し側
```ruby
UserPointService.new(user).add_points(100)
```

#### コールバック地獄からの脱却

❌ Before：after_createなどで副作用を連発
```ruby
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：サービスで明示的に処理を呼び出す
```ruby
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
```

呼び出し側
```ruby
user = UserRegistrationService.new(params[:user]).call
```

## SRPを意識するとテストがこう変わる！

#### サービスオブジェクト化でテストがしやすくなる

❌ Before（Fat Modelのままのテスト）
```ruby
# 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（サービスオブジェクトにした後のテスト）
```ruby
# 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
```

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