There’s no shortage of “Rails best practices” content out there. Most of it is written by people who’ve read a lot about Rails without shipping enough of it. Here’s what I actually enforce on projects — practices that have prevented production incidents, saved debugging hours, and made code maintainable over years instead of months.
Keep Your Controllers Thin
Controllers should do one thing: receive a request, pass it to the right business logic, and return a response. That’s it.
When controllers start containing domain logic — validation rules, complex conditionals, data transformation — they become impossible to test cleanly and painful to reason about. More importantly, that logic is now locked to the HTTP layer. You can’t reuse it in background jobs, rake tasks, or APIs without duplication.
The fix is service objects. A plain Ruby class that takes inputs and produces a result. It’s testable in isolation, reusable anywhere, and keeps your controller readable.
class OrdersController < ApplicationController
def create
result = CreateOrder.call(order_params, current_user)
if result.success?
render json: result.order, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
end
CreateOrder contains the logic. The controller just coordinates.
Strong Parameters, Always
Every Rails developer knows params.require(:user).permit(:name, :email). But on large projects I see this drift — developers adding new attributes without thinking about whether they should be user-controlled, or using params.permit! to allow everything “temporarily.”
Permitting all parameters is a mass assignment vulnerability. There is no situation where it’s the right call in production code. If you find yourself writing permit!, stop and think about what you’re actually allowing.
Write Migrations That Are Safe to Run in Production
Database migrations are the operation that scares me most in production. Unlike application code, you can’t just roll back a migration if something goes wrong — especially once data has been affected.
Rules I follow:
Never remove a column in the same migration that stops using it. Deploy code first that ignores the column, then remove the column in a separate migration. This is “down-first” migration strategy, and it prevents errors when your app is partially deployed.
Adding NOT NULL columns requires a default. If you add a NOT NULL constraint without a default and there’s existing data, your migration fails. Always provide a default or migrate existing data first.
Large table migrations need careful planning. Adding an index on a large table in production can lock the table. Use algorithm: :concurrently in PostgreSQL to avoid this. Be aware of the implications.
Don’t assume migrations are instantaneous. Test them on a copy of production data to understand the real duration before running them.
Indexes Are Not Optional
Every foreign key column needs an index. Every column you sort by. Every column you filter on. If you’re querying by something, it needs an index or your application will crawl under load.
The add-migration for a new association should almost always include an index on the foreign key column:
add_reference :orders, :customer, null: false, foreign_key: true, index: true
Use the Bullet gem in development to catch N+1 queries and missing indexes automatically.
Environment-Specific Configuration Belongs in Credentials
Hardcoded API keys and configuration values in code are a security problem and a deployment problem. Use Rails credentials (config/credentials.yml.enc) for secrets, environment variables for environment-specific values, and never commit .env files with real values.
The pattern that works:
# config/credentials.yml.enc
stripe:
secret_key: sk_live_...
webhook_secret: whsec_...
# In code
Stripe.api_key = Rails.application.credentials.stripe[:secret_key]
Background Jobs Need Idempotency
Active Job with Sidekiq is excellent. But jobs fail. They get retried. Sidekiq can run a job multiple times if something goes wrong at the wrong moment.
Every background job needs to be safe to run multiple times with the same input — that’s idempotency. Build it in from the start.
class ProcessPaymentJob < ApplicationJob
def perform(order_id)
order = Order.find(order_id)
return if order.payment_processed? # Already done — skip
# ... process payment
order.update!(payment_processed: true)
end
end
N+1 Queries Are Not a “Someday” Problem
The Bullet gem will tell you about N+1 queries in development. Don’t ignore them. What’s a 10ms query in development on 5 records is a 2-second query in production on 500.
Use includes(), eager_load(), or preload() depending on your needs:
# Bad: fires a query for each order's customer
orders = Order.all
orders.each { |o| puts o.customer.name }
# Good: eager loads customers in one query
orders = Order.includes(:customer).all
orders.each { |o| puts o.customer.name }
Test at the Right Level
Unit tests for business logic (service objects, models). Request specs for API endpoints (tests the full stack). System tests for critical user flows (login, checkout, whatever would lose money if broken).
Don’t write tests just to hit a coverage percentage. Write tests for the logic that matters — the code where a bug costs money or breaks trust.
My minimum on any project: test every service object, test every API endpoint’s happy path and at least one failure path, and test the critical user flows end-to-end.
Logging That’s Actually Useful
Rails’ default logging is verbose but not always useful. In production, you want structured logging that’s queryable.
Use the lograge gem to get structured, one-line-per-request logs. Add custom fields for the user ID, request ID, and any other context that would help you debug production issues.
When something goes wrong at 2am (and it will), you’ll be grateful you can filter logs by user ID and see exactly what they did.
Handle Errors, Don’t Ignore Them
begin
payment.charge
rescue Stripe::StripeError => e
# Log it, notify the team, tell the user something helpful
Rails.logger.error("Stripe error: #{e.message}")
Sentry.capture_exception(e)
render json: { error: "Payment failed" }, status: :payment_required
end
Don’t swallow errors silently. Don’t let them crash unhandled. Handle them, log them, and tell the user something actionable.
These aren’t exotic practices — they’re the fundamentals that separate code you can maintain for years from code that becomes a liability. If your Rails application has drifted away from these, that’s fixable. Let’s talk about what a recovery plan looks like.