Reusability — 再利用可能な設計
DRYとWET: 複製の罠
「ユイさん、このコードを見てほしい」
田中さんが開いたのは、3つのコントローラだった。内容を読み始めてすぐ、ユイは「これ、同じだ」と気づいた。
# app/controllers/orders_controller.rb
def create
@order = Order.new(order_params)
@order.user = current_user
# プレミアム割引
if current_user.premium?
@order.total *= 0.8
end
if @order.save
OrderMailer.confirmation(current_user.email, @order).deliver_later
redirect_to @order
end
end# app/controllers/subscriptions_controller.rb
def create
@subscription = Subscription.new(subscription_params)
@subscription.user = current_user
# プレミアム割引(同じロジックがまた出てきた)
if current_user.premium?
@subscription.price *= 0.8 # ← orderの場合はtotal、subscriptionはprice
end
if @subscription.save
SubscriptionMailer.confirmation(current_user.email, @subscription).deliver_later
redirect_to @subscription
end
end# app/controllers/api/v1/orders_controller.rb
def create
order = Order.new(order_params)
order.user = current_user
# またプレミアム割引...
if current_user.premium?
order.total *= 0.8 # ← 全く同じ
end
# API版は少し違うが、割引ロジックは同じ
if order.save
render json: order
end
end「プレミアム割引のロジックが3箇所に散らばっている」
「これをWETなコードという」田中さんが言った。
WET = Write Everything Twice(何でも2回書く)——実際には3回書いているが、WET のシャレが定着している。 DRY = Don't Repeat Yourself(同じことを繰り返すな)——Railsが強く推奨する設計原則。
WARNING
WETなコードは「割引率が変わった時」に破滅します。3箇所を全部変えなければならず、1箇所でも変え忘ればバグになります。3箇所がファイルを跨いでいると、変え忘れに気づくのは本番障害が起きた後かもしれません。これが「技術的負債の利子」の正体です。
なぜ複製が生まれるのか
WETなコードはなぜ生まれるのか。ユイは田中さんに聞いた。
「いくつかの理由がある」田中さんが答えた。
理由1: 締め切りプレッシャー 「とりあえず動かす」ために、既存のコードをコピー&ペーストして変数名だけ変える。「後でまとめる」と思うが、その「後で」は来ない。
理由2: 全体が見えていない コードベースを完全に把握している人が少ない場合、「既にどこかで解決されているか」を確認せず、自分で書いてしまう。
理由3: 抽象化が怖い 「共通化すると依存関係が増える」「後で変更が難しくなるかも」という誤った恐れから、複製を選ぶ。
# WETが生まれる典型的なシナリオ
# Step 1: 最初の実装(orders_controller.rb)
if current_user.premium?
@order.total *= 0.8
end
# Step 2: サブスクリプション追加(「似てるからコピーしよう」)
if current_user.premium?
@subscription.price *= 0.8 # total → price に変えただけ
end
# Step 3: API追加(「またコピーしよう」)
if current_user.premium?
order.total *= 0.8 # また同じ
end
# Step 4: 割引率が20%から25%に変更になった...
# どこを変えればいい?検索すると0.8が3箇所...
# 1箇所変え忘れた → 本番バグ → 深夜対応DRY原則の適用: 段階的なアプローチ
DRYの適用は段階的に行うのが良い。急に複雑な抽象化をするより、段階を踏んで改善する。
レベル1: 定数で重複を排除する
最も簡単なDRY化。マジックナンバー(意味不明な数値)を定数に切り出す。
# Before: 0.8が3箇所に散らばる(何の意味か不明)
@order.total *= 0.8
@subscription.price *= 0.8
order.total *= 0.8
# After: 定数で意図と値を1箇所に集約
# 定数の場所: config/initializers/pricing_constants.rb
module PricingConstants
# プレミアム会員の割引率(20%引き → 80%の価格)
PREMIUM_DISCOUNT_MULTIPLIER = 0.8
# 割引率として表現する場合
PREMIUM_DISCOUNT_RATE = 0.20
end
# 使用箇所
@order.total *= PricingConstants::PREMIUM_DISCOUNT_MULTIPLIER定数化の効果:
- 「0.8」が「プレミアム割引係数(20%引き)」であることが明確になる
- 割引率を変更するとき、1箇所だけ変えれば良い
- コードを読む人が数値の意味を推測する必要がなくなる
レベル2: メソッドで重複を排除する
同じロジックをメソッドとして切り出す。
# app/models/concerns/priceable.rb
module Priceable
extend ActiveSupport::Concern
PREMIUM_DISCOUNT_RATE = 0.20
def apply_premium_discount_for(user)
return unless user.premium?
discount = (price * PREMIUM_DISCOUNT_RATE).round(2)
self.price = price - discount
self.discount_amount = discount
end
# priceメソッドはinclude先のモデルが定義
# OrderはOrderモデルのtotalをpriceとして使う場合は:
def price
total
end
def price=(value)
self.total = value
end
end
class Order < ApplicationRecord
include Priceable
end
class Subscription < ApplicationRecord
include Priceable
end
# 使用例(コントローラがシンプルになる)
@order.apply_premium_discount_for(current_user)
@subscription.apply_premium_discount_for(current_user)レベル3: Service Objectで複雑なロジックを抽出する
ビジネスロジックが複雑な場合、Service Objectに切り出す。これが最も強力なDRY化手法だ。
# app/services/discount_service.rb
class DiscountService
PREMIUM_DISCOUNT = 0.20
COUPON_DISCOUNT_LIMIT = 10_000 # クーポン割引の上限額
def initialize(user)
@user = user
end
# 価格オブジェクト(OrderやSubscription)に割引を適用する
def apply_to(priceable)
original_price = priceable.price
discount_amount = calculate_total_discount(original_price)
priceable.price = original_price - discount_amount
priceable.discount_amount = discount_amount
priceable
end
# 価格から割引額だけを計算する(適用はしない)
def calculate_discount_for(price)
calculate_total_discount(price)
end
# どんな割引が適用されるかの内訳を返す
def discount_breakdown_for(price)
premium_discount = @user.premium? ? (price * PREMIUM_DISCOUNT).round(2) : 0
{
premium_discount: premium_discount,
total_discount: premium_discount
}
end
private
def calculate_total_discount(price)
return 0 unless @user.premium?
(price * PREMIUM_DISCOUNT).round(2)
end
end
# 使用例(全コントローラで同じように使える)
discount_service = DiscountService.new(current_user)
discount_service.apply_to(@order)
discount_service.apply_to(@subscription)# Before: 各コントローラが割引ロジックを直接持つ
# orders_controller.rb
if current_user.premium?
@order.total *= 0.8
end
# After: Service Objectに委譲する(コントローラは3行→1行)
# orders_controller.rb
DiscountService.new(current_user).apply_to(@order)Service Object: 設計パターン
Service ObjectはRailsで最もよく使われるデザインパターンの1つだ。
Service Objectの命名規則
# 命名パターン: [動詞][名詞]Service
OrderCreationService # 注文の作成
PaymentProcessingService # 支払いの処理
UserRegistrationService # ユーザー登録
RefundCalculationService # 返金額の計算
InvoiceGenerationService # 請求書の生成
# call メソッドを公開APIにする(慣例)
class OrderCreationService
def call
# 1つの操作を実行する
end
end
# 呼び出し方
service = OrderCreationService.new(user: user, cart: cart)
result = service.call良いService Objectの条件
# 悪いService Object例1: 何でも詰め込む(God Service)
class OrderService
def process(order, user, coupon, notify: true, sync_crm: true, update_inventory: true)
# 200行の処理...
# 注文処理・割引・クーポン・在庫・CRM・メール全部やる
end
end
# 悪いService Object例2: 責任が不明確(抽象的すぎる名前)
class ProcessingService
def process(something)
# somethingって何?
end
end
# 良いService Object: 単一の操作に集中する
class OrderCreationService
def initialize(user:, cart:, coupon_code: nil)
@user = user
@cart = cart
@coupon_code = coupon_code
end
def call
ActiveRecord::Base.transaction do
order = build_order
apply_discounts(order)
order.save!
order
end
end
private
def build_order
Order.new(
user: @user,
items: @cart.items,
subtotal: @cart.total
)
end
def apply_discounts(order)
DiscountService.new(@user).apply_to(order)
CouponService.new(@coupon_code).apply_to(order) if @coupon_code.present?
end
endINFO
Service Objectは call メソッド1つだけを公開するのが慣例です。「このサービスは何をするか」が .call という1単語で伝わります。複数の公開メソッドが必要になった場合は、Service Objectを分割する合図です。
モジュール抽出: 横断的関心事を共通化する
複数のモデルに共通する「横断的関心事」はモジュールで共通化できる。これは第3章のConcernと同じ考え方だ。
全文検索を共通化する
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
# クラスメソッドとして full_text_search を追加
scope :search, ->(query) {
sanitized = query.to_s.strip
return none if sanitized.blank?
# PostgreSQLのILIKEで大文字小文字を無視して検索
where(
"name ILIKE :q OR description ILIKE :q",
q: "%#{sanitized}%"
)
}
end
class_methods do
def search_with_relevance(query)
search(query).limit(20)
end
end
end
# Product と Article の両方に組み込む
class Product < ApplicationRecord
include Searchable
# Product.search("Ruby") が使えるようになる
end
class Article < ApplicationRecord
include Searchable
# Article.search("Rails") が使えるようになる
endソフトデリートを共通化する
「削除」が物理削除ではなく論理削除(deleted_atを設定する)のパターン。
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
# デフォルトで削除済みを除外する
default_scope { where(deleted_at: nil) }
scope :only_deleted, -> { unscope(where: :deleted_at).where.not(deleted_at: nil) }
scope :with_deleted, -> { unscope(where: :deleted_at) }
end
# ソフトデリート実行
def soft_delete!
update!(deleted_at: Time.current)
end
# 復元
def restore!
update!(deleted_at: nil)
end
def deleted?
deleted_at.present?
end
class_methods do
# deleted_at が nil でないレコードも含めてFindする
def find_with_deleted(id)
unscope(where: :deleted_at).find(id)
end
end
end
# 使用例
class Product < ApplicationRecord
include Searchable
include SoftDeletable
end
product = Product.find(1)
product.soft_delete! # deleted_at = 現在時刻
product.deleted? # => true
Product.all # deleted_atがnilのみ(defaultスコープ)
Product.only_deleted # deleted_atがあるもののみ
Product.with_deleted # すべて(削除済み含む)
product.restore! # deleted_at = nil に戻すタイムスタンプをリッチにする
# app/models/concerns/trackable.rb
module Trackable
extend ActiveSupport::Concern
included do
before_create :set_creator
before_update :set_updater
end
def created_by_name
User.find_by(id: created_by_id)&.full_name || 'Unknown'
end
def last_updated_by_name
User.find_by(id: updated_by_id)&.full_name || 'Unknown'
end
private
def set_creator
self.created_by_id = Current.user&.id
end
def set_updater
self.updated_by_id = Current.user&.id
end
end再利用性の判断基準: 3回ルール
「どこから抽出すべきか迷う時はどうすれば?」ユイが聞いた。
「3回ルールだ」田中さんが答えた。「同じコードが3箇所に出てきたら抽出を考える。2箇所なら様子を見る。1箇所なら絶対に抽出しない」
# 1箇所だけ: 抽出しない(YAGNIの原則)
# 将来使われるかもしれないからと抽象化するのは過剰エンジニアリング
# 2箇所: 観察する
# 「これは偶然似ているだけか、本当に同じロジックか」を見極める期間
# 3箇所以上: 抽出する
# もう偶然ではない、共通化する価値がある
# 判断を助ける問いかけ
# 1. このロジックが変わる可能性はあるか?(割引率の変更など)
# 2. 複数の場所で使われているか(または使われる見込みか)?
# 3. 抽出することで可読性が上がるか(名前をつける価値があるか)?
# → すべてYesなら抽出する価値があるWARNING
DRYを適用しすぎると、逆に可読性が落ちることがあります。「3回ルール」を守り、過度な抽象化(YAGNI: You Aren't Gonna Need It)を避けましょう。また「偶然似ているコード」と「本質的に同じロジック」を見分けることも大切です。見かけが似ていても異なる概念なら、無理にまとめると変更時に困ります。
再利用性の具体的な成果
Before/Afterを比較してみよう。
# Before: 同じ割引ロジックが3コントローラに散在
# orders_controller.rb 行23: if current_user.premium?; @order.total *= 0.8; end
# subscriptions_controller.rb 行31: if current_user.premium?; @subscription.price *= 0.8; end
# api/v1/orders_controller.rb 行18: if current_user.premium?; order.total *= 0.8; end
# After: DiscountServiceに集約、コントローラはシンプルに
# orders_controller.rb: DiscountService.new(current_user).apply_to(@order)
# subscriptions_controller.rb: DiscountService.new(current_user).apply_to(@subscription)
# api/v1/orders_controller.rb: DiscountService.new(current_user).apply_to(order)割引率が変わっても DiscountService の1箇所だけ変えれば済む。
実際に起きた変更シナリオ
# シナリオ: 「プレミアム割引を20%から25%に変更してほしい」という依頼
# Before: 3箇所を修正(見つからないと本番バグ)
# orders_controller.rb 行23: 0.8 → 0.75
# subscriptions_controller.rb 行31: 0.8 → 0.75
# api/v1/orders_controller.rb 行18: 0.8 → 0.75
# git grep "0.8" → 他にも8箇所あった...全部変えたか?
# After: 1箇所だけ修正(見つかる保証あり)
# discount_service.rb 行3: PREMIUM_DISCOUNT = 0.20 → 0.25
# テストを実行 → グリーン → デプロイ
# かかった時間: 5分ユイのまとめ
「再利用性は、単なるコードの節約じゃないんですね」ユイが言った。「変更が1箇所で済むということは、バグも1箇所で直せる。変更への自信が持てる」
「そう。そして再利用可能なコードを作るには、まず可読性が高くないといけない。名前がついていない処理は、再利用もできない。Readabilityなくして、Reusabilityなし。次はいよいよ最後のR、Refactorabilityだ。変更が怖くない設計を学ぼう」
# 3Rの連鎖
# Readability → コードに名前がつく → 意図が明確になる
# Reusability → 名前のついたコードが再利用できる → 重複がなくなる
# Refactorability → 重複のないコードが変更しやすくなる → テストが書きやすい
# → 変更が怖くない → 継続的な改善が可能になる3つのRは、バラバラに存在するのではなく、互いを支え合っている。可読性が再利用性を支え、再利用性がリファクタリング可能性を支える。このループが回り始めると、コードは時間と共に良くなっていく。