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このコードの問題は何か?
OrderがProduct、User、AwsSnsClient、SendGridに直接依存している- 変更の影響範囲が見えない(在庫ロジックを変えようとすると、SNS、ポイント、メールも巻き込まれる)
- テストが書きにくい(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「コンビニ払い」を追加するとき、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 は持たない(契約に含めない)
endI — 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
endD — 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
endINFO
依存性注入(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超は要注意。Refactorabilityチェックリスト
変更に強いコードのチェックリスト:
テスト関連:
□ テストカバレッジ80%以上(特にビジネスロジック)
□ 各テストが1つの振る舞いだけを検証している
□ テストが依存する外部サービスはモックまたはスタブを使っている
設計関連:
□ 1つのクラスが1つの責任だけを持つ(SRP)
□ 新機能追加時に既存コードを変えずに済む(OCP)
□ 依存は具体でなく抽象に向ける(DIP)
コード品質:
□ メソッドの長さが15行以内
□ 循環的複雑度(case/if の数)が10以下
□ 変更の影響範囲がテストで即座にわかる
□ 依存性注入でテストが書ける設計になっている
「SOLID原則は暗記するものじゃない」田中さんが言った。「コードを変えるたびに『これは変更しやすいか』と問いかける習慣が大事だ。問い続けることで、自然に良い設計の感覚が育っていく。次の章で、実際にRailsコードへの適用を見ていこう」
ユイは気づいた。「変更が怖い」という感覚は、設計の問題を教えてくれるサインだ。その感覚を無視して変更を恐れ続けるより、その感覚の根本原因(密結合・テスト不足)を解決する方が、長期的に見ればはるかに楽になる。
# Refactorabilityが高いコードの特徴
# → 変更したい部分がはっきりわかる(凝集度が高い)
# → 変更しても他の部分に影響しない(結合度が低い)
# → 変更後に壊れていないかすぐ確認できる(テストがある)
# → 変更の前後でコードが理解しやすい(可読性が高い)
# これが「3Rが揃っている状態」
# Readability → 変更すべき場所がわかる
# Reusability → 変更が1箇所で済む
# Refactorability → 変更した後のテストが通る