Rails Style Guide
crefft > Frameworks > Rails
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Extends Ruby style guide with Rails-specific conventions from Rails Style Guide.
Target Version: Rails 8.1+ with Ruby 3.4
Quick Reference
All Ruby tooling applies. Additional tools are REQUIRED:
| Task | Tool | Command |
|---|---|---|
| Lint | StandardRB1 + standard-rails2 | bundle exec standardrb |
| Security | Brakeman3 | bundle exec brakeman |
| Test | RSpec Rails4 | bundle exec rspec |
| Coverage | SimpleCov5 | via test suite |
| N+1 Detection | Bullet6 | via test suite |
| Profiling | rack-mini-profiler7 | ?pp=flamegraph |
| Rate Limiting | Rails built-in or Rack::Attack8 | via middleware |
StandardRB for Rails
Rails projects MUST use StandardRB1 with the standard-rails2 plugin for linting.
# Gemfile
group :development, :test do
gem "standard"
gem "standard-rails"
gem "standard-sorbet" # If using Sorbet
end
# .standard.yml
plugins:
- standard-rails
- standard-sorbet # If using Sorbet
The standard-rails2 plugin adds Rails-specific RuboCop rules. You SHOULD add standard-sorbet9 if your Rails app uses Sorbet10 for type checking.
Why StandardRB? StandardRB eliminates style debates with zero configuration. The standard-rails plugin extends this philosophy to Rails idioms, catching common anti-patterns like N+1 queries and improper use of callbacks while maintaining zero-config simplicity.
Project Structure
Rails projects SHOULD organize code using this structure:
app/
├── controllers/
├── models/
├── views/
├── services/ # Business logic
├── queries/ # Complex queries
├── serializers/ # JSON serialization
└── validators/ # Custom validators
Service objects SHOULD be used for complex business logic. Query objects MAY be used for complex database queries that don’t belong in models.
Models
Field Order
Model declarations MUST follow this order:
class User < ApplicationRecord
# 1. Associations
belongs_to :organization
has_many :posts, dependent: :destroy
has_one :profile, dependent: :destroy
# 2. Validations
validates :email, presence: true, uniqueness: true
validates :name, presence: true, length: {maximum: 255}
# 3. Callbacks (use sparingly)
before_save :normalize_email
# 4. Scopes
scope :active, -> { where(active: true) }
scope :recent, -> { order(created_at: :desc) }
# 5. Class methods
def self.find_by_email(email)
find_by(email: email.downcase)
end
# 6. Instance methods
def full_name
"#{first_name} #{last_name}"
end
private
def normalize_email
self.email = email.downcase.strip
end
end
Associations
# You MUST always specify dependent option for has_many/has_one
has_many :posts, dependent: :destroy
has_one :profile, dependent: :destroy
# You SHOULD use inverse_of for bi-directional associations
has_many :comments, inverse_of: :post
belongs_to :post, inverse_of: :comments
Validations
# You MUST use validates with hash syntax
validates :email, presence: true, uniqueness: {case_sensitive: false}
# You MUST validate boolean columns
validates :active, inclusion: {in: [true, false]}
# You MUST use database constraints for NOT NULL, unique indexes
Controllers
Thin Controllers
Controllers MUST be thin. Business logic MUST be extracted to service objects.
# BAD
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
@order.user = Current.user
@order.calculate_total
@order.apply_discount(params[:discount_code])
if @order.save
OrderMailer.confirmation(@order).deliver_later
redirect_to @order
else
render :new
end
end
end
# GOOD
class OrdersController < ApplicationController
def create
@order = Orders::CreateService.call(
user: Current.user,
params: order_params,
discount_code: params[:discount_code]
)
redirect_to @order
rescue Orders::CreateService::Error => e
@order = e.order
flash.now[:error] = e.message
render :new, status: :unprocessable_entity
end
end
Why Service Objects? Thin controllers improve testability by isolating business logic from HTTP concerns. Service objects are easier to test in isolation and can be reused across controllers, jobs, and rake tasks.
Strong Parameters
Controllers MUST use strong parameters to prevent mass assignment vulnerabilities.
private
def order_params
params.require(:order).permit(:product_id, :quantity, :notes)
end
Services
# app/services/orders/create_service.rb
module Orders
class CreateService
class Error < StandardError
attr_reader :order
def initialize(message, order:)
super(message)
@order = order
end
end
def self.call(...)
new(...).call
end
def initialize(user:, params:, discount_code: nil)
@user = user
@params = params
@discount_code = discount_code
end
def call
order = build_order
apply_discount(order)
save_order!(order)
send_confirmation(order)
order
end
private
attr_reader :user, :params, :discount_code
def build_order
Order.new(params.merge(user: user))
end
def apply_discount(order)
return unless discount_code
order.apply_discount(discount_code)
end
def save_order!(order)
return if order.save
raise Error.new(order.errors.full_messages.join(", "), order: order)
end
def send_confirmation(order)
OrderMailer.confirmation(order).deliver_later
end
end
end
Authentication (Rails 8+)
Projects SHOULD use the built-in authentication generator for new applications:
bin/rails generate authentication
This generates:
Usermodel withhas_secure_passwordSessionmodel for session trackingSessionsControllerfor login/logout- Password reset flow with secure tokens
- Current session helpers
# Generated authentication provides:
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(e) { e.strip.downcase }
end
# In controllers:
class ApplicationController < ActionController::Base
include Authentication
end
# Usage in views:
<% if authenticated? %>
Logged in as <%= Current.user.email_address %>
<% end %>
Why: Rails 8’s authentication generator provides secure defaults including session tracking, password resets, and rate limiting without third-party gems like Devise.
Application code MUST read the signed-in user from Current.user. The generated
Authentication concern exposes authenticated? and sets Current.session; the generated
Current model delegates user to that session. There is no current_user helper, so calling
one raises NoMethodError.
The generator covers sessions only. Everything else MUST be built or delegated to a gem:
| Capability | Generated by bin/rails generate authentication |
|---|---|
| Sign in, sign out, session tracking | Yes |
| Password reset over email | Yes |
| Rate-limited session creation | Yes |
| Sign-up and email confirmation | No |
| TOTP, WebAuthn, or passkeys | No |
| OAuth account linking | No |
Applications needing those flows SHOULD either add them deliberately — a registrations
controller with the same normalizes and has_secure_password rules, a signed confirmation
token with a short expiry, and a second factor stored per user — or use Devise11 with the
extensions below.
Background Jobs: Solid Queue (Rails 8+)
Projects SHOULD use Solid Queue12 instead of Sidekiq for most background job processing:
# Gemfile (included by default in Rails 8+)
gem "solid_queue"
# config/database.yml
production:
primary:
<<: *default
database: myapp_production
queue:
<<: *default
database: myapp_queue
migrations_paths: db/queue_migrate
# app/jobs/order_notification_job.rb
class OrderNotificationJob < ApplicationJob
queue_as :default
def perform(order_id)
order = Order.find(order_id)
OrderMailer.confirmation(order).deliver_now
end
end
# Enqueue
OrderNotificationJob.perform_later(order.id)
Why Solid Queue over Sidekiq?
| Feature | Solid Queue | Sidekiq |
|---|---|---|
| Redis required | No | Yes |
| Database backend | PostgreSQL, MySQL, SQLite | Redis only |
| Concurrency controls | Built-in | Requires sidekiq-unique-jobs |
| Recurring jobs | Built-in | Requires sidekiq-cron |
| Infrastructure | Simpler | Requires Redis |
When to use Sidekiq: High-throughput applications (10,000+ jobs/minute) or when Redis is already in the stack.
Job Continuations (Rails 8.1+)
Long-running jobs SHOULD include ActiveJob::Continuable so that work survives a restart:
class DataExportJob < ApplicationJob
include ActiveJob::Continuable
queue_as :default
def perform(export_id)
# Code outside a step runs on every attempt, so a resumed job rebuilds
# the state it needs. Completed steps are skipped.
@export = Export.find(export_id)
step :start_export do
@export.start!
end
step :process_records do |step|
@export.records.find_each(start: step.cursor) do |record|
@export.process_record(record)
step.advance! from: record.id
end
end
step :finalize do
@export.complete!
ExportMailer.ready(@export).deliver_later
end
end
end
Jobs MUST NOT carry iteration state between steps in instance variables:
# BAD: @records is nil after a resume, and the loop saves no progress
step :fetch_records do
@records = export.records.to_a
end
step :process_records do
@records.each_with_index do |record, i|
export.process_record(record)
checkpoint! if i % 100 == 0
end
end
# GOOD: reload durable state outside the steps, advance a serialized cursor
step :process_records do |step|
export.records.find_each(start: step.cursor) do |record|
export.process_record(record)
step.advance! from: record.id
end
end
Why: A resumed job replays perform from the top and skips steps that already finished, so
@records is nil on the second attempt. Only the cursor and the completed-step list are
serialized with the job.
Checkpoints are not progress: step.advance! and step.set! record the cursor and create
a checkpoint; checkpoint! alone only offers an interruption point. A checkpoint interrupts the
job only when queue_adapter.stopping? is true, which the Solid Queue12 adapter reports once
a worker begins shutting down. Active Job also checkpoints before every step except the first of
each execution. Interrupted jobs are re-enqueued automatically, which is what prevents work loss
during Kamal’s 30-second shutdown window.
Caching: Solid Cache (Rails 8+)
Projects SHOULD use Solid Cache13 instead of Redis/Memcached for caching. New Rails 8
applications are configured for it already; older applications MUST run the installer, which
writes config/cache.yml, the cache schema, and the production cache store:
bundle add solid_cache
bin/rails solid_cache:install
# config/environments/production.rb
config.cache_store = :solid_cache_store
Cache-store options MUST be nested under store_options, and sizes and durations MUST
be evaluated with ERB:
# config/cache.yml
default: &default
store_options:
max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
development:
<<: *default
test:
<<: *default
production:
<<: *default
database: cache
# config/database.yml
production:
primary: &primary_production
<<: *default
database: myapp_production
cache:
<<: *primary_production
database: myapp_production_cache
migrations_paths: db/cache_migrate
Then load the cache schema with bin/rails db:prepare.
Why nesting and ERB? SolidCache::Configuration accepts only store_options, database,
databases, connects_to, executor, encrypt, encryption_context_properties, and
size_estimate_samples. Top-level cache options raise
ArgumentError: unknown keywords: :max_size, :max_age before the cache serves a single read.
YAML has no Ruby duration literals either, so an unevaluated 256.megabytes reaches Solid Cache
as the string "256.megabytes".
Why a separate cache database? A dedicated database: entry keeps cache writes out of the
primary connection pool and out of primary backups. It is not mandatory: with no database,
databases, or connects_to key, Solid Cache uses the ActiveRecord::Base pool, which makes
cache writes part of any enclosing transaction and rolls them back with it.
Why: Solid Cache uses disk storage, enabling much larger caches than memory-based solutions. It supports encryption for privacy compliance and requires no additional infrastructure.
WebSockets: Solid Cable (Rails 8+)
Projects using Action Cable SHOULD use Solid Cable14 instead of Redis:
# config/cable.yml
production:
adapter: solid_cable
polling_interval: 0.1.seconds
message_retention: 1.day
Why: Solid Cable eliminates Redis dependency for Action Cable while retaining messages for debugging.
Why Hotwire
- Less JavaScript: Build reactive UIs using server-rendered HTML
- Default in Rails 8: Included by default, no additional gems needed
- Progressive enhancement: Works without JavaScript, enhanced when available
- Simpler mental model: Server controls application state, not client
Turbo Drive
Turbo Drive accelerates navigation by intercepting link clicks and form submissions:
<%# Turbo Drive is enabled by default. Disable for specific links: %>
<%= link_to "External Site", "https://example.com", data: { turbo: false } %>
<%# Disable for forms that should do full page reload: %>
<%= form_with model: @user, data: { turbo: false } do |f| %>
...
<% end %>
Turbo Frames
Turbo Frames enable partial page updates by scoping navigation to a frame:
<%# app/views/posts/index.html.erb %>
<%= turbo_frame_tag "posts" do %>
<% @posts.each do |post| %>
<%= render post %>
<% end %>
<%# Link loads only the frame, not full page %>
<%= link_to "Load More", posts_path(page: @page + 1) %>
<% end %>
<%# Lazy-load content %>
<%= turbo_frame_tag "comments", src: post_comments_path(@post), loading: :lazy do %>
<p>Loading comments...</p>
<% end %>
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.page(params[:page])
# Turbo Frame requests only render the frame, not layout
end
end
Turbo Streams
Turbo Streams enable real-time updates via WebSocket or HTTP responses:
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def create
@comment = @post.comments.build(comment_params)
if @comment.save
respond_to do |format|
format.turbo_stream # Renders create.turbo_stream.erb
format.html { redirect_to @post }
end
else
render :new, status: :unprocessable_entity
end
end
end
<%# app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "comments/form", locals: { comment: Comment.new } %>
<%= turbo_stream.update "comment_count", @post.comments.count %>
Turbo Stream Actions
| Action | Description |
|---|---|
append | Add content to end of target |
prepend | Add content to start of target |
replace | Replace entire target element |
update | Replace content inside target |
remove | Remove target element |
before | Insert content before target |
after | Insert content after target |
Broadcasting with Action Cable
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :post
after_create_commit -> {
broadcast_append_to post, target: "comments"
}
after_destroy_commit -> {
broadcast_remove_to post
}
end
<%# app/views/posts/show.html.erb %>
<%= turbo_stream_from @post %>
<div id="comments">
<%= render @post.comments %>
</div>
Stimulus Controllers
Stimulus adds lightweight JavaScript behavior to HTML:
// app/javascript/controllers/clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
static values = { successMessage: String }
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
this.dispatch("copied", { detail: { content: this.sourceTarget.value } })
}
}
<div data-controller="clipboard" data-clipboard-success-message-value="Copied!">
<input type="text" value="Copy me!" data-clipboard-target="source">
<button data-action="clipboard#copy">Copy</button>
</div>
Stimulus Best Practices
// GOOD: Use targets for DOM elements
static targets = ["input", "output"]
// GOOD: Use values for configuration
static values = { url: String, delay: { type: Number, default: 300 } }
// GOOD: Use classes for CSS manipulation
static classes = ["active", "loading"]
// GOOD: Small, focused controllers
// BAD: Monolithic controllers with 500+ lines
// GOOD: Use Stimulus outlets for controller communication
static outlets = ["modal"]
openModal() {
this.modalOutlet.open()
}
Testing Hotwire
# spec/requests/comments_spec.rb
RSpec.describe "Comments", type: :request do
describe "POST /posts/:post_id/comments" do
it "returns turbo stream response" do
post post_comments_path(post), params: { comment: { body: "Great!" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response).to have_http_status(:success)
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include("turbo-stream")
end
end
end
# spec/system/comments_spec.rb
RSpec.describe "Comments", type: :system, js: true do
it "adds comment without page reload" do
post = create(:post)
visit post_path(post)
fill_in "comment_body", with: "New comment"
click_button "Submit"
expect(page).to have_content("New comment")
expect(page).not_to have_current_path(post_comments_path(post))
end
end
ViewComponent
Projects with complex views SHOULD use ViewComponent18 to encapsulate view logic in testable, reusable components.
Why ViewComponent
- Testable: Components are unit-testable Ruby objects, 10x faster than system tests
- Encapsulated: Components own their templates, no implicit instance variables
- Reusable: Components are packaged for reuse across views
- Type-safe: Works with Sorbet for compile-time type checking
Installation
# Gemfile
gem "view_component"
bin/rails generate component Example title
Basic Component
# app/components/alert_component.rb
class AlertComponent < ViewComponent::Base
VARIANTS = %w[info success warning error].freeze
def initialize(message:, variant: "info", dismissible: false)
@message = message
@variant = variant.to_s
@dismissible = dismissible
end
def variant_classes
case @variant
when "success" then "bg-green-100 text-green-800"
when "warning" then "bg-yellow-100 text-yellow-800"
when "error" then "bg-red-100 text-red-800"
else "bg-blue-100 text-blue-800"
end
end
def render?
@message.present?
end
end
<%# app/components/alert_component.html.erb %>
<div class="alert <%= variant_classes %>" role="alert"
data-controller="<%= 'dismissible' if @dismissible %>">
<p><%= @message %></p>
<% if @dismissible %>
<button data-action="dismissible#dismiss">×</button>
<% end %>
</div>
<%# Usage in views %>
<%= render AlertComponent.new(message: "Success!", variant: :success, dismissible: true) %>
Components with Slots
# app/components/card_component.rb
class CardComponent < ViewComponent::Base
renders_one :header
renders_one :footer
renders_many :actions
def initialize(title: nil)
@title = title
end
end
<%# app/components/card_component.html.erb %>
<div class="card">
<% if header? || @title %>
<div class="card-header">
<%= header || @title %>
</div>
<% end %>
<div class="card-body">
<%= content %>
</div>
<% if actions? %>
<div class="card-actions">
<% actions.each do |action| %>
<%= action %>
<% end %>
</div>
<% end %>
<% if footer? %>
<div class="card-footer">
<%= footer %>
</div>
<% end %>
</div>
<%# Usage %>
<%= render CardComponent.new do |card| %>
<% card.with_header do %>
<h2>Custom Header</h2>
<% end %>
<p>Card content goes here.</p>
<% card.with_action do %>
<%= link_to "Edit", edit_path %>
<% end %>
<% card.with_action do %>
<%= button_to "Delete", delete_path, method: :delete %>
<% end %>
<% end %>
Collection Rendering
# app/components/user_row_component.rb
class UserRowComponent < ViewComponent::Base
with_collection_parameter :user
def initialize(user:, user_counter: nil)
@user = user
@counter = user_counter
end
end
<%# Render collection efficiently %>
<%= render UserRowComponent.with_collection(@users) %>
Component Testing
# spec/components/alert_component_spec.rb
RSpec.describe AlertComponent, type: :component do
it "renders message" do
render_inline(described_class.new(message: "Hello"))
expect(page).to have_text("Hello")
end
it "applies variant classes" do
render_inline(described_class.new(message: "Error!", variant: :error))
expect(page).to have_css(".bg-red-100")
end
it "does not render when message is blank" do
render_inline(described_class.new(message: ""))
expect(page).not_to have_css(".alert")
end
context "when dismissible" do
it "renders close button" do
render_inline(described_class.new(message: "Dismiss me", dismissible: true))
expect(page).to have_button("×")
end
end
end
Component Previews
# spec/components/previews/alert_component_preview.rb
class AlertComponentPreview < ViewComponent::Preview
# @param message text
# @param variant select [info, success, warning, error]
# @param dismissible toggle
def default(message: "This is an alert", variant: :info, dismissible: false)
render AlertComponent.new(
message: message,
variant: variant,
dismissible: dismissible
)
end
def all_variants
render_with_template
end
end
<%# spec/components/previews/alert_component_preview/all_variants.html.erb %>
<% %w[info success warning error].each do |variant| %>
<%= render AlertComponent.new(message: "#{variant.titleize} message", variant: variant) %>
<% end %>
Visit /rails/view_components to browse component previews during development.
ViewComponent with Hotwire
# app/components/live_counter_component.rb
class LiveCounterComponent < ViewComponent::Base
def initialize(count:, resource:)
@count = count
@resource = resource
end
def dom_id
"#{@resource.class.name.underscore}_#{@resource.id}_counter"
end
end
<%# app/components/live_counter_component.html.erb %>
<%= turbo_stream_from @resource, :counter %>
<span id="<%= dom_id %>"><%= @count %></span>
# app/models/post.rb
class Post < ApplicationRecord
after_update_commit -> {
broadcast_update_to self, :counter,
target: "post_#{id}_counter",
html: comments_count.to_s
}
end
Rate Limiting
Built-in Rate Limiting (Rails 8+)
Rails 8 projects SHOULD use the built-in rate_limit method for simple throttling scenarios:
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create, with: -> {
redirect_to new_session_url, alert: "Try again later."
}
def create
# Login logic
end
end
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
rate_limit to: 100, within: 1.minute, by: -> { request.remote_ip }
end
Why: Rails 8’s built-in rate limiting requires no additional gems and uses your existing cache store (Redis, Memcached, or Solid Cache). It integrates seamlessly with Action Controller.
Rack::Attack for Advanced Scenarios
Projects with complex rate limiting requirements SHOULD use Rack::Attack8:
# Gemfile
gem "rack-attack"
# config/initializers/rack_attack.rb
require "digest"
class Rack::Attack
# Use Redis for distributed rate limiting
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
url: ENV["REDIS_URL"],
db: 1 # Separate database for throttling
)
# Block bad actors
blocklist("block bad IPs") do |req|
Blocklist.exists?(ip: req.ip)
end
# Throttle all requests by IP (100 req/min)
throttle("req/ip", limit: 100, period: 1.minute) do |req|
req.ip
end
# Stricter limits for authentication endpoints
throttle("logins/ip", limit: 5, period: 20.seconds) do |req|
req.ip if req.path == "/login" && req.post?
end
# Throttle by email for password resets (prevent enumeration)
throttle("password_resets/email", limit: 3, period: 1.hour) do |req|
next unless req.path == "/password" && req.post?
user = req.params["user"]
next unless user.is_a?(Hash)
email = user["email"]
next unless email.is_a?(String)
email = email.strip.downcase
next if email.empty?
Digest::SHA256.hexdigest(email)
end
# Exponential backoff for failed logins
(1..5).each do |level|
blocklist("fail2ban/login/L#{level}") do |req|
Rack::Attack::Fail2Ban.filter(
"login-#{req.ip}",
maxretry: 5,
findtime: 1.minute,
bantime: (2**level).minutes
) do
req.path == "/login" && req.post? && req.env["warden.options"]&.dig(:message) == :invalid
end
end
end
# Safelist trusted IPs
safelist("allow localhost") do |req|
req.ip == "127.0.0.1" || req.ip == "::1"
end
# Custom response for throttled requests
self.throttled_responder = lambda do |req|
retry_after = (req.env["rack.attack.match_data"] || {})[:period]
[
429,
{
"Content-Type" => "application/json",
"Retry-After" => retry_after.to_s
},
[{ error: "Rate limit exceeded", retry_after: retry_after }.to_json]
]
end
end
Why the shape checks? Discriminators run in middleware, before any controller or strong
parameters. An attacker controls the whole body, so req.params["user"] may be missing, a
string, or an array. req.params["user"]["email"] raises NoMethodError on an empty POST and
TypeError on user[]=a, turning a throttle into a 500. dig alone is not enough either:
String and Array do not respond to dig the way Hash does. Returning nil — via next —
means “do not throttle this request,” which is the correct outcome for a request that carries no
email to throttle.
Why hash the email? The discriminator becomes part of the cache key. Hashing a canonicalized
address keeps plaintext addresses out of Redis while still bucketing " Alex@Example.COM " and
"alex@example.com" together.
Rate-limiting rules MUST be tested with malformed input:
# spec/requests/password_reset_throttle_spec.rb
RSpec.describe "POST /password", type: :request do
malformed = [
"", # no body
"user=", # empty scalar
"user=scalar", # string instead of hash
"user[]=a&user[]=b", # array instead of hash
"user[name]=bob", # no email key
"user[email][]=a", # array email
"user[email]=%20%20" # blank email
]
malformed.each do |body|
it "does not fail on #{body.inspect}" do
post "/password", params: body,
headers: {"CONTENT_TYPE" => "application/x-www-form-urlencoded"}
expect(response.status).not_to eq(500)
end
end
it "throttles the fourth reset for one address in an hour" do
4.times { post "/password", params: {user: {email: "alex@example.com"}} }
expect(response).to have_http_status(:too_many_requests)
end
end
User-Based Rate Limiting
API applications SHOULD implement per-user rate limiting with tiered limits:
# config/initializers/rack_attack.rb
Rack::Attack.throttle("api/user", limit: proc { |req|
user = User.find_by(api_key: req.env["HTTP_X_API_KEY"])
case user&.plan
when "enterprise" then 10_000
when "pro" then 1_000
else 100
end
}, period: 1.hour) do |req|
req.env["HTTP_X_API_KEY"] if req.path.start_with?("/api/")
end
# Include rate limit headers in responses
class Api::V1::BaseController < ApplicationController
after_action :set_rate_limit_headers
private
def set_rate_limit_headers
match_data = request.env["rack.attack.throttle_data"]&.dig("api/user")
return unless match_data
response.headers["X-RateLimit-Limit"] = match_data[:limit].to_s
response.headers["X-RateLimit-Remaining"] = (match_data[:limit] - match_data[:count]).to_s
response.headers["X-RateLimit-Reset"] = (match_data[:epoch_time] + match_data[:period]).to_s
end
end
When to use Rack::Attack over built-in rate limiting:
| Feature | Built-in (Rails 8+) | Rack::Attack |
|---|---|---|
| Simple endpoint throttling | Yes | Yes |
| IP blocking/safelisting | No | Yes |
| Fail2ban patterns | No | Yes |
| Custom response format | Limited | Full control |
| Distributed (Redis) | Via cache store | Native support |
| Request inspection | Limited | Full Rack access |
Why Rack::Attack? Rack::Attack operates at the Rack middleware layer, blocking malicious requests before they reach your Rails controllers. This protects against credential stuffing attacks (often 1,000-10,000x normal traffic) and reduces load on your application stack.
Security: Brakeman
All Rails projects MUST use Brakeman3 for static security analysis.
# Install
bundle add brakeman --group development
# Run
bundle exec brakeman
# CI with exit code
bundle exec brakeman --exit-on-warn --no-pager
Why Brakeman? Brakeman performs static analysis to detect common Rails security vulnerabilities including SQL injection, XSS, CSRF, and mass assignment. It runs without executing code, making it safe for CI and pre-commit hooks.
Authentication: Devise + OmniAuth
Legacy applications or those requiring OAuth integration MAY use Devise11 with OmniAuth19:
# Gemfile
gem "devise"
gem "omniauth"
gem "omniauth-rails_csrf_protection" # Required for OmniAuth 2.0+
gem "omniauth-google-oauth2"
# config/initializers/devise.rb
Devise.setup do |config|
config.omniauth :google_oauth2,
ENV["GOOGLE_CLIENT_ID"],
ENV["GOOGLE_CLIENT_SECRET"],
scope: "email,profile"
end
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:omniauthable, omniauth_providers: [:google_oauth2]
def self.from_omniauth(auth)
find_or_create_by(provider: auth.provider, uid: auth.uid) do |user|
user.email = auth.info.email
user.password = Devise.friendly_token[0, 20]
end
end
end
Why: While Rails 8 includes built-in authentication, Devise remains the standard for applications that need registration, confirmation, lockable accounts, and OAuth providers without writing those flows. Devise core has no two-factor module: TOTP requires the separate devise-two-factor20 extension, and passkeys require a WebAuthn library. Choose Devise for the account-management breadth, not for MFA.
Authorization: Pundit
Applications with role-based access control SHOULD use Pundit21:
# Gemfile
gem "pundit"
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Authentication
include Pundit::Authorization
after_action :verify_authorized, except: :index
after_action :verify_policy_scoped, only: :index
rescue_from Pundit::NotAuthorizedError do |exception|
redirect_to root_path, alert: "You are not authorized to perform this action."
end
private
# Pundit calls current_user by default; generated Rails 8 authentication
# supplies Current.user instead.
def pundit_user
Current.user
end
end
Applications that switch the signed-in user mid-request — impersonation, sign-in after sign-up —
MUST call pundit_reset! after changing the session, because Pundit memoizes pundit_user
for the request.
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def update?
user.admin? || record.user == user
end
def destroy?
user.admin?
end
class Scope < ApplicationPolicy::Scope
def resolve
if user.admin?
scope.all
else
scope.where(published: true).or(scope.where(user: user))
end
end
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = policy_scope(Post)
end
def update
@post = Post.find(params[:id])
authorize @post
if @post.update(post_params)
redirect_to @post
else
render :edit
end
end
end
Why Pundit? Pundit encapsulates authorization logic in plain Ruby policy classes, keeping controllers thin and making access rules testable. It enforces the principle of least privilege by requiring explicit authorization for each action.
Performance
Development Profiling
Rails projects SHOULD use rack-mini-profiler7 for development profiling:
# Gemfile
group :development do
gem "rack-mini-profiler"
gem "flamegraph" # Flame graph visualization
gem "stackprof" # Stack profiler for flame graphs
gem "memory_profiler" # Memory allocation profiling
end
# config/initializers/rack_profiler.rb
if defined?(Rack::MiniProfiler)
# Position badge on left side
Rack::MiniProfiler.config.position = "bottom-left"
# Enable memory profiling (add ?pp=profile-memory to URL)
Rack::MiniProfiler.config.enable_advanced_debugging_tools = true
# Store profiling data in memory (or use Redis for persistence)
Rack::MiniProfiler.config.storage = Rack::MiniProfiler::MemoryStore
end
Access profiling features by appending query parameters:
?pp=help- Show all available profiling options?pp=flamegraph- Generate flame graph visualization?pp=profile-memory- Show memory allocations?pp=profile-gc- Show garbage collection stats?pp=analyze-memory- Detailed memory analysis
Why rack-mini-profiler? It provides real-time performance visibility with minimal overhead, showing SQL queries, view rendering times, and memory usage directly in the browser during development.
N+1 Query Detection
All Rails projects MUST use Bullet6 to detect N+1 queries:
# Gemfile
group :development, :test do
gem "bullet"
end
# config/environments/development.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = true # JavaScript alert in browser
Bullet.bullet_logger = true # Log to log/bullet.log
Bullet.console = true # Browser console warnings
Bullet.rails_logger = true # Rails logger
Bullet.add_footer = true # Add details to page footer
# Raise errors in test environment
Bullet.raise = Rails.env.test?
# Ignore specific associations if needed
# Bullet.add_safelist type: :n_plus_one_query, class_name: "User", association: :posts
end
end
# config/environments/test.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.raise = true # Fail tests on N+1 queries
end
end
Why Bullet? N+1 queries are the most common performance issue in Rails applications. Bullet detects them during development and can fail tests, preventing performance regressions from reaching production.
Memory Profiling
Applications with memory concerns SHOULD use memory_profiler22 for detailed analysis:
# Gemfile
group :development, :test do
gem "memory_profiler"
end
# lib/tasks/memory_profile.rake
namespace :memory do
desc "Profile memory usage of a specific code path"
task profile: :environment do
report = MemoryProfiler.report do
# Code to profile
100.times { User.all.to_a }
end
report.pretty_print(
detailed_report: true,
scale_bytes: true,
normalize_paths: true
)
end
end
# spec/support/memory_profiler.rb
RSpec.configure do |config|
config.around(:each, :profile_memory) do |example|
report = MemoryProfiler.report { example.run }
if report.total_allocated_memsize > 10.megabytes
puts "WARNING: High memory allocation detected"
report.pretty_print(to_file: "tmp/memory_#{example.description.parameterize}.txt")
end
end
end
Production APM
Production applications SHOULD use an Application Performance Monitoring (APM) service:
# Gemfile
# Choose ONE of the following based on requirements:
gem "appsignal" # AppSignal[^31] - Ruby-focused, GDPR compliant
gem "skylight" # Skylight[^32] - Rails-specific, simple pricing
gem "newrelic_rpm" # New Relic - Enterprise features, broader ecosystem
# config/appsignal.yml (if using AppSignal)
production:
active: true
name: "MyApp"
push_api_key: <%= ENV["APPSIGNAL_PUSH_API_KEY"] %>
enable_host_metrics: true
enable_minutely_probes: true
# Custom instrumentation for business-critical code
class Orders::CreateService
def call
ActiveSupport::Notifications.instrument("orders.create", order_id: order.id) do
# Business logic
end
end
end
| APM | Best For | Pricing Model |
|---|---|---|
| AppSignal23 | Ruby/Rails teams, GDPR requirements | Request-based ($23+/month) |
| Skylight24 | Rails-only, simple performance tuning | Request-based ($20+/month) |
| New Relic | Enterprise, polyglot environments | Host-based (varies) |
Why APM in production? Development profiling misses issues that only appear under production load. APM tools provide continuous monitoring, alerting on performance regressions, slow queries, and error spikes before users complain.
Query Tracing
Applications debugging slow queries MAY use active_record_query_trace25:
# Gemfile
group :development do
gem "active_record_query_trace"
end
# config/initializers/query_trace.rb
if defined?(ActiveRecordQueryTrace) && Rails.env.development?
ActiveRecordQueryTrace.enabled = true
ActiveRecordQueryTrace.level = :app # Only show app code, not gems
ActiveRecordQueryTrace.lines = 5 # Number of backtrace lines
ActiveRecordQueryTrace.colorize = true
end
Why: When Bullet identifies an N+1 query, active_record_query_trace shows exactly where in your code the query originates, making it easier to add eager loading.
Benchmarking
Performance-critical code SHOULD include benchmarks:
# Gemfile
group :development, :test do
gem "benchmark-ips"
gem "derailed_benchmarks"
end
# spec/benchmarks/user_search_benchmark.rb
require "benchmark/ips"
RSpec.describe "User search performance", :benchmark do
let!(:users) { create_list(:user, 1000) }
it "compares search implementations" do
Benchmark.ips do |x|
x.report("LIKE query") { User.where("name LIKE ?", "%john%").to_a }
x.report("pg_search") { User.search_by_name("john").to_a }
x.compare!
end
end
end
# Profile boot time and memory
bundle exec derailed bundle:mem
bundle exec derailed bundle:objects
# Profile specific request
bundle exec derailed exec perf:stackprof PATH_TO_HIT=/users
Migrations
Migrations MUST include NOT NULL constraints and indexes where appropriate:
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name, null: false, limit: 255
t.boolean :active, null: false, default: true
t.timestamps
end
add_index :users, :email, unique: true
end
end
Safe Migration Patterns
Production migrations MUST use the strong_migrations26 gem to prevent zero-downtime deployment issues:
# Gemfile
gem "strong_migrations"
# Safe: add column with default
add_column :users, :status, :string, default: "active"
# Safe: add index concurrently
disable_ddl_transaction!
add_index :users, :email, algorithm: :concurrently
Why strong_migrations? The gem catches dangerous migrations that can cause downtime or lock tables during deployment, such as adding columns with defaults, removing columns still in use, or creating indexes on large tables without concurrency.
Testing with RSpec
Rails projects MUST use RSpec27 for testing with RSpec Rails4 extensions.
# spec/rails_helper.rb
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
end
# spec/models/user_spec.rb
RSpec.describe User do
describe "validations" do
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
end
describe "#full_name" do
subject(:user) { build(:user, first_name: "John", last_name: "Doe") }
it "returns first and last name" do
expect(user.full_name).to eq("John Doe")
end
end
end
Why RSpec Rails? RSpec Rails provides Rails-specific matchers and helpers that make testing models, controllers, and system tests more expressive and maintainable. It integrates seamlessly with fixtures, factories, and database cleaners.
CI Pipeline
Rails projects MUST run linting, security checks, and tests in CI.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
bundler-cache: true
- run: bundle exec standardrb
- run: bundle exec brakeman --exit-on-warn
test:
runs-on: ubuntu-latest
services:
postgres:
# postgres:18; registry manifest digest.
image: postgres@sha256:74935e72241653ca55e0414067e6d8763aceb8a810eb51b452253ec3dcfc4336
env:
POSTGRES_PASSWORD: postgres
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
bundler-cache: true
- run: bundle exec rails db:setup
- run: bundle exec rspec
E2E & Acceptance Testing
Projects with user-facing interfaces SHOULD include end-to-end tests using Capybara28. Cucumber29 MAY be used for BDD-style acceptance tests.
# Gemfile
group :test do
gem "capybara"
gem "selenium-webdriver"
gem "cucumber-rails", require: false
gem "site_prism" # Page Object Model library
end
# spec/system/orders_spec.rb
RSpec.describe "Order creation", type: :system do
it "creates order with valid data" do
visit new_order_path
fill_in "Product", with: "Widget"
fill_in "Quantity", with: "5"
click_button "Create Order"
expect(page).to have_content("Order created successfully")
expect(Order.last.quantity).to eq(5)
end
end
# features/step_definitions/order_steps.rb
Given("I am on the new order page") do
visit new_order_path
end
When("I create an order for {int} widgets") do |quantity|
fill_in "Product", with: "Widget"
fill_in "Quantity", with: quantity
click_button "Create Order"
end
Then("I should see a success message") do
expect(page).to have_content("Order created successfully")
end
# spec/support/pages/order_page.rb
class OrderPage < SitePrism::Page # Using SitePrism for Page Object Model[^12]
set_url "/orders/new"
element :product_field, "#order_product"
element :quantity_field, "#order_quantity"
element :submit_button, "input[type='submit']"
def create_order(product:, quantity:)
product_field.set(product)
quantity_field.set(quantity)
submit_button.click
end
end
Thread Safety Testing
Applications using multi-threading (Puma30, Sidekiq31) SHOULD include thread safety tests.
# spec/jobs/order_processor_job_spec.rb
RSpec.describe OrderProcessorJob do
it "processes orders concurrently" do
orders = create_list(:order, 5)
threads = orders.map do |order|
Thread.new { described_class.perform_now(order.id) }
end
threads.each(&:join)
orders.each do |order|
expect(order.reload.status).to eq("processed")
end
end
end
# spec/support/connection_pool_spec.rb
RSpec.describe "Connection pool" do
it "handles concurrent requests" do
pool_size = ActiveRecord::Base.connection_pool.size
threads = pool_size.times.map do
Thread.new { User.count }
end
expect { threads.each(&:join) }.not_to raise_error
end
end
# spec/requests/concurrent_requests_spec.rb
RSpec.describe "Concurrent requests" do
it "handles parallel API calls safely" do
user = create(:user, balance: 100)
threads = 5.times.map do
Thread.new do
post api_v1_debit_path, params: {user_id: user.id, amount: 10}
end
end
threads.each(&:join)
expect(user.reload.balance).to eq(50)
end
end
Idempotence Testing
Background jobs and API endpoints SHOULD be idempotent. Critical operations MUST be tested by running them more than once, not by asserting that a duplicate was never enqueued.
Idempotent Jobs
Every queue delivers a job at least once: a worker that dies after sending an email but before acknowledging the job will run it again. Jobs with external side effects MUST therefore guard the side effect in the database, under a unique index:
# db/migrate/20260908000001_create_order_notifications.rb
class CreateOrderNotifications < ActiveRecord::Migration[8.1]
def change
create_table :order_notifications do |t|
t.references :order, null: false, foreign_key: true
t.string :kind, null: false
t.datetime :delivered_at
t.timestamps
end
add_index :order_notifications, [:order_id, :kind], unique: true
end
end
# app/jobs/order_notification_job.rb
class OrderNotificationJob < ApplicationJob
queue_as :default
KIND = "confirmation"
def perform(order_id)
order = Order.find(order_id)
notification = OrderNotification.create_or_find_by!(order_id: order.id, kind: KIND)
claimed = OrderNotification
.where(id: notification.id, delivered_at: nil)
.update_all(delivered_at: Time.current)
return if claimed.zero?
OrderMailer.confirmation(order).deliver_now
end
end
# spec/jobs/order_notification_job_spec.rb
RSpec.describe OrderNotificationJob do
let(:order) { create(:order) }
it "delivers one email however many times it runs" do
expect {
3.times { described_class.perform_now(order.id) }
}.to change { ActionMailer::Base.deliveries.count }.by(1)
end
it "delivers one email under concurrent execution" do
threads = 5.times.map do
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
described_class.perform_now(order.id)
end
end
end
threads.each(&:join)
expect(ActionMailer::Base.deliveries.count).to eq(1)
end
end
Why claim before delivering? The conditional update_all is a single atomic statement, so
exactly one execution sees claimed == 1 even when several run at once. create_or_find_by!
inserts inside a savepoint in Rails 8.1, so the losing insert does not abort a surrounding
transaction. Claiming first makes delivery at-most-once: a crash between the claim and the send
drops that email. Where an email matters more than a rare duplicate, send first and record
afterwards, or pass an idempotency key to the mail provider and let it deduplicate.
Jobs MUST NOT mix the two job APIs:
# BAD: raises ArgumentError, "Sidekiq::Job cannot be included in an ActiveJob"
class OrderNotificationJob < ApplicationJob
include Sidekiq::Worker
sidekiq_options lock: :until_executed
end
Projects that run Sidekiq31 directly MUST write a native job class, and MUST register the sidekiq-unique-jobs32 middleware themselves — it has not been automatic since v7, and the gem does not support Active Job:
# app/sidekiq/order_notification_job.rb
class OrderNotificationJob
include Sidekiq::Job
sidekiq_options lock: :until_executed, on_conflict: :log
def perform(order_id)
# Same database claim as above: locks stop duplicate enqueues,
# not duplicate executions.
end
end
# config/initializers/sidekiq.rb
require "sidekiq-unique-jobs"
Sidekiq.configure_server do |config|
config.redis = {url: ENV["REDIS_URL"]}
config.client_middleware { |chain| chain.add SidekiqUniqueJobs::Middleware::Client }
config.server_middleware { |chain| chain.add SidekiqUniqueJobs::Middleware::Server }
SidekiqUniqueJobs::Server.configure(config)
end
Sidekiq.configure_client do |config|
config.redis = {url: ENV["REDIS_URL"]}
config.client_middleware { |chain| chain.add SidekiqUniqueJobs::Middleware::Client }
end
Why not treat a lock as idempotence? lock: :until_executed suppresses a duplicate enqueue
while the first job is queued. It does nothing about a retry after the job already sent the
email, which is the case that produces the second message.
Idempotent Endpoints
Endpoints that create resources SHOULD accept an Idempotency-Key header and MUST
reserve it atomically, scoped to the actor and the endpoint:
# db/migrate/20260908000002_create_idempotent_requests.rb
class CreateIdempotentRequests < ActiveRecord::Migration[8.1]
def change
create_table :idempotent_requests do |t|
t.references :user, null: false, foreign_key: true
t.string :endpoint, null: false
t.string :idempotency_key, null: false
t.integer :status
t.json :response_body # jsonb on PostgreSQL
t.timestamps
end
add_index :idempotent_requests,
[:user_id, :endpoint, :idempotency_key],
unique: true,
name: "index_idempotent_requests_on_actor_and_key"
end
end
# app/controllers/api/v1/orders_controller.rb
class Api::V1::OrdersController < Api::V1::BaseController
ENDPOINT = "POST /api/v1/orders"
def create
key = request.headers["Idempotency-Key"].presence
if key.nil?
return render json: {error: "Idempotency-Key header is required"},
status: :bad_request
end
record = completed_request(key) || create_order(key)
if record
render json: record.response_body, status: record.status
else
render json: {error: "A request with this Idempotency-Key is still in flight"},
status: :conflict
end
end
private
def completed_request(key)
IdempotentRequest.completed.find_by(
user: Current.user, endpoint: ENDPOINT, idempotency_key: key
)
end
# Reserves the key and does the work in one transaction. Returns nil when
# another request holds the key and has not finished yet.
def create_order(key)
IdempotentRequest.transaction do
record = IdempotentRequest.create!(
user: Current.user, endpoint: ENDPOINT, idempotency_key: key
)
order = Orders::CreateService.call(user: Current.user, params: order_params)
record.update!(
status: 201,
response_body: order.as_json(only: [:id, :quantity, :status])
)
record
end
rescue ActiveRecord::RecordNotUnique
completed_request(key)
end
end
# app/models/idempotent_request.rb
class IdempotentRequest < ApplicationRecord
belongs_to :user
scope :completed, -> { where.not(status: nil) }
end
# spec/requests/api/v1/orders_spec.rb
RSpec.describe "POST /api/v1/orders", type: :request do
let(:user) { create(:user) }
let(:headers) { {"Idempotency-Key" => "key-123"} }
it "replays the stored response for a repeated key" do
post api_v1_orders_path, params: {order: {quantity: 2}}, headers: headers
first_body = response.body
post api_v1_orders_path, params: {order: {quantity: 2}}, headers: headers
expect(response).to have_http_status(:created)
expect(response.body).to eq(first_body)
expect(Order.count).to eq(1)
end
it "scopes keys to the actor" do
create(:idempotent_request, user: create(:user), endpoint: "POST /api/v1/orders",
idempotency_key: "key-123", status: 201, response_body: {"id" => 0})
post api_v1_orders_path, params: {order: {quantity: 2}}, headers: headers
expect(response).to have_http_status(:created)
expect(JSON.parse(response.body)["id"]).not_to eq(0)
end
it "returns 409 while another request holds the key" do
IdempotentRequest.create!(
user: user, endpoint: "POST /api/v1/orders", idempotency_key: "key-123"
)
post api_v1_orders_path, params: {order: {quantity: 2}}, headers: headers
expect(response).to have_http_status(:conflict)
expect(Order.count).to eq(0)
end
it "leaves no reservation behind when the operation fails" do
failure = Orders::CreateService::Error.new("payment declined", order: Order.new)
allow(Orders::CreateService).to receive(:call).and_raise(failure)
expect {
post api_v1_orders_path, params: {order: {quantity: 2}}, headers: headers
}.to raise_error(Orders::CreateService::Error)
expect(IdempotentRequest.count).to eq(0)
end
it "creates one order for simultaneous requests with the same key" do
statuses = Concurrent::Array.new
threads = 4.times.map do
Thread.new do
# Rack::Test sessions are not thread-safe; call the app directly.
ActiveRecord::Base.connection_pool.with_connection do
env = Rack::MockRequest.env_for(
api_v1_orders_path,
method: "POST",
params: {order: {quantity: 2}},
"HTTP_IDEMPOTENCY_KEY" => "key-123",
**credentials_for(user) # whatever your API authenticates with
)
statuses << Rails.application.call(env).first
end
end
end
threads.each(&:join)
expect(Order.count).to eq(1)
expect(statuses.uniq - [201, 409]).to be_empty
end
end
Why a transaction and a unique index? The published anti-pattern is a find_by followed by a
create!:
# BAD: two requests both find nothing, both create an order
existing = IdempotentRequest.find_by(key: idempotency_key)
return render json: existing.response if existing
order = Orders::CreateService.call(params: order_params)
IdempotentRequest.create!(key: idempotency_key, response: order)
Nothing prevents two requests from passing the find_by check together, the service is called
without the user: it requires, an Active Record object is stored where a serialized body
belongs, and a key from one customer collides with the same key from another. Reserving the row
inside the transaction that does the work fixes all four: the unique index rejects the second
reservation, a failure rolls the reservation back so the client can retry, and the persisted
status and body make the replay identical to the original response.
# spec/models/user_spec.rb
RSpec.describe User do
it "upserts idempotently" do
user_data = {email: "test@example.com", name: "Test User"}
2.times do
User.upsert(user_data, unique_by: :email)
end
expect(User.where(email: "test@example.com").count).to eq(1)
end
end
Reliability & Resilience Testing
Applications with external dependencies SHOULD test failure scenarios using chaos engineering tools like Toxiproxy33.
# Gemfile
group :test do
gem "toxiproxy"
end
# spec/support/toxiproxy.rb
RSpec.configure do |config|
config.before(:suite) do
Toxiproxy.populate([
{
name: "postgres",
listen: "127.0.0.1:5433",
upstream: "127.0.0.1:5432"
}
])
end
end
# spec/integration/payment_resilience_spec.rb
RSpec.describe "Payment gateway resilience" do
it "handles network latency" do
Toxiproxy[:payment_gateway].downstream(:latency, latency: 2000).apply do
expect {
PaymentService.charge(amount: 100)
}.to raise_error(PaymentService::TimeoutError)
end
end
it "handles connection failures" do
Toxiproxy[:payment_gateway].down do
expect {
PaymentService.charge(amount: 100)
}.to raise_error(PaymentService::ConnectionError)
end
end
end
# Gemfile
gem "semian" # Circuit breaker for Ruby[^17]
# config/initializers/semian.rb
Semian::NetHTTP.semian_configuration = proc do |host, port|
{
name: "#{host}:#{port}",
tickets: 10,
error_threshold: 3,
error_timeout: 10,
success_threshold: 2
}
end
# spec/jobs/order_processor_job_spec.rb
RSpec.describe OrderProcessorJob do
it "retries on transient failures" do
order = create(:order)
allow(PaymentService).to receive(:charge).and_raise(Net::ReadTimeout).twice
allow(PaymentService).to receive(:charge).and_return(true)
described_class.perform_now(order.id)
expect(order.reload.status).to eq("paid")
end
end
Compatibility Testing
Gems and libraries SHOULD test compatibility across supported Rails and Ruby versions using Appraisal34.
# Gemfile
group :development, :test do
gem "appraisal"
end
# Appraisals
appraise "rails-8.0" do
gem "rails", "~> 8.0.0"
end
appraise "rails-8.1" do
gem "rails", "~> 8.1.0"
end
# Generate gemfiles
bundle exec appraisal install
# Run tests across versions
bundle exec appraisal rspec
# .github/workflows/test.yml
strategy:
matrix:
ruby: ["3.4", "4.0"]
rails: ["8.0", "8.1"]
steps:
- uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0
with:
ruby-version: ${{ matrix.ruby }}
- run: bundle exec appraisal rails-${{ matrix.rails }} rspec
Internationalization Testing
Applications supporting multiple locales MUST test localization and character encoding.
# spec/support/i18n.rb
RSpec.configure do |config|
config.around(:each, locale: true) do |example|
original_locale = I18n.locale
I18n.locale = example.metadata[:locale]
example.run
I18n.locale = original_locale
end
end
# spec/models/product_spec.rb
RSpec.describe Product do
describe "#localized_name", locale: :es do
it "returns Spanish translation" do
product = create(:product)
expect(product.localized_name).to eq("Producto de Prueba")
end
end
end
# spec/system/localization_spec.rb
RSpec.describe "Localization" do
it "displays content in user's locale" do
I18n.available_locales.each do |locale|
visit root_path(locale: locale)
expect(page).to have_content(I18n.t("welcome.title", locale: locale))
end
end
end
# spec/i18n_spec.rb
RSpec.describe "Translation coverage" do
it "has translations for all locales" do
I18n.available_locales.each do |locale|
missing = I18n.t(".", locale: locale).select { |k, v| v.is_a?(String) && v.start_with?("translation missing") }
expect(missing).to be_empty, "Missing translations in #{locale}: #{missing.keys}"
end
end
end
# spec/models/user_spec.rb
RSpec.describe User do
it "handles UTF-8 characters" do
user = create(:user, name: "José María 中文 🎉")
expect(user.reload.name).to eq("José María 中文 🎉")
end
end
Data Integrity Testing
Database constraints and migrations MUST be tested to ensure data integrity.
# Gemfile
gem "strong_migrations"
# spec/migrations/add_user_status_spec.rb
RSpec.describe AddUserStatus, type: :migration do
it "adds status column with default" do
migrate!
User.reset_column_information
user = User.create!(email: "test@example.com")
expect(user.status).to eq("active")
end
end
# spec/models/order_spec.rb
RSpec.describe Order do
describe "foreign key constraints" do
it "prevents orphaned orders" do
user = create(:user)
order = create(:order, user: user)
expect { user.destroy }.to raise_error(ActiveRecord::InvalidForeignKey)
end
end
describe "check constraints" do
it "enforces positive quantity" do
order = build(:order, quantity: -1)
expect(order.save).to be(false)
expect(order.errors[:quantity]).to include("must be greater than 0")
end
end
end
# spec/support/transactional_fixtures.rb
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.around(:each, no_transaction: true) do |example|
original = config.use_transactional_fixtures
config.use_transactional_fixtures = false
example.run
config.use_transactional_fixtures = original
end
end
# spec/features/checkout_spec.rb
RSpec.describe "Checkout process", no_transaction: true do
it "rolls back on payment failure" do
initial_count = Order.count
visit checkout_path
# Payment fails...
expect(Order.count).to eq(initial_count)
end
end
A/B Testing & Feature Flags
Applications using feature flags or A/B tests MUST test both enabled and disabled states using tools like Flipper35 and Split36.
# Gemfile
gem "flipper"
gem "flipper-active_record"
gem "split"
# config/initializers/flipper.rb
Flipper.configure do |config|
config.default do
adapter = Flipper::Adapters::ActiveRecord.new
Flipper.new(adapter)
end
end
# spec/features/feature_flags_spec.rb
RSpec.describe "Feature flags" do
it "shows new UI when enabled" do
Flipper.enable(:new_dashboard, user)
visit dashboard_path
expect(page).to have_css(".new-dashboard")
end
it "shows old UI when disabled" do
Flipper.disable(:new_dashboard)
visit dashboard_path
expect(page).to have_css(".old-dashboard")
end
end
# spec/controllers/experiments_controller_spec.rb
RSpec.describe ExperimentsController do
describe "A/B test variants" do
it "assigns users to control group" do
allow(Split::Helper).to receive(:ab_test).with("checkout_flow").and_return("control")
get :show
expect(response).to render_template("checkout/standard")
end
it "assigns users to experiment group" do
allow(Split::Helper).to receive(:ab_test).with("checkout_flow").and_return("simplified")
get :show
expect(response).to render_template("checkout/simplified")
end
end
end
# spec/models/user_spec.rb
RSpec.describe User do
describe "feature gate behavior" do
it "accesses premium features when enabled" do
user = create(:user)
Flipper.enable(:premium_features, user)
expect(user.can_access_premium?).to be(true)
end
it "denies access when feature disabled" do
user = create(:user)
Flipper.disable(:premium_features)
expect(user.can_access_premium?).to be(false)
end
end
end
See Also
- Ruby Style Guide - Base Ruby conventions that apply to Rails
- Testing Guide - General testing principles and patterns
- CI Guide - Continuous integration best practices
References
Footnotes
-
StandardRB - Ruby style guide, linter, and formatter. Zero-config solution for consistent Ruby code style. https://github.com/standardrb/standard ↩ ↩2
-
standard-rails - Rails-specific StandardRB plugin with additional RuboCop rules for Rails best practices. https://github.com/standardrb/standard-rails ↩ ↩2 ↩3
-
Brakeman - Static analysis security scanner for Ruby on Rails applications. Detects common vulnerabilities without executing code. https://brakemanscanner.org/ | GitHub ↩ ↩2
-
RSpec Rails - RSpec testing framework extensions for Ruby on Rails. Provides Rails-specific matchers and helpers. https://rspec.info/ | GitHub ↩ ↩2
-
SimpleCov - Code coverage analysis tool for Ruby. Generates coverage reports during test execution. https://github.com/simplecov-ruby/simplecov ↩
-
Bullet - Helps detect N+1 queries and unused eager loading. Essential for Rails performance optimization. https://github.com/flyerhzm/bullet ↩ ↩2
-
rack-mini-profiler - Profiler for development and production Ruby rack apps. Shows SQL queries, view rendering times, and memory usage. https://github.com/MiniProfiler/rack-mini-profiler ↩ ↩2
-
Rack::Attack - Rack middleware for blocking and throttling abusive requests. Battle-tested rate limiting with 33+ million downloads. https://github.com/rack/rack-attack ↩ ↩2
-
standard-sorbet - Sorbet-specific StandardRB plugin for type checking integration. https://github.com/standardrb/standard-sorbet ↩
-
Sorbet - Fast, powerful type checker designed for Ruby. https://sorbet.org/ | GitHub ↩
-
Devise - Flexible authentication solution for Rails with Warden. Industry standard for complex authentication requirements. https://github.com/heartcombo/devise ↩ ↩2
-
Solid Queue - Database-backed Active Job backend. Replaces Redis/Sidekiq for most use cases in Rails 8+. https://github.com/rails/solid_queue ↩ ↩2
-
Solid Cache - Database-backed Rails cache store. Replaces Redis/Memcached with disk-based caching. https://github.com/rails/solid_cache ↩
-
Solid Cable - Database-backed Action Cable adapter. Replaces Redis for WebSocket pubsub in Rails 8+. https://github.com/rails/solid_cable ↩
-
Hotwire - HTML over the wire. The default frontend approach in Rails 8+ combining Turbo and Stimulus for modern UIs without heavy JavaScript. https://hotwired.dev/ ↩
-
Turbo - Core library of Hotwire for page navigation, partial updates (frames), and real-time updates (streams). https://turbo.hotwired.dev/ | GitHub ↩
-
Stimulus - Modest JavaScript framework for the HTML you already have. Adds behavior to HTML via data attributes. https://stimulus.hotwired.dev/ | GitHub ↩
-
ViewComponent - Framework for creating reusable, testable, and encapsulated view components in Ruby on Rails. By GitHub. https://viewcomponent.org/ | GitHub ↩
-
OmniAuth - Flexible authentication system utilizing Rack middleware. Enables OAuth integration with third-party providers. https://github.com/omniauth/omniauth ↩
-
devise-two-factor - TOTP second factor for Devise. Devise core provides no MFA module. https://github.com/devise-two-factor/devise-two-factor ↩
-
Pundit - Minimal authorization library using plain Ruby objects. Encapsulates access rules in testable policy classes. https://github.com/varvet/pundit ↩
-
memory_profiler - Memory profiling routines for Ruby. Provides detailed allocation reports for debugging memory issues. https://github.com/SamSaffron/memory_profiler ↩
-
AppSignal - Application performance monitoring and error tracking for Ruby. GDPR compliant with request-based pricing. https://www.appsignal.com/ | GitHub ↩
-
Skylight - Rails-focused application profiler and performance monitoring. Simple pricing with deep Rails integration. https://www.skylight.io/ ↩
-
active_record_query_trace - Logs backtrace of all SQL queries executed by Active Record. Helps identify query origins in large applications. https://github.com/brunofacca/active-record-query-trace ↩
-
strong_migrations - Catches unsafe migrations in development to prevent production downtime and table locks. https://github.com/ankane/strong_migrations ↩
-
RSpec - Behavior-driven development framework for Ruby. Provides expressive DSL for writing tests. https://rspec.info/ | GitHub ↩
-
Capybara - Web-based acceptance test framework for Ruby. Simulates user interactions with web applications. https://github.com/teamcapybara/capybara ↩
-
Cucumber - BDD framework for writing human-readable acceptance tests using Gherkin syntax. https://cucumber.io/ | GitHub ↩
-
Puma - Modern, concurrent web server for Ruby/Rack applications. Multi-threaded for high performance. https://puma.io/ | GitHub ↩
-
Sidekiq - Background job processor for Ruby using multi-threading. Efficient alternative to single-threaded solutions. https://sidekiq.org/ | GitHub ↩ ↩2
-
sidekiq-unique-jobs - Ensures uniqueness of Sidekiq jobs to prevent duplicate processing. https://github.com/mhenrixon/sidekiq-unique-jobs ↩
-
Toxiproxy - Framework for simulating network conditions and failures. Chaos engineering tool for testing resilience. https://github.com/Shopify/toxiproxy ↩
-
Appraisal - Library for testing gems against multiple versions of dependencies. https://github.com/thoughtbot/appraisal ↩
-
Flipper - Feature flag library for Ruby applications. Supports various backends and rollout strategies. https://www.flippercloud.io/ | GitHub ↩
-
Split - Rack-based A/B testing framework for Ruby. Enables experimentation and conversion tracking. https://github.com/splitrb/split ↩