Railsパターン は何をしますか?
モダンなRuby on Railsアプリケーション(Rails 7.1+ および 8.x)のためのフレームワークパターン。Railsは設計上、意見が分かれるフレームワークですが、これらはコミュニティが「50モデルを超えても保守可能な」アプリケーションのために収束したパターンです。このスキルは「どのように(how)」実装するかを扱います。「何を(what)」そして「いつ(when)」—つまり、どのパターンを選ぶべきかの判断については、Rubyパターンルール(rules/ruby/patterns.md、このリポジトリ内、インストール先は rules/ecc/ruby/patterns.md)を参照してください。
アクティブにするタイミング
- Railsアプリケーション(フルスタック、API専用、またはハイブリッド)を構築するとき
app/またはconfig/に触れるPRをレビューするとき- モデル、コントローラ、サービス、またはジョブを生成するとき
- コントローラのアクションが約10行を超えたとき
- モデルファイルが約200行を超えたとき
- コントローラやビューでActiveRecordクエリが使われ始めたとき
コアコンセプト
ディレクトリ契約
Railsアプリは予測可能な構造に従います。ディレクトリは意図的に、そして軽率に追加してはいけません。
app/
models/ ActiveRecordモデル。永続化とドメインロジックをデータの近くに配置。
controllers/ HTTPリクエスト処理。薄いオーケストレーションのみ。
views/ ERBテンプレート。ビジネスロジックは含めない。
components/ ViewComponentクラス。テストが必要なビューロジック。
services/ サービスオブジェクト。複数ステップのビジネス処理。
forms/ フォームオブジェクト。複数モデルにまたがる複雑なフォーム処理。
queries/ クエリオブジェクト。再利用可能で合成可能なActiveRecordクエリ。
jobs/ バックグラウンドジョブ。Solid Queue、Sidekiq、またはGoodJobによる非同期処理。
mailers/ ActionMailerクラス。
helpers/ ビューヘルパー。小さな表示用ロジックのみ。
policies/ 認可ポリシー(Pundit使用時)。オプション。
channels/ WebSocket処理用のActionCableチャンネル。
app/lib/、app/utils/、app/managers/ は避けてください。何かが上記のディレクトリに適合しない場合、通常は新しいディレクトリを作るのではなく、設計を見直す必要があります。本当に汎用的なコードは lib/ に配置します。
スリムなコントローラ
コントローラはリクエストを受け取り、適切なオブジェクトに委譲し、レスポンスをレンダリングします。ビジネスロジックは他の場所に置きます。(Rubyパターンルールに従い、コントローラが複数の責任を持ち始めたらサービスオブジェクトに抽出します。)
サービスオブジェクト
単一モデルの保存を超えるビジネス処理のデフォルト。一貫性を保つための規約:
- 接尾辞でなくドメインで名前空間を分ける(
InvoiceCreatorではなくInvoices::Create)。 - クラスメソッド
.callがインスタンスの#callに委譲する。 - 真偽値や生のレコードではなく、Resultオブジェクトを返し、呼び出し側が成功、エラー、影響を受けたレコードで分岐できるようにする。
- 複数レコードの書き込みはトランザクションでラップする。
- 各サービスは単一目的に保つ(
Invoices::Create、Invoices::MarkPaid)。Invoices::Managerのようなものは作成しない。
フォームオブジェクト
フォームが複数のモデルにまたがる場合、またはカラムにマッピングしないフィールドがある場合、ネストされた属性や間違ったモデルの仮想属性を使用する代わりにフォームオブジェクトを使用します。フォームオブジェクトはビューに対してモデルのように振る舞い(form_with model: @form)、レコードをクリーンに構成します。
クエリオブジェクト
コントローラやサービス間で再利用されるActiveRecordクエリ、またはスコープには複雑すぎるクエリは、スコープを入力として受け取り合成可能なクエリオブジェクトに抽出します。目安: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 Load
@posts = Post.published.includes(:author)
includes を使用すると、Railsがpreloadとeager_loadを自動選択します。関連でフィルタリングする場合、個別のクエリには preload を、JOINを使用するには eager_load を強制します。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を渡してください。リトライにより配信は「at-least-once(少なくとも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
# このConcernをインクルードしているのはInvoiceのみ。横断的ではなく、
# 「スリムな」モデルに見せるためにモジュールに隠されたInvoice自身のロジックです。
# Invoiceに戻してください。
1つのクラスだけで使用されるConcernは単なるコードの移動であり、そのクラスに属するべきです。Concernは、関連のないモデル間で共有される横断的関心事のために予約してください。