Rails「Fat Model」症候群:モデルをSkinnyに保つ(宗教戦争しない版)

Rails「Fat Model」症候群:モデルをSkinnyに保つ(宗教戦争しない版)

· · 5分で読めます

Advertisement

最初は本当に小さい変更です。

User.rb にバリデーションを1つ。
次にコールバック。
次にパスワードリセット。
次にオンボーディング。
次にPDF請求書(なぜ?)。
次に“ちょっとした外部連携”。

そして気づけば、モデルはCTOより多くの責務を抱えています。

# app/models/user.rb(太る過程)
class User < ApplicationRecord
  validates :email, presence: true, uniqueness: true
  before_save :normalize_email
  
  def send_password_reset!
    update!(reset_token: SecureRandom.hex(32), reset_sent_at: Time.current)
    UserMailer.password_reset(self).deliver_later
  end
  
  def complete_onboarding!(params)
    transaction do
      update!(params.slice(:name, :timezone))
      Profile.create!(user: self, bio: params[:bio])
      Analytics.track("onboarding_completed", user_id: id)
    end
  end
  
  def generate_invoice_pdf!(invoice_id)
    invoice = invoices.find(invoice_id)
    pdf = InvoicePdfRenderer.new(invoice).render
    InvoiceStorage.upload("invoices/#{invoice.id}.pdf", pdf)
  end
  
  private
  
  def normalize_email = self.email = email.to_s.strip.downcase
  ...

Railsのモデルを開いて 「このファイル、天気があるな…」 と思ったら、それは Rails fat model(モデル肥大化) です。
「Fat Model, Skinny Controller」は、モデルを太らせ放題にする免罪符ではなく、狙いは ドメインの凝集です。


ドメインロジック vs オーケストレーション

設計レビューで効く質問はこれです:

「これはエンティティの性質(ドメイン)か? それとも境界を跨ぐ手続き(調整)か?」

モデル内で境界を跨ぐ調整をするとこうなります:

# app/models/order.rb(調整が混ざっている)
class Order < ApplicationRecord
  def checkout!(token)
    PaymentGateway.charge(total_cents, token)
    items.each { |i| i.product.decrement!(:stock) }
    OrderMailer.confirmation(self).deliver_later
  end
end

skinny models Rails のゴールは「モデルを小さくする」ではなく、責務が一貫したモデルを保つことです。


モデルを一貫させるパターン(各セクションに短いスニペット)

1) Service Object(業務アクションの入口)

使いどころ: 複数モデル+副作用+外部連携を含む業務フロー

入口が1つになるイメージ:

result = PlaceOrder.new(user:, cart_items:, payment_token: token).call
return render(:new, status: :unprocessable_entity) unless result.ok?

Result+transaction+エラーコード

# app/services/place_order.rb
class PlaceOrder
  Result = Data.define(:ok?, :value, :error, :code) do
    def self.ok(value) = new(ok?: true, value: value, error: nil, code: nil)
    def self.fail(error, code: :unknown) = new(ok?: false, value: nil, error: error, code: code)
  end
  
  def initialize(user:, cart_items:, payment_token:)
    @user, @items, @token = user, cart_items, payment_token
  end
  
  def call
    ActiveRecord::Base.transaction do
      order = @user.orders.create!(items: @items)
      PaymentGateway.charge(
        amount: order.total_cents,
        token: @token,
        idempotency_key: order.id
      )
      @items.each do |item|
        item.product.lock!
        item.product.decrement!(:stock)
      end
      OrderMailer.confirmation(@user, order).deliver_later
      Result.ok(order)
    end
  rescue PaymentGateway::Declined => e
    Result.fail(e.message, code: :payment_declined)
  rescue ActiveRecord::RecordInvalid => e
    Result.fail(e.record.errors.full_messages.to_sentence, code: :validation_failed)
  rescue => e
    Rails.logger.error("[PlaceOrder] #{e.class}: #{e.message}")
    Result.fail("Unexpected error", code: :unknown)
  end
end

呼び出し側(Controller)は退屈でOK:

def create
  result = PlaceOrder.new(user: current_user, cart_items: cart_items, payment_token: params[:token]).call
  result.ok? ? redirect_to(order_path(result.value), notice: "Order placed!") :
               render(:new, status: :unprocessable_entity, alert: result.error)
end

2) Concern(モデル内の整理)

使いどころ: 1文で説明できるまとまり(共有したい場合も)

class User < ApplicationRecord
  include NormalizesEmail
end
# app/models/concerns/normalizes_email.rb
module NormalizesEmail
  extend ActiveSupport::Concern
  included { before_save :normalize_email }
  private
  def normalize_email = self.email = email.to_s.strip.downcase
end

3) Query Object(検索と絞り込みの分離)

Controllerはこうなる:

@products = ProductFilteringQuery.new(Product.visible_to(current_user)).call(params)
# app/queries/product_filtering_query.rb
class ProductFilteringQuery
  def initialize(relation = Product.all) = @relation = relation
  
  def call(params = {})
    s = @relation
    s = params[:status].present? ? s.where(status: params[:status]) : s.published
    s = params[:max_price].present? ? s.where("price_cents <= ?", params[:max_price].to_i) : s
    s = params[:category_id].present? ? s.joins(:categorizations).where(categorizations: { category_id: params[:category_id] }) : s
    s = case params[:sort]
        when "price_low"  then s.order(price_cents: :asc)
        when "price_high" then s.order(price_cents: :desc)
        else s.order(created_at: :desc)
        end
    s.limit(params.fetch(:limit, 10))
  end
end

4) Value Object(頭のある属性)

user.address.to_s
# app/value_objects/address.rb
class Address
  def initialize(zip:, prefecture:, city:, line1:)
    @zip, @prefecture, @city, @line1 = zip, prefecture, city, line1
  end
  
  def to_s
    "#{@zip} #{@prefecture}#{@city}#{@line1}"
  end
end
# app/models/user.rb
def address = Address.new(zip: zip, prefecture: prefecture, city: city, line1: line1)

5) Form Object(文脈専用の検証)

form = SignupForm.new(signup_params)
return render(:new, status: :unprocessable_entity) unless form.valid?
user = form.save!
# app/forms/signup_form.rb
class SignupForm
  include ActiveModel::Model
  
  attr_accessor :email, :password, :nickname
  validates :email, :password, :nickname, presence: true
  
  def save!
    ActiveRecord::Base.transaction do
      user = User.create!(email: email, password: password)
      Profile.create!(user: user, nickname: nickname)
      user
    end
  end
end

推奨配置

app/services/         # ビジネスアクション(オーケストレーション)
app/queries/          # フィルタリング/検索条件の組み立て
app/forms/            # フロー固有のバリデーション+複数モデルへの書き込み
app/value_objects/    # 純粋な Ruby のドメイン・プリミティブ
app/models/concerns/  # モデル横断の共通振る舞い(関心事の切り出し)

ベンチ手順

最低限これを出すと説得力が上がります:

RSpec(ノイズを減らすため複数回)

for i in {1..10}; do bundle exec rspec spec/services/place_order_spec.rb; done

benchmark-ips(ロジック単体)

# script/bench/place_order_ips.rb
require "benchmark/ips"
require_relative "../../config/environment"
user  = User.first
items = CartItem.limit(5).to_a
token = "tok_test"
Benchmark.ips do |x|
  x.report("PlaceOrder#call") { PlaceOrder.new(user:, cart_items: items, payment_token: token).call }
  x.compare!
end

wrk(E2E)

wrk -t4 -c50 -d30s https://yourapp.example.com/products

結果と一緒に書く前提:

- Rails/Ruby/DBバージョン
- データ件数と分布
- warm-upの有無
- 実行回数とばらつき
- p50/p95 + error rate + query count

最後に:残すもの/外に出すもの

モデルに残す(エンティティのルール):

def cancelable? = status.in?(%w[pending authorized])

モデル外へ(境界を跨ぐ手続き・複雑検索):

PlaceOrder.new(...).call
ProductFilteringQuery.new(...).call(params)
SignupForm.new(...).save!
Advertisement
#rails#fat#model#skinny#モデル肥大化
Advertisement