Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 080 / 100

Common Rails mistakes: a practical guide for developers

Habits that cause pain.

Expona Admin6 min read

After eighty days of building with Rails, you've seen patterns emerge across your codebases. Some of them speed you up; others silently degrade performance or create maintenance nightmares. This guide walks through common Rails mistakes: a practical guide for developers who want to avoid the habits that cause real pain in production systems.

N+1 Queries

The N+1 query problem remains one of the most frequent performance killers we encounter. It happens when you load a collection of records, then loop through them to fetch related data, triggering one query per iteration. In a controller or view rendering thousands of items, this compounds fast.

Here's a typical scenario. You're building an order dashboard:

ruby
def index
  @orders = Order.all
end

In the view, you render each order with its customer:

ruby
<% @orders.each do |order| %>
  <tr>
    <td><%= order.customer.name %></td>
    <td><%= order.total %></td>
  </tr>
<% end %>

This generates one query for all orders, then one query per order to load the customer. If you have 100 orders, you've made 101 queries.

The fix is straightforward: use includes to eager-load the association.

ruby
def index
  @orders = Order.includes(:customer).all
end

Now you get two queries total: one for orders, one for all related customers. For nested associations, chain them:

ruby
@orders = Order.includes(:customer, :line_items).all

When dealing with more complex trees, consider joins for filtering or preload vs eager_load depending on your query shape. We often use tools like Bullet in development to catch these automatically. Enable it early; it pays for itself immediately.

Business Logic in Callbacks

Rails callbacks (before_save, after_create, before_destroy) feel powerful and convenient. They're also a common source of bugs and hard-to-follow code paths. When business logic lives inside callbacks, it runs implicitly whenever the triggering action fires—which may be more often than you think.

Imagine a system that calculates an invoice total whenever line items change:

ruby
class LineItem < ApplicationRecord
  belongs_to :invoice
  
  after_save :update_invoice_total
  
  def update_invoice_total
    invoice.update(total: invoice.line_items.sum(:amount))
  end
end

This looks harmless until you bulk-import 1,000 line items. Each save triggers the callback, recalculating the invoice total 1,000 times. You've also hidden the side effect; future developers might not realize that saving a line item touches the invoice.

Better approach: make the logic explicit with service objects or explicit method calls:

ruby
class LineItem < ApplicationRecord
  belongs_to :invoice
end

class RecalculateInvoiceTotal
  def initialize(invoice)
    @invoice = invoice
  end
  
  def call
    @invoice.update(total: @invoice.line_items.sum(:amount))
  end
end

# In your controller or job
invoice = Invoice.find(params[:id])
RecalculateInvoiceTotal.new(invoice).call

If you must use callbacks, keep them thin and focused. Avoid callbacks that trigger other callbacks or reach across associations. Reserve them for simple transformations like timestamp normalization or slug generation.

Missing Database Indexes

Rails makes it easy to add migrations and create tables. It's equally easy to forget indexes until your queries crawl on real data. Indexes matter when you filter, join, or sort on a column regularly.

A common pattern: you add a user_id foreign key to comments, but forget to index it.

ruby
class CreateComments < ActiveRecord::Migration[7.0]
  def change
    create_table :comments do |t|
      t.references :user
      t.references :post
      t.text :body
      t.timestamps
    end
  end
end

The references method creates a foreign key but, by default, does not add an index. When you query Comment.where(user_id: user.id) on a table with millions of rows, you trigger a full table scan.

Always add indexes explicitly:

ruby
class CreateComments < ActiveRecord::Migration[7.0]
  def change
    create_table :comments do |t|
      t.references :user, index: true
      t.references :post, index: true
      t.text :body
      t.timestamps
    end
  end
end

For compound lookups, consider multi-column indexes:

ruby
add_index :comments, [:post_id, :user_id], unique: true

This speeds up queries like Comment.where(post_id: post.id, user_id: user.id). Review your slow query logs regularly (in development and production) to catch missing indexes before they cause outages.

Fat Controllers

Controllers grow fast. You add a line of validation logic, then some data transformation, then an external API call. Before long, your controller action is 50+ lines, testing it requires mocking half the app, and it's unclear what belongs where.

A common offender:

ruby
class InvoicesController < ApplicationController
  def create
    invoice = Invoice.new(invoice_params)
    
    # Validation
    unless invoice.valid?
      return render :new, status: :unprocessable_entity
    end
    
    # Business logic
    invoice.number = "INV-#{Date.today.strftime('%Y%m%d')}-#{SecureRandom.hex(3)}"
    invoice.discount = calculate_customer_discount(invoice.customer)
    invoice.tax = calculate_tax(invoice)
    
    # External integration
    response = NotificationService.send_invoice_email(invoice)
    unless response.success?
      return render :new, status: :unprocessable_entity, alert: response.error
    end
    
    invoice.save!
    redirect_to invoice, notice: 'Invoice created'
  end
  
  private
  
  def calculate_customer_discount(customer)
    # Complex logic here
  end
  
  def calculate_tax(invoice)
    # Complex logic here
  end
end

This controller mixes validation, domain logic, external calls, and HTTP concerns. Testing requires stubbing services and database setup.

Extract into service objects or form objects:

ruby
class InvoicesController < ApplicationController
  def create
    @invoice_form = InvoiceForm.new(invoice_params)
    
    if @invoice_form.save
      redirect_to @invoice_form.invoice, notice: 'Invoice created'
    else
      render :new, status: :unprocessable_entity
    end
  end
  
  private
  
  def invoice_params
    params.require(:invoice).permit(:customer_id, :amount)
  end
end

class InvoiceForm
  include ActiveModel::Model
  
  attr_accessor :customer_id, :amount, :invoice
  
  validates :customer_id, :amount, presence: true
  
  def save
    return false unless valid?
    
    @invoice = CreateInvoice.new(customer_id, amount).call
    @invoice.persisted?
  end
end

class CreateInvoice
  def initialize(customer_id, amount)
    @customer_id = customer_id
    @amount = amount
  end
  
  def call
    invoice = Invoice.new(customer_id: @customer_id, amount: @amount)
    invoice.number = generate_invoice_number
    invoice.discount = CustomerDiscountCalculator.new(@customer_id).call
    invoice.tax = TaxCalculator.new(invoice).call
    
    NotificationService.send_invoice_email(invoice)
    invoice.save
    invoice
  end
  
  private
  
  def generate_invoice_number
    "INV-#{Date.today.strftime('%Y%m%d')}-#{SecureRandom.hex(3)}"
  end
end

Now the controller is thin and testable. Business logic lives in dedicated classes. Each class has a single responsibility, making it easier to test, reuse, and modify.

Common Mistakes

  • Forgetting to test N+1 fixes: Adding includes feels like it should work, but race conditions and stale associations can hide bugs. Write tests that verify query count using ActiveSupport::Notifications or Bullet.
  • Using callbacks for side effects: If your callback sends an email, syncs with an external service, or modifies another record, you've hidden business logic. Make it explicit in your application flow.
  • Assuming indexes exist: Rails doesn't auto-index foreign keys or frequently-filtered columns. Audit your queries and migrations; add indexes proactively.
  • Testing fat controllers in isolation: If your controller is 30 lines, you're probably testing too much in one place. Push logic out; your tests become faster and more focused.

Key Takeaways

  • Use includes, joins, or preload to eliminate N+1 queries. Validate with Bullet or query logging in development.
  • Keep callbacks thin. Move business logic into service objects or form objects so it's explicit and testable.
  • Always index foreign keys, frequently-filtered columns, and join conditions. Review slow query logs to catch missing indexes.
  • Extract logic from controllers into dedicated classes with single responsibilities. Thin controllers are easier to test, modify, and reason about.
  • These patterns aren't Rails-specific; they reflect principles of good software design. Catching them early saves hours of debugging in production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles