最初は本当に小さい変更です。
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 オーケストレーション
設計レビューで効く質問はこれです:
「これはエンティティの性質(ドメイン)か? それとも境界を跨ぐ手続き(調整)か?」
- ドメインロジック:不変条件・状態遷移・小さなふるまい → モデル近く
- オーケストレーション:決済、在庫、メール、外部API、リトライ、冪等性 → モデル外
モデル内で境界を跨ぐ調整をするとこうなります:
# 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
endskinny 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
end3) 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
end4) 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実行時間(複数回平均)
- 重要エンドポイントの p50/p95
- クエリ数 + 重い箇所の EXPLAIN ANALYZE
- (任意)allocation
RSpec(ノイズを減らすため複数回)
for i in {1..10}; do bundle exec rspec spec/services/place_order_spec.rb; donebenchmark-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!
endwrk(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!