crefft Source 2026.10.4

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:

TaskToolCommand
LintStandardRB1 + standard-rails2bundle exec standardrb
SecurityBrakeman3bundle exec brakeman
TestRSpec Rails4bundle exec rspec
CoverageSimpleCov5via test suite
N+1 DetectionBullet6via test suite
Profilingrack-mini-profiler7?pp=flamegraph
Rate LimitingRails built-in or Rack::Attack8via 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:

  • User model with has_secure_password
  • Session model for session tracking
  • SessionsController for 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:

CapabilityGenerated by bin/rails generate authentication
Sign in, sign out, session trackingYes
Password reset over emailYes
Rate-limited session creationYes
Sign-up and email confirmationNo
TOTP, WebAuthn, or passkeysNo
OAuth account linkingNo

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?

FeatureSolid QueueSidekiq
Redis requiredNoYes
Database backendPostgreSQL, MySQL, SQLiteRedis only
Concurrency controlsBuilt-inRequires sidekiq-unique-jobs
Recurring jobsBuilt-inRequires sidekiq-cron
InfrastructureSimplerRequires 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.

Hotwire: Turbo + Stimulus

Projects SHOULD use Hotwire15 for modern, interactive UIs without writing custom JavaScript. Hotwire combines Turbo16 for HTML-over-the-wire updates with Stimulus17 for lightweight JavaScript sprinkles.

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

ActionDescription
appendAdd content to end of target
prependAdd content to start of target
replaceReplace entire target element
updateReplace content inside target
removeRemove target element
beforeInsert content before target
afterInsert 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:

FeatureBuilt-in (Rails 8+)Rack::Attack
Simple endpoint throttlingYesYes
IP blocking/safelistingNoYes
Fail2ban patternsNoYes
Custom response formatLimitedFull control
Distributed (Redis)Via cache storeNative support
Request inspectionLimitedFull 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
APMBest ForPricing Model
AppSignal23Ruby/Rails teams, GDPR requirementsRequest-based ($23+/month)
Skylight24Rails-only, simple performance tuningRequest-based ($20+/month)
New RelicEnterprise, polyglot environmentsHost-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

References

Footnotes

  1. StandardRB - Ruby style guide, linter, and formatter. Zero-config solution for consistent Ruby code style. https://github.com/standardrb/standard ↩ ↩2

  2. standard-rails - Rails-specific StandardRB plugin with additional RuboCop rules for Rails best practices. https://github.com/standardrb/standard-rails ↩ ↩2 ↩3

  3. Brakeman - Static analysis security scanner for Ruby on Rails applications. Detects common vulnerabilities without executing code. https://brakemanscanner.org/ | GitHub ↩ ↩2

  4. RSpec Rails - RSpec testing framework extensions for Ruby on Rails. Provides Rails-specific matchers and helpers. https://rspec.info/ | GitHub ↩ ↩2

  5. SimpleCov - Code coverage analysis tool for Ruby. Generates coverage reports during test execution. https://github.com/simplecov-ruby/simplecov ↩

  6. Bullet - Helps detect N+1 queries and unused eager loading. Essential for Rails performance optimization. https://github.com/flyerhzm/bullet ↩ ↩2

  7. 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

  8. 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

  9. standard-sorbet - Sorbet-specific StandardRB plugin for type checking integration. https://github.com/standardrb/standard-sorbet ↩

  10. Sorbet - Fast, powerful type checker designed for Ruby. https://sorbet.org/ | GitHub ↩

  11. Devise - Flexible authentication solution for Rails with Warden. Industry standard for complex authentication requirements. https://github.com/heartcombo/devise ↩ ↩2

  12. Solid Queue - Database-backed Active Job backend. Replaces Redis/Sidekiq for most use cases in Rails 8+. https://github.com/rails/solid_queue ↩ ↩2

  13. Solid Cache - Database-backed Rails cache store. Replaces Redis/Memcached with disk-based caching. https://github.com/rails/solid_cache ↩

  14. Solid Cable - Database-backed Action Cable adapter. Replaces Redis for WebSocket pubsub in Rails 8+. https://github.com/rails/solid_cable ↩

  15. 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/ ↩

  16. Turbo - Core library of Hotwire for page navigation, partial updates (frames), and real-time updates (streams). https://turbo.hotwired.dev/ | GitHub ↩

  17. Stimulus - Modest JavaScript framework for the HTML you already have. Adds behavior to HTML via data attributes. https://stimulus.hotwired.dev/ | GitHub ↩

  18. ViewComponent - Framework for creating reusable, testable, and encapsulated view components in Ruby on Rails. By GitHub. https://viewcomponent.org/ | GitHub ↩

  19. OmniAuth - Flexible authentication system utilizing Rack middleware. Enables OAuth integration with third-party providers. https://github.com/omniauth/omniauth ↩

  20. devise-two-factor - TOTP second factor for Devise. Devise core provides no MFA module. https://github.com/devise-two-factor/devise-two-factor ↩

  21. Pundit - Minimal authorization library using plain Ruby objects. Encapsulates access rules in testable policy classes. https://github.com/varvet/pundit ↩

  22. memory_profiler - Memory profiling routines for Ruby. Provides detailed allocation reports for debugging memory issues. https://github.com/SamSaffron/memory_profiler ↩

  23. AppSignal - Application performance monitoring and error tracking for Ruby. GDPR compliant with request-based pricing. https://www.appsignal.com/ | GitHub ↩

  24. Skylight - Rails-focused application profiler and performance monitoring. Simple pricing with deep Rails integration. https://www.skylight.io/ ↩

  25. 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 ↩

  26. strong_migrations - Catches unsafe migrations in development to prevent production downtime and table locks. https://github.com/ankane/strong_migrations ↩

  27. RSpec - Behavior-driven development framework for Ruby. Provides expressive DSL for writing tests. https://rspec.info/ | GitHub ↩

  28. Capybara - Web-based acceptance test framework for Ruby. Simulates user interactions with web applications. https://github.com/teamcapybara/capybara ↩

  29. Cucumber - BDD framework for writing human-readable acceptance tests using Gherkin syntax. https://cucumber.io/ | GitHub ↩

  30. Puma - Modern, concurrent web server for Ruby/Rack applications. Multi-threaded for high performance. https://puma.io/ | GitHub ↩

  31. Sidekiq - Background job processor for Ruby using multi-threading. Efficient alternative to single-threaded solutions. https://sidekiq.org/ | GitHub ↩ ↩2

  32. sidekiq-unique-jobs - Ensures uniqueness of Sidekiq jobs to prevent duplicate processing. https://github.com/mhenrixon/sidekiq-unique-jobs ↩

  33. Toxiproxy - Framework for simulating network conditions and failures. Chaos engineering tool for testing resilience. https://github.com/Shopify/toxiproxy ↩

  34. Appraisal - Library for testing gems against multiple versions of dependencies. https://github.com/thoughtbot/appraisal ↩

  35. Flipper - Feature flag library for Ruby applications. Supports various backends and rollout strategies. https://www.flippercloud.io/ | GitHub ↩

  36. Split - Rack-based A/B testing framework for Ruby. Enables experimentation and conversion tracking. https://github.com/splitrb/split ↩