Rails パターン集
モダンなRuby on Railsアプリケーション(Rails 7.1+ および 8.x)向けのフレームワークパターン集です。Railsは設計思想がはっきりしているフレームワークですが、ここではモデル数50を超えても保守しやすいアプリのためにコミュニティが合意したパターンをまとめています。このスキルは「どのように実装するか(How)」に焦点を当てています。「何を」「いつ」(どのパターンを選ぶべきか)については、Rubyパターンルールを参照してください。このリポジトリ内の rules/ruby/patterns.md (インストール先は rules/ecc/ruby/patterns.md)です。
アクティベートすべきタイミング
- Railsアプリケーションを構築するとき(フルスタック、API専用、ハイブリッド)
app/またはconfig/に影響するPRをレビューするとき- モデル、コントローラ、サービス、ジョブを生成するとき
- コントローラのアクションが約10行を超えたとき
- モデルファイルが約200行を超えたとき
- コントローラやビューでActive Recordのクエリが出現し始めたとき
中核となる概念
ディレクトリ規約
Railsアプリは予測可能な構造に従います。ディレクトリは計画的に追加し、不用意に追加してはいけません。
app/
models/ ActiveRecordモデル。データに近い永続化とドメインロジック。
controllers/ HTTPリクエスト処理。薄いオーケストレーションのみ。
views/ ERBテンプレート。ビジネスロジックは含めない。
components/ ViewComponentクラス。テストが必要なビューロジック。
services/ サービスオブジェクト。複数ステップのビジネス操作。
forms/ フォームオブジェクト。複数モデルにまたがる複雑なフォーム処理。
queries/ クエリオブジェクト。再利用可能で合成可能なActive Recordクエリ。
jobs/ バックグラウンドジョブ。Solid Queue、Sidekiq、GoodJobによる非同期処理。
mailers/ ActionMailerクラス。
helpers/ ビューヘルパー。ごく小さなプレゼンテーションロジックのみ。
policies/ 認可ポリシー(Pundit使用時)。オプション。
channels/ ActionCableチャンネル。WebSocket処理用。
app/lib/、app/utils/、app/managers/ は避けてください。何かが上記のディレクトリに適合しない場合、通常は新しいディレクトリを作るのではなく、設計を見直す必要があります。本当に汎用的なコードは lib/ に置きます。
スキニーコントローラ
コントローラはリクエストを受け取り、適切なオブジェクトに委譲し、レスポンスをレンダリングします。ビジネスロジックは他の場所に置きます(Rubyパターンルールに従い、コントローラが複数の責務を持ち始めたらサービスオブジェクトに抽出します)。
サービスオブジェクト
単一モデルの保存を超えるビジネス操作のデフォルト。一貫性を保つための規約:
- ドメインで名前空間を作る(
Invoices::Create)。サフィックス(InvoiceCreator)は使わない。 - クラスメソッド
.callがインスタンスの#callに委譲する。 Resultオブジェクトを返す。真偽値や生のレコードは返さない。これにより呼び出し元は成功、エラー、影響を受けたレコードに基づいて分岐できる。- 複数レコードの書き込みはトランザクションでラップする。
- 各サービスは単一目的に保つ(
Invoices::Create、Invoices::MarkPaid)。決してInvoices::Managerにしない。
フォームオブジェクト
フォームが複数のモデルにまたがる場合や、カラムにマッピングできないフィールドがある場合は、ネストされた属性や間違ったモデル上の仮想属性ではなく、フォームオブジェクトを使用します。ビューに対してはモデルと同じように振る舞い(form_with model: @form)、レコードをきれいに構成します。
クエリオブジェクト
コントローラやサービス間で再利用されるActive Recordクエリ、またはスコープでは複雑すぎるクエリは、クエリオブジェクトに抽出します。このオブジェクトはスコープを入力として受け取り、合成可能にします。経験則:スコープが3つ以上の連鎖条件を超えるか、パラメータを取り始めたら、クエリオブジェクトにしたいタイミングです。
バックグラウンドジョブ
遅いものは何でもオフロードします(Rubyパターンルールに従い、グリーンフィールドのRails 8でスループットが控えめならSolid Queue、成熟した可観測性、高スループット、既存のRedisが必要ならSidekiq)。アダプタに関わらず:IDを渡しレコードは渡さない、perform を冪等にする、retry_on/discard_on を明示的に設定する。
ViewComponent > パーシャル
条件付きレンダリング、2つ以上の引数、または3箇所以上で再利用されるビューロジックには、パーシャルよりもViewComponentを推奨します。コンポーネントは単独でテスト可能で、インターフェースを明示的に公開します。深い条件ロジックを持つパーシャルは負債になります。
Hotwire: Turbo と Stimulus
デフォルトのRailsフロントエンドスタック(Rubyパターンルールに従い、サーバーレンダリングアプリにはHotwireを推奨。React/Vueはインタラクションの複雑さがクライアントサーフェスを正当化する場合のみ)。Turbo Framesは部分的なページ更新、Turbo Streamsはサーバー駆動の更新、Stimulusはマークアップの近くでの小さなクライアント側の振る舞いに使用。
Rails 8 Solid スタック
Rails 8は、以前はRedisが必要だったものをデータベースベースのデフォルトで出荷します:Solid Queue(ジョブ)、Solid Cache(キャッシュ)、Solid Cable(ActionCable)。トレードオフは、データベースの負荷が増える代わりにインフラコンポーネントが1つ減ることです。控えめなスループットに適しており、高スケールではRedisが依然として優位です。KamalはデフォルトのDockerベースのデプロイツールです。
コード例
スキニーコントローラとサービスオブジェクト
# 悪い例:コントローラにビジネスロジックがある
class InvoicesController < ApplicationController
def create
@invoice = Invoice.new(invoice_params)
@invoice.user = current_user
@invoice.line_items.build(invoice_params[:line_items])
@invoice.tax_total = TaxCalculator.new(@invoice).calculate
@invoice.total = @invoice.line_items.sum(&:amount) + @invoice.tax_total
if @invoice.save
InvoiceMailer.created(@invoice).deliver_later
AccountingExportJob.perform_later(@invoice.id)
redirect_to @invoice, notice: "請求書を作成しました"
else
render :new
end
end
end
# 良い例:コントローラはオーケストレーションし、サービスが作業を行う
class InvoicesController < ApplicationController
def create
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "請求書を作成しました"
else
@invoice = result.invoice
render :new, status: :unprocessable_entity
end
end
end
サービスオブジェクト
# app/services/invoices/create.rb
module Invoices
class Create
# StructはRails 7.1がサポートするすべてのRubyで実行可能に保つ。
# Ruby 3.2+ では、`Data.define(:success?, :invoice, :errors)` がより
# 簡潔で不変な代替手段。
Result = Struct.new(:success, :invoice, :errors, keyword_init: true) do
def success?
success
end
end
def self.call(params:, user:)
new(params: params, user: user).call
end
def initialize(params:, user:)
@params = params
@user = user
end
def call
invoice = build_invoice
ApplicationRecord.transaction do
invoice.save!
end
begin
send_notifications(invoice)
rescue StandardError => e
Rails.logger.error("請求書 #{invoice.id} の通知送信に失敗しました: #{e.message}")
end
Result.new(success: true, invoice: invoice, errors: nil)
rescue ActiveRecord::RecordInvalid => e
Result.new(success: false, invoice: e.record, errors: e.record.errors)
end
private
attr_reader :params, :user
def build_invoice
invoice = user.invoices.new(params.except(:line_items))
invoice.line_items.build(params[:line_items])
invoice.tax_total = TaxCalculator.call(invoice)
invoice.total = invoice.line_items.sum(&:amount) + invoice.tax_total
invoice
end
def send_notifications(invoice)
InvoiceMailer.created(invoice).deliver_later
AccountingExportJob.perform_later(invoice.id)
end
end
end
フォームオブジェクト
# app/forms/signup_form.rb
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
attribute :company_name, :string
attribute :terms_accepted, :boolean
validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP
validates :password, presence: true, length: { minimum: 12 }
validates :company_name, presence: true
validates :terms_accepted, acceptance: true
attr_reader :user, :company
def save
return false unless valid?
ApplicationRecord.transaction do
@company = Company.create!(name: company_name)
@user = @company.users.create!(email: email, password: password, role: :owner)
end
true
rescue ActiveRecord::RecordInvalid => e
errors.merge!(e.record.errors)
false
end
end
クエリオブジェクト
# app/queries/invoices/overdue.rb
module Invoices
class Overdue
def self.call(scope: Invoice.all, as_of: Time.current)
new(scope: scope, as_of: as_of).call
end
def initialize(scope:, as_of:)
@scope = scope
@as_of = as_of
end
def call
scope
.where(status: :sent)
.where(due_date: ..as_of)
.where.not(id: paid_invoice_ids)
.includes(:customer, :line_items)
end
private
attr_reader :scope, :as_of
def paid_invoice_ids
Payment.where(created_at: ..as_of).pluck(:invoice_id)
end
end
end
クエリオブジェクトはスコープを受け取るため、合成可能です:Invoices::Overdue.call(scope: current_user.invoices)。
N+1対策
# 悪い例:ビューで post.author.name を呼び出すとN+1が発生
@posts = Post.published
# 良い例:eager loading
@posts = Post.published.includes(:author)
includes を使うとRailsがpreloadとeager_loadを選択します。関連付けでフィルタリングする場合は、preload で個別のクエリ、eager_load でJOINを強制します。Rails 6.1以降、strict_loading は不注意な遅延ロードに対して例外を発生させます。
カウンターキャッシュ
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
add_column :posts, :comments_count, :integer, default: 0, null: false
post.comments_count は COUNT(*) ではなくカラムの読み取りになります。この例は新しいテーブルを想定しています。既に行があるテーブルにカウンターキャッシュを追加するにはバックフィルが必要であり、ここでは範囲外です。
バックグラウンドジョブの形状
レコードIDを渡し、レコードは渡さないでください。リトライにより配信は最低でも1回保証されるため、外部サービスを呼び出すジョブは冪等でなければなりません。そうでなければ、リモート呼び出しが成功した後の一時的な障害が、次の試行で効果を重複させます。
class AccountingExportJob < ApplicationJob
queue_as :exports
retry_on AccountingApi::TransientError, wait: :polynomially_longer, attempts: 5
discard_on AccountingApi::PermanentError
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
export = AccountingExport.create_or_find_by!(
invoice: invoice,
idempotency_key: "invoice-export-#{invoice.id}-#{invoice.updated_at.to_i}"
)
return if export.completed_at?
receipt = AccountingApi.export(invoice, idempotency_key: export.idempotency_key)
export.update!(completed_at: Time.current, external_id: receipt.id)
end
end
add_index :accounting_exports, :idempotency_key, unique: true
ユニークインデックスがこれを安全にします:2つの試行が競合した場合、データベースは2番目の挿入を拒否し、Active Recordが呼び出し内で競合を解決して既存の行を返します。これはジョブレベルのリトライなしで発生します。上記の retry_on は AccountingApi::TransientError のみをカバーします。ガードはリモート呼び出し前のウィンドウをカバーし、idempotency_key をAPIに渡すことでその後のウィンドウをカバーするため、API呼び出しと update! の間のクラッシュでも単一のエクスポートに解決されます。
ViewComponent
# app/components/invoice_status_badge_component.rb
class InvoiceStatusBadgeComponent < ViewComponent::Base
STATUS_CLASSES = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800"
}.freeze
def initialize(invoice:)
@invoice = invoice
end
def call
tag.span(@invoice.status.humanize, class: "rounded-full px-2 py-1 text-sm #{status_class}")
end
private
def status_class
STATUS_CLASSES.fetch(@invoice.status.to_sym, "bg-gray-100")
end
end
<%= render InvoiceStatusBadgeComponent.new(invoice: @invoice) %>
Hotwire
<%# Turbo Frame: 「編集」をクリックするとこのフレームのみが置き換わる %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<div class="invoice">
<%= link_to "編集", edit_invoice_path(@invoice) %>
</div>
<% end %>
<%# Turbo Stream: app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "form", locals: { comment: Comment.new } %>
// app/javascript/controllers/copy_to_clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
}
}
許容可能なコールバックと許容不可能なコールバック
# 許容可能:純粋なデータの正規化
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# サービスに移動すべき:副作用がコールバックに隠れている
# class User < ApplicationRecord
# after_create :send_welcome_email # オプトアウトが難しく、テストが難しい
# end
良いConcernと悪いConcern
# 良い:真に横断的で、無関係なモデル間で再利用可能
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
end
def soft_delete! = update!(deleted_at: Time.current)
def restore! = update!(deleted_at: nil)
end
# 悪い:ちょうど1つのモデルで使われる「concern」で、そのモデルに属するロジックを保持している
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# このモジュールをincludeしているのはInvoiceのみ。これは横断的ではなく、Invoice自身のロジックを
# 「スリムなモデル」に見せかけるためにモジュールに隠したもの。Invoiceに戻すべき。
1つのクラスだけで使われるconcernは単なるコードの移動であり、そのクラスに属するべきです。concernは、複数の無関係なモデル間で真に横断的な振る舞いがある場合にのみ使用し、2つ以上のモデルで同じコードが必要になるまでは抽出を控えてください。
まとめ
- 予測可能なディレクトリ構造を守る。
app/lib/やapp/utils/は避ける。 - コントローラは薄く保つ。ビジネスロジックはサービスオブジェクトに抽出する。
- サービスオブジェクトはドメインで名前空間を作り、Resultを返し、明示的に失敗できるようにする。
- フォームオブジェクトで複数モデルのフォームと検証を扱う。
- クエリオブジェクトで複雑なActive Recordクエリをカプセル化する。
- ViewComponentをパーシャルよりも優先する。
- バックグラウンドジョブは冪等にし、IDを渡し、明示的なリトライポリシーを設定する。
- コールバックはデータの正規化のみに使い、副作用のためには使わない。
- Concernは真に横断的な場合のみ使用し、そうでなければモデルにロジックを保持する。
- Rails 8アプリでは、Solid Queue、Solid Cache、Solid Cableを使ってインフラストラクチャを簡素化する。