← Back to Skills

Rails Patterns

ruby

Rails-specific patterns and best practices. Covers ActiveRecord, controllers, services, and testing with RSpec.

Rails Patterns

Guidelines for writing Rails applications following community best practices.

When to Activate

Model Patterns

Use scopes for reusable queries

class User < ApplicationRecord
  scope :active, -> { where(active: true) }
  scope :recent, -> { order(created_at: :desc) }
  scope :with_role, ->(role) { where(role: role) }
end

# Usage
User.active.recent.with_role(:admin)

Validate at the right level

class Order < ApplicationRecord
  # Database-enforced constraints
  validates :email, presence: true
  validates :amount, numericality: { greater_than: 0 }
  
  # Business logic validation
  validate :inventory_available, on: :create
  
  private
  
  def inventory_available
    return if line_items.all?(&:in_stock?)
    errors.add(:base, "Some items are out of stock")
  end
end

Use callbacks sparingly

# GOOD - simple, side-effect free
before_validation :normalize_email

# BAD - external side effects in callbacks
after_create :send_welcome_email  # Move to service/job
after_save :sync_to_external_api  # Move to service/job

Controller Patterns

Keep controllers thin

class OrdersController < ApplicationController
  def create
    result = CreateOrder.call(order_params, current_user)
    
    if result.success?
      redirect_to result.order, notice: "Order created"
    else
      @order = result.order
      render :new, status: :unprocessable_entity
    end
  end
  
  private
  
  def order_params
    params.require(:order).permit(:product_id, :quantity)
  end
end

Use strong parameters correctly

def user_params
  params.require(:user).permit(
    :name, 
    :email,
    address_attributes: [:street, :city, :zip]
  )
end

Service Objects

Use a consistent interface

class CreateOrder
  def self.call(...)
    new(...).call
  end
  
  def initialize(params, user)
    @params = params
    @user = user
  end
  
  def call
    order = Order.new(@params.merge(user: @user))
    
    if order.save
      NotifyOrderJob.perform_later(order)
      Result.success(order: order)
    else
      Result.failure(order: order, errors: order.errors)
    end
  end
end

Keep services focused

# GOOD - single responsibility
class ProcessPayment
  def call(order)
    # Only payment processing logic
  end
end

class SendOrderConfirmation
  def call(order)
    # Only notification logic
  end
end

# BAD - doing too much
class CreateOrder
  def call
    validate_inventory
    calculate_totals
    process_payment
    send_confirmation
    update_analytics
    sync_to_warehouse
  end
end

Testing with RSpec

Use factories, not fixtures

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
    email { Faker::Internet.email }
    
    trait :admin do
      role { :admin }
    end
  end
end

# Usage
create(:user, :admin)

Test behavior, not implementation

# GOOD - tests behavior
it "creates an order for the user" do
  expect { described_class.call(params, user) }
    .to change(user.orders, :count).by(1)
end

# BAD - tests implementation
it "calls Order.create with params" do
  expect(Order).to receive(:create).with(params)
  described_class.call(params, user)
end

Use request specs for APIs

RSpec.describe "Orders API", type: :request do
  describe "POST /api/orders" do
    it "creates an order" do
      post "/api/orders", params: { order: valid_params }
      
      expect(response).to have_http_status(:created)
      expect(json_response[:id]).to be_present
    end
  end
end

Database

Add indexes for foreign keys and queried columns

class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.references :user, null: false, foreign_key: true
      t.string :status, null: false, default: "pending"
      t.timestamps
    end
    
    add_index :orders, :status
    add_index :orders, [:user_id, :status]
  end
end

Avoid N+1 queries

# GOOD - eager loading
def index
  @orders = Order.includes(:user, :line_items).recent
end

# BAD - N+1
def index
  @orders = Order.recent
  # Each order.user triggers a query
end

Background Jobs

Use jobs for slow operations

class SendWelcomeEmailJob < ApplicationJob
  queue_as :default
  
  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

# Usage - pass IDs, not objects
SendWelcomeEmailJob.perform_later(user.id)

Make jobs idempotent

class ProcessPaymentJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)
    
    # Skip if already processed
    return if order.paid?
    
    PaymentGateway.charge(order)
    order.update!(status: :paid)
  end
end