mybook

Readability 実践 — Railsモデルの可読性改善

Fat Model問題との遭遇

「ユイさん、次はUserモデルを見てみよう」

田中さんが開いたファイルは1247行あった。エディタのスクロールバーがほとんど動かないほどの長さだ。

wc -l app/models/user.rb
# => 1247

「本の一章分だ」ユイが呟いた。

「そう。小説1章分のコードを、バグを修正するたびに『解読』しなければならない。これがFat Modelの問題だ」

# app/models/user.rb の構造(1247行の惨状)
class User < ApplicationRecord
  # ============== バリデーション(30行)==============
  validates :email, presence: true, uniqueness: true,
                    format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :name, presence: true, length: { minimum: 2, maximum: 50 }
  validates :phone, format: { with: /\A0\d{9,10}\z/ }, allow_blank: true
  # ... 他20個のバリデーション
 
  # ============== アソシエーション(25行)==============
  has_many :orders, dependent: :destroy
  has_many :reviews, dependent: :destroy
  has_many :addresses, dependent: :destroy
  has_many :payment_methods, dependent: :destroy
  has_many :loyalty_transactions
  # ... 他15個
 
  # ============== スコープ(40行)==============
  scope :premium, -> { where(plan: 'premium') }
  scope :active, -> { where(active: true) }
  scope :recent_signups, -> { where('created_at > ?', 1.month.ago) }
  scope :at_risk, -> { where('last_login_at < ?', 3.months.ago) }
  # ... 他20個
 
  # ============== コールバック(20行)==============
  before_save :normalize_email
  after_create :send_welcome_email
  after_update :sync_to_crm, if: :saved_change_to_email?
 
  # ============== 認証関連メソッド(150行)==============
  def authenticate(password); ...; end
  def generate_reset_token; ...; end
  def password_reset_expired?; ...; end
  def lock_account!; ...; end
  def unlock_account!; ...; end
  # ...
 
  # ============== プロフィール関連(100行)==============
  def full_name; "#{first_name} #{last_name}"; end
  def display_name; premium? ? "#{full_name} ★" : full_name; end
  def avatar_url; ...; end
  def bio_excerpt; ...; end
 
  # ============== ロイヤリティプログラム(200行)==============
  def loyalty_tier; ...; end
  def add_loyalty_points(amount); ...; end
  def redeem_loyalty_points(amount); ...; end
  def loyalty_points_expiring_soon; ...; end
  def calculate_tier_progress; ...; end
 
  # ============== 外部連携(150行)==============
  def sync_to_crm; ...; end
  def sync_to_mailchimp; ...; end
  def slack_notification(message); ...; end
 
  # ============== エクスポート(100行)==============
  def to_csv_row; ...; end
  def to_crm_payload; ...; end
  def to_pdf; ...; end
  def to_json_api; ...; end
 
  # ============== 分析・レポート(200行)==============
  def lifetime_value; ...; end
  def churn_risk_score; ...; end
  def monthly_spending(month); ...; end
  def product_preferences; ...; end
 
  # その他200行...
end

WARNING

Fat Model(肥大化したモデル)は可読性の大敵です。1000行を超えたモデルは、「どこに何があるか」がわからなくなり、修正が恐ろしくなります。新機能を追加するとき「どこに書けばいい?」という問いへの答えが「とりあえずUserモデルに」になってしまいます。これが更なる肥大化を招く悪循環です。

解剖: Userモデルの責任を分類する

「まず、このモデルが何をしているか書き出してみよう」田中さんが言った。

責任を分類すると、Userモデルは以下の6つの役割を担っていることがわかった。

Loading diagram...

「1つのクラスが6つの役割を担っている。だから1247行になる。だから読めない。だから変えるのが怖い。根本原因はここだ」

田中さんが図を指差した。「解決策は、この6つの責任を別々のファイルに切り出すこと。Railsには Concern という完璧な仕組みが用意されている」

Concernで関心事を分離する

Concernを使うと、「ある関心事(Concern)」に関するコードをモジュールとして切り出し、モデルに include することができる。

認証関連を切り出す

# app/models/concerns/user/authenticatable.rb
module User::Authenticatable
  extend ActiveSupport::Concern
 
  included do
    has_secure_password
 
    validates :email, presence: true, uniqueness: true,
                      format: { with: URI::MailTo::EMAIL_REGEXP }
 
    before_save :downcase_email
 
    # ロックアウトの設定
    MAXIMUM_FAILED_ATTEMPTS = 5
    LOCK_DURATION = 1.hour
  end
 
  # サインイン試行を記録する
  def record_sign_in_attempt(success:)
    if success
      update!(
        sign_in_count: sign_in_count + 1,
        last_sign_in_at: Time.current,
        failed_attempts: 0
      )
    else
      increment_failed_attempts
    end
  end
 
  def locked?
    locked_at.present? && locked_at > LOCK_DURATION.ago
  end
 
  def lock_account!
    update!(locked_at: Time.current)
  end
 
  def unlock_account!
    update!(locked_at: nil, failed_attempts: 0)
  end
 
  def generate_password_reset_token!
    update!(
      reset_password_token: SecureRandom.urlsafe_base64,
      reset_password_sent_at: Time.current
    )
    reset_password_token
  end
 
  def password_reset_expired?
    reset_password_sent_at < 2.hours.ago
  end
 
  private
 
  def downcase_email
    self.email = email.downcase.strip
  end
 
  def increment_failed_attempts
    new_count = failed_attempts + 1
    if new_count >= self.class::MAXIMUM_FAILED_ATTEMPTS
      lock_account!
    else
      update!(failed_attempts: new_count)
    end
  end
end

ロイヤリティプログラムを切り出す

# app/models/concerns/user/loyalty_program.rb
module User::LoyaltyProgram
  extend ActiveSupport::Concern
 
  included do
    has_many :loyalty_transactions, dependent: :destroy
 
    TIERS = {
      bronze: { min_points: 0,      discount_rate: 0.00, label: 'ブロンズ' },
      silver: { min_points: 1_000,  discount_rate: 0.05, label: 'シルバー' },
      gold:   { min_points: 5_000,  discount_rate: 0.10, label: 'ゴールド' },
      platinum: { min_points: 20_000, discount_rate: 0.15, label: 'プラチナ' }
    }.freeze
 
    POINTS_PER_YEN = 0.01  # 100円で1ポイント
    POINTS_EXPIRY_DAYS = 365
  end
 
  def loyalty_tier
    tier_key = TIERS.reverse_each.find do |_key, config|
      loyalty_points >= config[:min_points]
    end&.first
    tier_key || :bronze
  end
 
  def loyalty_tier_label
    TIERS[loyalty_tier][:label]
  end
 
  def loyalty_discount_rate
    TIERS[loyalty_tier][:discount_rate]
  end
 
  def add_loyalty_points(amount, reason:)
    points = (amount * POINTS_PER_YEN).floor
    return if points <= 0
 
    ActiveRecord::Base.transaction do
      increment!(:loyalty_points, points)
      loyalty_transactions.create!(
        points: points,
        reason: reason,
        expires_at: POINTS_EXPIRY_DAYS.days.from_now
      )
    end
    points
  end
 
  def redeem_loyalty_points(points)
    raise InsufficientPointsError if loyalty_points < points
 
    ActiveRecord::Base.transaction do
      decrement!(:loyalty_points, points)
      loyalty_transactions.create!(
        points: -points,
        reason: 'redemption'
      )
    end
    true
  end
 
  def points_expiring_soon(within: 30.days)
    loyalty_transactions.where(expires_at: Time.current..within.from_now)
                        .sum(:points)
  end
 
  def next_tier_info
    current_tier_config = TIERS[loyalty_tier]
    tiers_above = TIERS.select { |_k, v| v[:min_points] > current_tier_config[:min_points] }
    return nil if tiers_above.empty?
 
    next_tier_key, next_tier_config = tiers_above.min_by { |_k, v| v[:min_points] }
    {
      tier: next_tier_key,
      label: next_tier_config[:label],
      points_needed: next_tier_config[:min_points] - loyalty_points
    }
  end
end

外部連携を切り出す

# app/models/concerns/user/external_integrations.rb
module User::ExternalIntegrations
  extend ActiveSupport::Concern
 
  def sync_to_crm!
    return unless crm_sync_enabled?
 
    CrmService.upsert_contact(
      external_id: id,
      email: email,
      name: full_name,
      tier: loyalty_tier_label,
      joined_at: created_at.iso8601
    )
    update_column(:crm_synced_at, Time.current)
  end
 
  def sync_to_email_platform!
    EmailPlatformService.update_subscriber(
      email: email,
      properties: {
        name: full_name,
        tier: loyalty_tier_label,
        premium: premium?
      }
    )
  end
 
  def crm_synced?
    crm_synced_at.present?
  end
 
  private
 
  def crm_sync_enabled?
    ENV['CRM_ENABLED'] == 'true'
  end
end

エクスポートを切り出す

# app/models/concerns/user/exportable.rb
module User::Exportable
  extend ActiveSupport::Concern
 
  CSV_HEADERS = %w[id email name tier loyalty_points joined_at].freeze
 
  def to_csv_row
    [
      id,
      email,
      full_name,
      loyalty_tier_label,
      loyalty_points,
      created_at.strftime('%Y-%m-%d')
    ]
  end
 
  def to_crm_payload
    {
      external_id: id,
      email: email,
      name: full_name,
      tier: loyalty_tier_label,
      loyalty_points: loyalty_points,
      joined_at: created_at.iso8601,
      last_order_at: orders.maximum(:created_at)&.iso8601
    }
  end
 
  def to_json_api
    {
      id: id,
      type: 'users',
      attributes: {
        email: email,
        name: full_name,
        tier: loyalty_tier_label
      }
    }
  end
 
  class_methods do
    def export_to_csv(users)
      CSV.generate(headers: true) do |csv|
        csv << CSV_HEADERS
        users.each { |user| csv << user.to_csv_row }
      end
    end
  end
end

スリムになったUserモデル

# app/models/user.rb (改善後: 50行)
class User < ApplicationRecord
  include User::Authenticatable
  include User::LoyaltyProgram
  include User::ExternalIntegrations
  include User::Exportable
 
  # コアのアソシエーション(本当にUserの核となるもの)
  has_many :orders, dependent: :destroy
  has_many :reviews, dependent: :destroy
  has_many :addresses, dependent: :destroy
 
  # コアのスコープ(頻繁に使うもの)
  scope :active, -> { where(active: true) }
  scope :premium, -> { where(plan: :premium) }
 
  # コアのメソッド(どのConcernにも属さない基本的なもの)
  def full_name
    "#{first_name} #{last_name}"
  end
 
  def display_name
    premium? ? "#{full_name} ★" : full_name
  end
 
  def premium?
    plan == 'premium'
  end
end

User モデルは50行に収まった。

INFO

Concernの分割基準は「関心事(何について?)」です。「認証について」「ロイヤリティについて」「外部連携について」——と問いかけて、答えごとにファイルを分ける。ファイルを開かなくても、include の行を読むだけでUserモデルの機能が把握できます。

ディレクトリ構造で意図を伝える

ファイルの構造自体がドキュメントになる。

app/models/
├── user.rb                               # コアのみ(50行)
└── concerns/
    └── user/
        ├── authenticatable.rb            # 認証(80行)
        ├── loyalty_program.rb            # ポイント・特典(100行)
        ├── external_integrations.rb      # CRM・メール連携(60行)
        └── exportable.rb                 # CSV・JSON出力(50行)

ファイルを開かなくても、ディレクトリを見るだけで「Userモデルはこれらの機能を持っている」とわかる。新メンバーがオンボーディングするときも、このディレクトリを見れば「認証はここ、ロイヤリティはここ」とすぐに探せる。

# Before: 新機能「サブスクリプション管理」を追加するとき
# → 「とりあえずuser.rbに書くか...」(1247行→1350行に)
 
# After: 同じ作業
# → concerns/user/subscription.rb を作成して include するだけ
# → user.rb は変更なし(関心事が明確に分離される)

型ヒントとドキュメント化

Rubyは動的型付け言語だが、型コメントを書くことで引数の意図が明確になる。

# @param で引数の型と意図を明示する
class OrderService
  # 注文を作成する
  #
  # @param user [User] 注文を行うユーザー
  # @param cart_items [Array<CartItem>] カートのアイテム一覧
  # @param coupon_code [String, nil] クーポンコード(任意)
  # @return [Order] 作成された注文
  # @raise [InsufficientStockError] 在庫不足の場合
  # @raise [InvalidCouponError] クーポンが無効な場合
  def create_order(user, cart_items, coupon_code: nil)
    # ...
  end
end
# SorbetやRBS(Ruby 3.0+)で型シグネチャを定義する
# typed: strict の場合
 
# user.rbs
class User
  def full_name: () -> String
  def premium?: () -> bool
  def add_loyalty_points: (Integer, reason: String) -> Integer
  def loyalty_tier: () -> Symbol
end

型シグネチャがあると、IDEの補完が効き、型エラーを実行前に発見できる。

Before/After 比較: 数字で見る改善効果

改善の全体像を振り返ろう。

# Before: 読むたびに全体を把握しなければならない
class User < ApplicationRecord
  # 1247行の巨大クラス
  # 認証もポイントもエクスポートも全部ここにある
  # 新機能追加のたびに全体を把握しなければならない
  # メソッドを探すのにCtrl+Fが必要で、見つかっても前後の文脈が掴みにくい
end
 
# After: ファイルを見るだけで構造がわかる
class User < ApplicationRecord
  include User::Authenticatable         # ← 認証はここ
  include User::LoyaltyProgram          # ← ポイントはここ
  include User::ExternalIntegrations    # ← 外部連携はここ
  include User::Exportable              # ← エクスポートはここ
 
  # コアのみ: 50行
end
指標BeforeAfter
user.rb の行数1247行50行
メソッドを探す時間2〜5分数秒(ファイル名で判断)
新機能追加の場所「とりあえずuser.rb」明確(新Concernを作成)
テストの書きやすさ困難(全依存をロード必要)Concernごとに独立してテスト可能
新メンバーの理解時間1日以上数時間(ディレクトリ構造が地図)

Concernのテストを独立させる

Concernの最大のメリットの1つは、テストを独立して書けること。

# spec/models/concerns/user/loyalty_program_spec.rb
RSpec.describe User::LoyaltyProgram do
  # テスト用のダミークラスに include してテスト
  let(:user_class) do
    Class.new do
      include User::LoyaltyProgram
 
      attr_accessor :loyalty_points, :id
      def initialize
        @loyalty_points = 0
        @id = 1
      end
    end
  end
 
  let(:user) { user_class.new }
 
  describe '#loyalty_tier' do
    context '0ポイントの場合' do
      it 'ブロンズを返す' do
        expect(user.loyalty_tier).to eq(:bronze)
      end
    end
 
    context '1000ポイント以上の場合' do
      before { user.loyalty_points = 1000 }
 
      it 'シルバーを返す' do
        expect(user.loyalty_tier).to eq(:silver)
      end
    end
 
    context '5000ポイント以上の場合' do
      before { user.loyalty_points = 5000 }
 
      it 'ゴールドを返す' do
        expect(user.loyalty_tier).to eq(:gold)
      end
    end
  end
 
  describe '#next_tier_info' do
    context 'シルバーの場合' do
      before { user.loyalty_points = 2000 }
 
      it '次のゴールドまでの情報を返す' do
        info = user.next_tier_info
        expect(info[:tier]).to eq(:gold)
        expect(info[:points_needed]).to eq(3000)  # 5000 - 2000
      end
    end
  end
end

このテストは、UserモデルのDBを必要とせず、Concernのロジックだけをテストできる。実行が速く、他の部分に影響されない。

実践: Fat Modelを解消するステップ

ユイはメモした。

Fat Model解消ステップ:

1. 現在のモデルの全メソッドを一覧にする
2. 「何について(関心事)」で分類する
   例: 認証について / ポイントについて / エクスポートについて
3. 関心事ごとに concerns/[model]/[concern].rb を作成する
4. 元のモデルから切り出して include する
5. テストを Concern ごとに作成する
6. モデル本体はコアのアソシエーションとスコープだけにする

判断基準(切り出すべきか?):
- 「この機能はUserの『本質』か、それとも付加的な機能か?」
- 付加的な機能(認証・ポイント・外部連携)→ Concern に切り出す
- Userの本質(名前・メール・プラン)→ user.rb に残す

INFO

Concernによる分離は、ディレクトリ構造をドキュメントにする設計です。include の一覧を読むだけで、そのモデルが持つ機能が把握できます。1000行の巨大ファイルを読み解くより、50行のモデルと5つの80行Concernを読む方が、総読書量は同じでも「理解のしやすさ」が格段に違います。

「次は再利用性だ」と田中さんが言った。「読みやすくなったコードを、どう使い回すかを考えよう。DRYの話をしよう」

Concernで整理されたUserモデルは、単に「見た目がきれいになった」だけではない。テストしやすくなった。新機能を追加する場所が明確になった。チームメンバーがコードを読むときの認知負荷が下がった。可読性の改善は、開発速度の改善に直結する。

付録: Rails Concern vs Plain Module

Railsの ActiveSupport::Concern と Plain Ruby の Module の使い分け。

# Plain Ruby Module: シンプルなミックスイン
module Formattable
  def formatted_price(price)
#{price.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse}"
  end
end
 
# ActiveSupport::Concern: Rails の文脈が必要な場合
module User::LoyaltyProgram
  extend ActiveSupport::Concern
 
  included do
    # has_many や scope など ActiveRecord のマクロが使える
    has_many :loyalty_transactions
    scope :gold_members, -> { where('loyalty_points >= ?', 5000) }
  end
 
  class_methods do
    # self.xxxx メソッドが追加できる
    def top_earners(limit: 10)
      order(loyalty_points: :desc).limit(limit)
    end
  end
 
  # インスタンスメソッド
  def loyalty_tier
    # ...
  end
end

使い分け基準:

  • has_many, scope, validates などのActiveRecordマクロが必要 → ActiveSupport::Concern
  • 単純なユーティリティメソッド群 → Plain Ruby Module