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:
def index
@orders = Order.all
endIn the view, you render each order with its customer:
<% @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.
def index
@orders = Order.includes(:customer).all
endNow you get two queries total: one for orders, one for all related customers. For nested associations, chain them:
@orders = Order.includes(:customer, :line_items).allWhen 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:
class LineItem < ApplicationRecord
belongs_to :invoice
after_save :update_invoice_total
def update_invoice_total
invoice.update(total: invoice.line_items.sum(:amount))
end
endThis 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:
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).callIf 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.
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
endThe 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:
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
endFor compound lookups, consider multi-column indexes:
add_index :comments, [:post_id, :user_id], unique: trueThis 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:
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
endThis controller mixes validation, domain logic, external calls, and HTTP concerns. Testing requires stubbing services and database setup.
Extract into service objects or form objects:
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
endNow 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
includesfeels 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, orpreloadto 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.






Comments
Loading comments…