mybook

Refactorability — 変更に強い設計

「変更が怖い」という感覚の正体

「このコードを変えたら何かが壊れるかもしれない」

ユイはOrderモデルの complete_order メソッドを見て、そう感じた。在庫の更新処理を変更する必要があるのに、手が止まる。何かを壊してしまう気がする。

「その感覚の正体を教えよう」田中さんが言った。「それは『密結合』から来る恐怖だ」

# 「変更が怖い」コードの典型例
class Order < ApplicationRecord
  def complete!
    # 在庫を更新(直接呼び出している)
    items.each do |item|
      product = Product.find(item.product_id)
      product.stock -= item.quantity
      product.save!  # ← これを変えると...
 
      # 在庫が減ったらAWSのSNSに通知
      if product.stock < product.reorder_point
        AwsSnsClient.new(
          region: 'ap-northeast-1',
          access_key: ENV['AWS_ACCESS_KEY']  # ← AWSに直接依存
        ).publish(
          topic_arn: ENV['LOW_STOCK_TOPIC_ARN'],
          message: "#{product.name}の在庫が少なくなりました"
        )
      end
    end
 
    # 注文を完了に
    update!(status: :completed)
 
    # ユーザーのポイントを追加(UserモデルとOrderモデルが密結合)
    user.loyalty_points += (total * 0.01).floor
    user.save!
 
    # メールを送信(SendGridに直接依存)
    SendGrid::API.new(api_key: ENV['SENDGRID_KEY'])
                 .client.mail._post(body: build_confirmation_email)
  end
end

このコードの問題は何か?

  1. OrderProductUserAwsSnsClientSendGrid に直接依存している
  2. 変更の影響範囲が見えない(在庫ロジックを変えようとすると、SNS、ポイント、メールも巻き込まれる)
  3. テストが書きにくい(AWSのSNSとSendGridへの実際の通信が起きる)

「密結合とは、部品が互いに強く結びついている状態だ。1つを変えると他も連鎖的に変わる。レゴブロックで例えると、ブロック同士が接着剤でくっついている状態。組み替えようとすると全部バラバラになる」

Refactorability(リファクタリング可能性) = 変更が安心してできる設計。

INFO

変更が怖いコードは「密結合」している。変更が安心できるコードは「疎結合」している。SOLID原則はこの疎結合を実現するための5つの指針です。全部を一度に覚えようとせず、「今のコードに当てはまるものから1つずつ」実践しましょう。

SOLID原則: 5つの指針

S — Single Responsibility Principle(単一責任の原則)

「クラスが変更される理由は1つだけであるべき」

# Before: 1つのクラスが複数の変更理由を持つ
class Order < ApplicationRecord
  # 注文データ(データベース変更で変わる)
  # +
  # メール送信(メールテンプレート変更で変わる)
  # +
  # 在庫更新(在庫ロジック変更で変わる)
  # = 3つの変更理由がある
  def complete!
    update!(status: :completed)
    UserMailer.order_confirmation(user, self).deliver_later  # メール
    InventoryService.decrease_stock(items)                   # 在庫
    Analytics.track('order_created', order_id: id)          # 分析
  end
end
 
# After: 各責任を別クラスへ
class Order < ApplicationRecord
  # Orderはデータの永続化だけ(DB変更のみで変わる)
  scope :completed, -> { where(status: 'completed') }
  scope :pending, -> { where(status: 'pending') }
 
  def complete!
    update!(status: :completed, completed_at: Time.current)
  end
 
  def pending?
    status == 'pending'
  end
end
 
class OrderCompletionService
  # 注文完了の「ビジネスプロセス」を担当(プロセス変更で変わる)
  def initialize(order, mailer: OrderMailer, inventory: InventoryService)
    @order = order
    @mailer = mailer          # 依存性注入(後述)
    @inventory = inventory
  end
 
  def call
    ActiveRecord::Base.transaction do
      @order.complete!
      post_completion_tasks
    end
  end
 
  private
 
  def post_completion_tasks
    send_confirmation_email
    update_inventory
    track_analytics
  end
 
  def send_confirmation_email
    @mailer.confirmation(@order.user, @order).deliver_later
  end
 
  def update_inventory
    @inventory.decrease_stock(@order.items)
  end
 
  def track_analytics
    AnalyticsJob.perform_later('order.completed', order_id: @order.id)
  end
end

変更理由が明確になった。「メールテンプレートを変えたい」→ OrderMailer だけ変える。「在庫ロジックを変えたい」→ InventoryService だけ変える。Order モデルには触れない。

O — Open/Closed Principle(開放閉鎖の原則)

「拡張に開き、修正に閉じる」——新機能は既存コードを変えずに追加できるべき。

# Before: 新しい決済方法が増えるたびに既存コードを修正する(修正に開いている)
class PaymentProcessor
  def process(order, method)
    case method
    when 'credit_card'
      CreditCardGateway.charge(order.total, order.card_token)
    when 'paypal'
      PaypalClient.execute_payment(order.total, order.paypal_token)
    when 'bank_transfer'
      BankTransfer.initiate(order.total, order.bank_account)
    # → 「コンビニ払い」を追加するにはここに when を追加する必要がある
    # → 既存のcase文を修正するリスクがある
    end
  end
end
# After: 新しい決済方法はクラスを追加するだけ(既存コードを変えない)
module PaymentGateway
  # 共通インターフェース(Rubyはインターフェースを明示しないが、慣習として)
  class Base
    def process(order)
      raise NotImplementedError, "#{self.class}#process を実装してください"
    end
 
    def refund(order, amount)
      raise NotImplementedError, "#{self.class}#refund を実装してください"
    end
  end
 
  class CreditCard < Base
    def process(order)
      result = CreditCardGateway.charge(order.total, order.card_token)
      PaymentResult.new(success: result.success?, transaction_id: result.id)
    end
 
    def refund(order, amount)
      CreditCardGateway.refund(order.payment_transaction_id, amount)
    end
  end
 
  class PayPal < Base
    def process(order)
      result = PaypalClient.execute_payment(order.total, order.paypal_token)
      PaymentResult.new(success: result.approved?, transaction_id: result.id)
    end
 
    def refund(order, amount)
      PaypalClient.refund(order.payment_transaction_id, amount)
    end
  end
 
  # 新しい決済方法: クラスを追加するだけ(PaymentProcessorは変えない)
  class ConvenienceStore < Base
    def process(order)
      code = ConvenienceStorePayment.generate_code(order.total)
      order.update!(convenience_code: code, expires_at: 3.days.from_now)
      PaymentResult.new(success: true, transaction_id: code)
    end
 
    def refund(order, amount)
      # コンビニ払いは自動返金不可(オペレーター対応)
      RefundTicket.create!(order: order, amount: amount, status: :pending)
    end
  end
end
 
class PaymentProcessor
  GATEWAYS = {
    'credit_card' => PaymentGateway::CreditCard,
    'paypal' => PaymentGateway::PayPal,
    'convenience_store' => PaymentGateway::ConvenienceStore
  }.freeze
 
  def process(order, payment_method)
    gateway_class = GATEWAYS.fetch(payment_method) do
      raise UnknownPaymentMethod, "未知の決済方法: #{payment_method}"
    end
 
    gateway_class.new.process(order)
  end
end
Loading diagram...

「コンビニ払い」を追加するとき、PaymentProcessor に1行 GATEWAYS の登録を追加するだけで済む。既存のクレジットカードやPayPalの処理は一切変えない。

L — Liskov Substitution Principle(リスコフ置換原則)

「基底クラスを使う場所で、派生クラスに差し替えても動く設計にする」

# Before: 継承した派生クラスが基底クラスの契約を破る
class Shipping
  def calculate_fee(order)
    500  # デフォルトは500円
  end
 
  def deliver(order)
    # 通常配送の処理
  end
end
 
class FreeShipping < Shipping
  def calculate_fee(order)
    return 0 if order.total > 5000
    500
  end
end
 
class DigitalDelivery < Shipping
  def deliver(order)
    # メールでダウンロードリンクを送る
  end
 
  def calculate_fee(order)
    0  # デジタルは常に無料
  end
 
  def ship_to_address(address)
    raise "デジタル商品は物理配送できません"  # ← 基底クラスの契約を破る!
  end
end
 
# After: 適切な抽象化で置換可能にする
module Shippable
  def calculate_fee(order)
    raise NotImplementedError
  end
 
  def estimated_delivery_days
    raise NotImplementedError
  end
end
 
class StandardShipping
  include Shippable
 
  def calculate_fee(order)
    order.total >= 5000 ? 0 : 500
  end
 
  def estimated_delivery_days
    3
  end
end
 
class DigitalDelivery
  include Shippable
 
  def calculate_fee(order)
    0  # デジタルは常に無料
  end
 
  def estimated_delivery_days
    0  # 即時配信
  end
  # ship_to_address は持たない(契約に含めない)
end

I — Interface Segregation Principle(インターフェース分離の原則)

「クライアントは使わないメソッドに依存すべきでない」

# Before: 大きすぎるインターフェース(使わないメソッドを実装させられる)
module Notifiable
  def send_email(user, message); raise NotImplementedError; end
  def send_sms(user, message); raise NotImplementedError; end
  def send_push(user, message); raise NotImplementedError; end
  def send_slack(channel, message); raise NotImplementedError; end
end
 
class OrderNotifier
  include Notifiable
 
  def send_email(user, message)
    OrderMailer.notification(user, message).deliver_later
  end
 
  # SMSは使わないけど実装を強制される
  def send_sms(user, message)
    raise NotImplementedError, "SMSは使いません"
  end
 
  # Pushも使わないけど...
  def send_push(user, message)
    raise NotImplementedError, "Pushは使いません"
  end
  # ...
end
 
# After: インターフェースを分割する
module EmailNotifiable
  def send_email(user, message); raise NotImplementedError; end
end
 
module SmsNotifiable
  def send_sms(user, message); raise NotImplementedError; end
end
 
module PushNotifiable
  def send_push(user, message); raise NotImplementedError; end
end
 
# 必要なものだけ include する
class OrderNotifier
  include EmailNotifiable  # メールだけ使う
 
  def send_email(user, message)
    OrderMailer.notification(user, message).deliver_later
  end
end
 
class AlertNotifier
  include EmailNotifiable
  include SmsNotifiable    # 重要なアラートはSMSも送る
 
  def send_email(user, message)
    AlertMailer.urgent(user, message).deliver_later
  end
 
  def send_sms(user, message)
    SmsGateway.send(user.phone, message)
  end
end

D — Dependency Inversion Principle(依存性逆転の原則)

「具体に依存するな、抽象に依存せよ」——これが最も重要でテストに直結する原則。

# Before: 具体的な実装(SendGrid)に直接依存
class NotificationService
  def send_order_confirmation(user, order)
    # SendGridのAPIに直接依存している
    mail_body = {
      personalizations: [{
        to: [{ email: user.email }],
        subject: "ご注文ありがとうございます"
      }],
      from: { email: 'noreply@example.com' },
      content: [{ type: 'text/plain', value: "注文#{order.id}が完了しました" }]
    }
 
    sg = SendGrid::API.new(api_key: ENV['SENDGRID_API_KEY'])
    sg.client.mail._post(body: mail_body)
  end
end

問題: NotificationServiceのテストを書くとき、本物のSendGrid APIが呼ばれてしまう。テストするたびにメールが飛ぶ。テスト環境でSendGrid APIキーが必要になる。

# After: 抽象(インターフェース)に依存し、具体を注入する
class NotificationService
  # 依存性注入(DI): 具体的な実装をコンストラクタで受け取る
  def initialize(email_client: ActionMailer::Base)
    @email_client = email_client
  end
 
  def send_order_confirmation(user, order)
    # @email_clientが何者かを知らない(Railsのマイラーかもしれないし、テスト用のダブルかもしれない)
    @email_client.deliver_order_confirmation(user: user, order: order)
  end
end
 
# 本番環境: 本物のメーラーを使う(デフォルト引数なので指定不要)
NotificationService.new.send_order_confirmation(user, order)
 
# テスト用のダブル(偽物)を定義する
class FakeEmailClient
  attr_reader :sent_emails
 
  def initialize
    @sent_emails = []
  end
 
  def deliver_order_confirmation(user:, order:)
    @sent_emails << { user: user, order: order }
  end
end
 
# テスト: 偽物を注入してテストする
RSpec.describe NotificationService do
  it '注文確認メールを送る' do
    fake_client = FakeEmailClient.new
    # 偽物を注入(本物のメールは飛ばない)
    service = NotificationService.new(email_client: fake_client)
 
    user = build(:user)
    order = build(:order)
    service.send_order_confirmation(user, order)
 
    # 偽物に「メールが送られたか」を確認
    expect(fake_client.sent_emails).to include(
      hash_including(user: user, order: order)
    )
  end
end

INFO

依存性注入(DI)を使うと、テストで本物のメール送信・外部API呼び出しを起動せずに済みます。本番では本物を、テストでは偽物を注入できます。この「差し替え可能性」こそがRefactorabilityの核心です。

テスト駆動でRefactorabilityを確保する

テストがあると「変更しても壊れていないか」を自動で確認できる。これが変更への自信の源だ。

テストがない状態でのリファクタリング

# テストなしでのリファクタリング(危険)
def process_order(order_id, user, coupon_code)
  order = Order.find(order_id)
  # ... 30行の処理 ...
end
 
# メソッドを分割しようとする
def process_order(order_id, user, coupon_code)
  order = Order.find(order_id)
  apply_premium_discount(order, user)
  apply_coupon(order, coupon_code)
  complete_order(order, user)
end
 
# 「動くかな...?」「本番で試すしかない」
# → 変更が怖い → リファクタリングできない → 品質が下がり続ける

テストがある状態でのリファクタリング

# まずテストを書く
RSpec.describe OrderCreationService do
  let(:user) { create(:user, :premium) }
  let(:cart) { create(:cart, :with_items, total: 10_000) }
  subject(:service) { described_class.new(user: user, cart: cart) }
 
  describe '#call' do
    context '正常系' do
      it '注文を作成する' do
        order = service.call
        expect(order).to be_persisted
        expect(order.status).to eq('completed')
      end
 
      it 'プレミアム割引(20%)を適用する' do
        order = service.call
        expect(order.total).to eq(8_000)  # 10000 * 0.8
      end
 
      it '確認メールをキューに追加する' do
        expect { service.call }.to have_enqueued_mail(OrderMailer, :confirmation)
      end
    end
 
    context '在庫不足の場合' do
      before { cart.items.first.product.update!(stock: 0) }
 
      it '例外を発生させる' do
        expect { service.call }.to raise_error(InsufficientStockError)
      end
 
      it '注文レコードを作成しない(ロールバック)' do
        expect { service.call rescue nil }.not_to change(Order, :count)
      end
    end
 
    context 'クーポンコードが有効な場合' do
      let(:coupon) { create(:coupon, discount_amount: 500, code: 'SAVE500') }
      subject(:service) { described_class.new(user: user, cart: cart, coupon_code: coupon.code) }
 
      it 'クーポン割引を追加で適用する' do
        order = service.call
        # プレミアム20%引き(8000円) - クーポン500円 = 7500円
        expect(order.total).to eq(7_500)
      end
    end
  end
end

テストがあると、メソッドを分割しても「テストがグリーンかどうか」で正しく動いているかを確認できる。リファクタリングが安全になる。

テストの4フェーズパターン(AAA)

RSpec.describe DiscountService do
  it 'プレミアム会員に20%割引を適用する' do
    # Arrange(準備)— テストの前提条件を設定する
    user = build_stubbed(:user, plan: 'premium')
    order = build_stubbed(:order, total: 1_000)
    service = DiscountService.new(user)
 
    # Act(実行)— テスト対象を実行する
    service.apply_to(order)
 
    # Assert(検証)— 期待する結果を検証する
    expect(order.total).to eq(800)
    expect(order.discount_amount).to eq(200)
  end
end

変更容易性の指標: どこから改善すべきか

「どのファイルから改善すべきかわかりますか?」ユイが聞いた。

「変更頻度と複雑度を組み合わせて見るんだ。変更が多くて複雑なファイルが最も危険だ」

# 変更頻度が高いファイルを調べる(Churn分析)
git log --format=format: --name-only | \
  grep '\.rb$' | \
  sort | \
  uniq -c | \
  sort -rg | \
  head -20
 
# 出力例
# 89 app/models/order.rb              ← 頻繁に変更されている
# 67 app/services/discount_service.rb
# 45 app/controllers/orders_controller.rb
# 12 spec/models/order_spec.rb
# 循環的複雑度を測定する(RuboCop)
# .rubocop.yml
Metrics/CyclomaticComplexity:
  Max: 10
  Enabled: true
  Description: |
    条件分岐とループの数を計測。10超は理解が難しい。
    各分岐はテストケースを1つ追加することを意味する。
 
Metrics/MethodLength:
  Max: 15
  Enabled: true
 
Metrics/AbcSize:
  Max: 20
  Enabled: true
  Description: |
    Assignment(代入)、Branch(分岐)、Condition(条件)の複雑度。
    20超は要注意。
Loading diagram...

Refactorabilityチェックリスト

変更に強いコードのチェックリスト:

テスト関連:
□ テストカバレッジ80%以上(特にビジネスロジック)
□ 各テストが1つの振る舞いだけを検証している
□ テストが依存する外部サービスはモックまたはスタブを使っている

設計関連:
□ 1つのクラスが1つの責任だけを持つ(SRP)
□ 新機能追加時に既存コードを変えずに済む(OCP)
□ 依存は具体でなく抽象に向ける(DIP)

コード品質:
□ メソッドの長さが15行以内
□ 循環的複雑度(case/if の数)が10以下
□ 変更の影響範囲がテストで即座にわかる
□ 依存性注入でテストが書ける設計になっている

「SOLID原則は暗記するものじゃない」田中さんが言った。「コードを変えるたびに『これは変更しやすいか』と問いかける習慣が大事だ。問い続けることで、自然に良い設計の感覚が育っていく。次の章で、実際にRailsコードへの適用を見ていこう」

ユイは気づいた。「変更が怖い」という感覚は、設計の問題を教えてくれるサインだ。その感覚を無視して変更を恐れ続けるより、その感覚の根本原因(密結合・テスト不足)を解決する方が、長期的に見ればはるかに楽になる。

# Refactorabilityが高いコードの特徴
# → 変更したい部分がはっきりわかる(凝集度が高い)
# → 変更しても他の部分に影響しない(結合度が低い)
# → 変更後に壊れていないかすぐ確認できる(テストがある)
# → 変更の前後でコードが理解しやすい(可読性が高い)
 
# これが「3Rが揃っている状態」
# Readability → 変更すべき場所がわかる
# Reusability → 変更が1箇所で済む
# Refactorability → 変更した後のテストが通る