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行...
endWARNING
Fat Model(肥大化したモデル)は可読性の大敵です。1000行を超えたモデルは、「どこに何があるか」がわからなくなり、修正が恐ろしくなります。新機能を追加するとき「どこに書けばいい?」という問いへの答えが「とりあえずUserモデルに」になってしまいます。これが更なる肥大化を招く悪循環です。
解剖: Userモデルの責任を分類する
「まず、このモデルが何をしているか書き出してみよう」田中さんが言った。
責任を分類すると、Userモデルは以下の6つの役割を担っていることがわかった。
「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
endUser モデルは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| 指標 | Before | After |
|---|---|---|
| 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